mirror of https://github.com/openclaw/openclaw.git
Merge 3c941eae23 into c4265a5f16
This commit is contained in:
commit
0b52975f46
|
|
@ -0,0 +1,214 @@
|
||||||
|
---
|
||||||
|
summary: "Path-scoped RWX permissions for file and exec tools"
|
||||||
|
read_when:
|
||||||
|
- Restricting which paths an agent can read, write, or execute
|
||||||
|
- Configuring per-agent filesystem access policies
|
||||||
|
- Hardening single-OS-user gateway deployments
|
||||||
|
title: "Access Policy"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Access policy
|
||||||
|
|
||||||
|
Access policy lets you restrict what paths an agent can **read**, **write**, or **execute** — independently of which binary is running. It enforces at two layers: the tool layer (read/write/edit/exec tools) and, on macOS, the OS layer via `sandbox-exec`.
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
The exec allowlist controls _which binaries_ an agent can run, but it cannot restrict _which paths_ those binaries touch. A permitted `/bin/ls` on `~/workspace` is equally permitted on `~/.ssh`. Access policy closes that gap by scoping permissions to path patterns instead of binary names.
|
||||||
|
|
||||||
|
## Config file
|
||||||
|
|
||||||
|
Access policy is configured in a **sidecar file** separate from `openclaw.json`:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.openclaw/access-policy.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The file is **optional** — if absent, all operations pass through unchanged with no enforcement. No restart is required when the file changes; it is read fresh on each agent turn.
|
||||||
|
|
||||||
|
## Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"agents": {
|
||||||
|
"*": {
|
||||||
|
"policy": {
|
||||||
|
"/**": "r--",
|
||||||
|
"/tmp/": "rwx",
|
||||||
|
"~/": "rw-",
|
||||||
|
"~/dev/": "rwx",
|
||||||
|
"~/.ssh/**": "---",
|
||||||
|
"~/.aws/**": "---"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"myagent": { "policy": { "~/private/": "rw-" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permission strings
|
||||||
|
|
||||||
|
Each rule value is a three-character string — one character per operation:
|
||||||
|
|
||||||
|
| Position | Letter | Meaning |
|
||||||
|
| -------- | --------- | ------------------------ |
|
||||||
|
| 0 | `r` / `-` | Read allowed / denied |
|
||||||
|
| 1 | `w` / `-` | Write allowed / denied |
|
||||||
|
| 2 | `x` / `-` | Execute allowed / denied |
|
||||||
|
|
||||||
|
Examples: `"rwx"` (full access), `"r--"` (read only), `"r-x"` (read + exec), `"---"` (deny all).
|
||||||
|
|
||||||
|
Use `"---"` to explicitly deny all access to a path — this is the deny mechanism. A rule with `"---"` always blocks regardless of broader rules, as long as it is the longest (most specific) matching pattern.
|
||||||
|
|
||||||
|
### Pattern syntax
|
||||||
|
|
||||||
|
- Patterns are path globs: `*` matches within a segment, `**` matches any depth.
|
||||||
|
- Trailing `/` is shorthand for `/**` — e.g. `"/tmp/"` matches everything under `/tmp`.
|
||||||
|
- `~` expands to the OS home directory (not `OPENCLAW_HOME`).
|
||||||
|
- On macOS, `/tmp`, `/var`, and `/etc` are transparently normalized from their `/private/*` real paths.
|
||||||
|
|
||||||
|
### Precedence
|
||||||
|
|
||||||
|
1. **`policy`** — longest matching glob wins (most specific pattern takes priority).
|
||||||
|
2. **Implicit fallback** — `"---"` (deny all) when no rule matches. Use `"/**": "r--"` (or any perm) as an explicit catch-all.
|
||||||
|
|
||||||
|
To deny a specific path, add a `"---"` rule that is more specific than any allow rule covering that path:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"policy": {
|
||||||
|
"/**": "r--",
|
||||||
|
"~/.ssh/**": "---"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`~/.ssh/**` is longer than `/**` so it wins for any path under `~/.ssh/`.
|
||||||
|
|
||||||
|
## Layers
|
||||||
|
|
||||||
|
```
|
||||||
|
agents["*"] → agents["myagent"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`agents["*"]`** — base policy applied to every agent. Put org-wide rules here. Can include both `policy` (path rules) and `scripts` (per-script overrides).
|
||||||
|
- **`agents["myagent"]`** — per-agent overrides merged on top of `agents["*"]`. `policy` rules are shallow-merged (agent wins on collision). `scripts` entries are deep-merged: the base `sha256` is always preserved and cannot be overridden by an agent block.
|
||||||
|
|
||||||
|
Named agents can also add their own `scripts` block, which is merged with the base scripts config.
|
||||||
|
|
||||||
|
## Per-script policy
|
||||||
|
|
||||||
|
The `scripts` block inside any agent config grants additional path permissions when a **specific binary** is the exec target. The override fires only when `resolvedArgv0` matches a key in `scripts` — it does not apply to unmatched exec calls.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"agents": {
|
||||||
|
"*": {
|
||||||
|
"policy": { "/**": "r--" },
|
||||||
|
"scripts": {
|
||||||
|
"policy": {
|
||||||
|
"/tmp/**": "rw-"
|
||||||
|
},
|
||||||
|
"~/bin/deploy.sh": {
|
||||||
|
"policy": { "~/deploy/**": "rwx" },
|
||||||
|
"sha256": "<hex>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"veda": {
|
||||||
|
"policy": { "~/.openclaw/agents/veda/workspace/**": "rwx" },
|
||||||
|
"scripts": {
|
||||||
|
"~/bin/veda-tool.sh": {
|
||||||
|
"policy": { "/opt/data/**": "r--" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `scripts["policy"]`
|
||||||
|
|
||||||
|
A flat `{ path: perm }` map of shared path rules. These rules are merged as the **base** for every per-script entry in the same `scripts` block, before the per-script `policy` is applied.
|
||||||
|
|
||||||
|
**Important:** `scripts["policy"]` only takes effect when an exec call matches one of the named script keys. If the `scripts` block has `scripts["policy"]` but no actual script entries, those rules are never applied.
|
||||||
|
|
||||||
|
### Per-script entries
|
||||||
|
|
||||||
|
Each key is the resolved absolute path to a script (tilde is expanded, symlinks are followed at match time).
|
||||||
|
|
||||||
|
- **`policy`** — path rules that add to or narrow the base policy for this script only. Override rules are emitted _after_ base rules in the OS sandbox profile so a script grant can reach inside a broadly denied subtree (last-match-wins semantics).
|
||||||
|
- **`sha256`** — optional SHA-256 hex of the script file. When set, exec is denied if the hash does not match. Best-effort integrity check — there is a small TOCTOU window between the hash read and kernel exec.
|
||||||
|
|
||||||
|
### Script override flow
|
||||||
|
|
||||||
|
When an exec call matches a script key:
|
||||||
|
|
||||||
|
1. `scripts["policy"]` shared rules are merged as a base.
|
||||||
|
2. The matching script's `policy` is merged on top (override key wins).
|
||||||
|
3. The resulting path rules are emitted _after_ the agent's main `policy` rules in the OS sandbox profile.
|
||||||
|
|
||||||
|
The `scripts` block is stripped from the policy after the match so it does not bleed into unrelated tool calls in the same agent turn.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
### Tool layer
|
||||||
|
|
||||||
|
Every read, write, edit, and exec tool call checks the resolved path against the active policy before executing. A denied path throws immediately — the operation never reaches the OS.
|
||||||
|
|
||||||
|
### OS layer (macOS)
|
||||||
|
|
||||||
|
On macOS, exec commands are additionally wrapped with `sandbox-exec` using a generated Seatbelt (SBPL) profile derived from the policy. This catches paths that expand at runtime (e.g. `cat $HOME/.ssh/id_rsa`) that config-level heuristics cannot intercept.
|
||||||
|
|
||||||
|
On Linux, a `bwrap` (bubblewrap) wrapper is generated instead.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
If the file exists but cannot be parsed, or contains structural errors (wrong nesting, misplaced keys), a clear error is logged and **all access is denied** (fail-closed) until the file is fixed:
|
||||||
|
|
||||||
|
```
|
||||||
|
[access-policy] Cannot parse ~/.openclaw/access-policy.json: ...
|
||||||
|
[access-policy] Failing closed (default: "---") until the file is fixed.
|
||||||
|
```
|
||||||
|
|
||||||
|
Common mistakes caught by the validator:
|
||||||
|
|
||||||
|
- `policy`, `rules`, `scripts`, or `base` placed at the top level instead of under `agents["*"]`
|
||||||
|
- Permission strings that are not exactly 3 characters (`"rwx"`, `"r--"`, `"---"`, etc.)
|
||||||
|
- `deny` or `default` keys inside agent blocks — these fields were removed; use `"---"` rules instead
|
||||||
|
|
||||||
|
### Bare directory paths
|
||||||
|
|
||||||
|
If a rule path has no glob suffix and resolves to a real directory (e.g. `"~/dev/openclaw"` instead of `"~/dev/openclaw/**"`), the validator auto-expands it to `/**` and logs a one-time diagnostic:
|
||||||
|
|
||||||
|
```
|
||||||
|
[access-policy] access-policy.policy["~/dev/openclaw"] is a directory — rule auto-expanded to "~/dev/openclaw/**" so it covers all contents.
|
||||||
|
```
|
||||||
|
|
||||||
|
A bare path without `/**` would match only the directory entry itself, not its contents.
|
||||||
|
|
||||||
|
Auto-expansion also applies to bare directory paths inside `scripts["policy"]` and per-script `policy` blocks.
|
||||||
|
|
||||||
|
## A2A trust scope
|
||||||
|
|
||||||
|
When an agent spawns a subagent, the subagent runs with its own agent identity and its own policy block applies. This is correct for standard OpenClaw subagent spawning.
|
||||||
|
|
||||||
|
For cross-agent MCP tool delegation (an orchestrator invoking a tool on behalf of a subagent via an MCP channel), the calling agent's identity governs — no automatic narrowing to the subagent's policy occurs. Explicit delegation controls are planned as a follow-up.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
**Metadata leak via directory listing.** `find`, `ls`, and shell globs use `readdir()` to enumerate directory contents, which is allowed. When content access is then denied at `open()`, the filenames are already visible in the error output. Content is protected; filenames are not. This is inherent to how OS-level enforcement works at the syscall level.
|
||||||
|
|
||||||
|
**Interpreter bypass of exec bit.** The `x` bit gates `execve()` on the file itself. Running `bash script.sh` executes bash (permitted), which reads the script as text (read permitted if `r` is set). The exec bit on the script is irrelevant for interpreter-based invocations. To prevent execution of a script entirely, deny read access to it (`"---"`).
|
||||||
|
|
||||||
|
**File-level `"---"` rules on Linux (bwrap).** On Linux, `"---"` rules are enforced at the OS layer using `bwrap --tmpfs` overlays, which only work on directories. When a `"---"` rule resolves to an existing file (e.g. `"~/.netrc": "---"`), the OS-level mount is skipped — bwrap cannot overlay a file with a tmpfs. Tool-layer enforcement still blocks read/write/edit calls for that file. However, exec commands running inside the sandbox can still access the file directly (e.g. `cat ~/.netrc`). A warning is emitted to stderr when this gap is active. To enforce at the OS layer on Linux, deny the parent directory instead (e.g. `"~/.aws/**": "---"` rather than `"~/.aws/credentials": "---"`). On macOS, seatbelt handles file-level denials correctly with `(deny file-read* (literal ...))`.
|
||||||
|
|
||||||
|
**Mid-path wildcard patterns and OS-level exec enforcement.** Patterns with a wildcard in a non-final segment — such as `skills/**/*.sh` or `logs/*/app.log` — cannot be expressed as OS-level subpath matchers. bwrap and Seatbelt do not understand glob syntax; they work with concrete directory prefixes. For non-deny rules, OpenClaw emits the longest concrete prefix (`skills/`) as an approximate OS-level rule for read and write access, which is bounded and safe. The exec bit is intentionally omitted from the OS approximation: granting exec on the entire prefix directory would allow any binary under that directory to be executed by subprocesses, not just files matching the original pattern. Exec for mid-path wildcard patterns is enforced by the tool layer only. To get OS-level exec enforcement, use a trailing-`**` pattern such as `skills/**` (which covers the directory precisely, with the file-type filter applying at the tool layer only).
|
||||||
|
|
||||||
|
**`scripts["policy"]` requires at least one script entry to take effect.** Shared script rules in `scripts["policy"]` are only applied when a specific script key matches the exec target. A `scripts` block with only `scripts["policy"]` and no named script entries has no effect on any exec call.
|
||||||
|
|
||||||
|
**No approval flow.** Access policy is fail-closed: a denied operation returns an error immediately. There is no `ask`/`on-miss` mode equivalent to exec approvals. If an agent hits a denied path, it receives a permission error and must handle it. Interactive approval for filesystem access is planned as a follow-up feature.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Exec approvals](/tools/exec-approvals) — allowlist-based exec gating (complements access policy)
|
||||||
|
- [Exec tool](/tools/exec) — exec tool reference
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
|
import type { AccessPolicyConfig } from "../config/types.tools.js";
|
||||||
|
import { buildExecApprovalUnavailableReplyPayload } from "../infra/exec-approval-reply.js";
|
||||||
import {
|
import {
|
||||||
addAllowlistEntry,
|
addAllowlistEntry,
|
||||||
type ExecAsk,
|
type ExecAsk,
|
||||||
|
|
@ -13,22 +15,20 @@ import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js";
|
||||||
import type { SafeBinProfile } from "../infra/exec-safe-bin-policy.js";
|
import type { SafeBinProfile } from "../infra/exec-safe-bin-policy.js";
|
||||||
import { logInfo } from "../logger.js";
|
import { logInfo } from "../logger.js";
|
||||||
import { markBackgrounded, tail } from "./bash-process-registry.js";
|
import { markBackgrounded, tail } from "./bash-process-registry.js";
|
||||||
|
import { sendExecApprovalFollowup } from "./bash-tools.exec-approval-followup.js";
|
||||||
import {
|
import {
|
||||||
buildExecApprovalRequesterContext,
|
buildExecApprovalRequesterContext,
|
||||||
buildExecApprovalTurnSourceContext,
|
buildExecApprovalTurnSourceContext,
|
||||||
registerExecApprovalRequestForHostOrThrow,
|
registerExecApprovalRequestForHostOrThrow,
|
||||||
} from "./bash-tools.exec-approval-request.js";
|
} from "./bash-tools.exec-approval-request.js";
|
||||||
import {
|
import {
|
||||||
buildDefaultExecApprovalRequestArgs,
|
|
||||||
buildExecApprovalFollowupTarget,
|
|
||||||
buildExecApprovalPendingToolResult,
|
|
||||||
createExecApprovalDecisionState,
|
|
||||||
createAndRegisterDefaultExecApprovalRequest,
|
createAndRegisterDefaultExecApprovalRequest,
|
||||||
|
resolveBaseExecApprovalDecision,
|
||||||
resolveApprovalDecisionOrUndefined,
|
resolveApprovalDecisionOrUndefined,
|
||||||
resolveExecHostApprovalContext,
|
resolveExecHostApprovalContext,
|
||||||
sendExecApprovalFollowupResult,
|
|
||||||
} from "./bash-tools.exec-host-shared.js";
|
} from "./bash-tools.exec-host-shared.js";
|
||||||
import {
|
import {
|
||||||
|
buildApprovalPendingMessage,
|
||||||
DEFAULT_NOTIFY_TAIL_CHARS,
|
DEFAULT_NOTIFY_TAIL_CHARS,
|
||||||
createApprovalSlug,
|
createApprovalSlug,
|
||||||
normalizeNotifyOutput,
|
normalizeNotifyOutput,
|
||||||
|
|
@ -60,6 +60,7 @@ export type ProcessGatewayAllowlistParams = {
|
||||||
maxOutput: number;
|
maxOutput: number;
|
||||||
pendingMaxOutput: number;
|
pendingMaxOutput: number;
|
||||||
trustedSafeBinDirs?: ReadonlySet<string>;
|
trustedSafeBinDirs?: ReadonlySet<string>;
|
||||||
|
permissions?: AccessPolicyConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProcessGatewayAllowlistResult = {
|
export type ProcessGatewayAllowlistResult = {
|
||||||
|
|
@ -141,28 +142,6 @@ export async function processGatewayAllowlist(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requiresAsk) {
|
if (requiresAsk) {
|
||||||
const requestArgs = buildDefaultExecApprovalRequestArgs({
|
|
||||||
warnings: params.warnings,
|
|
||||||
approvalRunningNoticeMs: params.approvalRunningNoticeMs,
|
|
||||||
createApprovalSlug,
|
|
||||||
turnSourceChannel: params.turnSourceChannel,
|
|
||||||
turnSourceAccountId: params.turnSourceAccountId,
|
|
||||||
});
|
|
||||||
const registerGatewayApproval = async (approvalId: string) =>
|
|
||||||
await registerExecApprovalRequestForHostOrThrow({
|
|
||||||
approvalId,
|
|
||||||
command: params.command,
|
|
||||||
workdir: params.workdir,
|
|
||||||
host: "gateway",
|
|
||||||
security: hostSecurity,
|
|
||||||
ask: hostAsk,
|
|
||||||
...buildExecApprovalRequesterContext({
|
|
||||||
agentId: params.agentId,
|
|
||||||
sessionKey: params.sessionKey,
|
|
||||||
}),
|
|
||||||
resolvedPath: allowlistEval.segments[0]?.resolution?.resolvedPath,
|
|
||||||
...buildExecApprovalTurnSourceContext(params),
|
|
||||||
});
|
|
||||||
const {
|
const {
|
||||||
approvalId,
|
approvalId,
|
||||||
approvalSlug,
|
approvalSlug,
|
||||||
|
|
@ -173,46 +152,57 @@ export async function processGatewayAllowlist(
|
||||||
sentApproverDms,
|
sentApproverDms,
|
||||||
unavailableReason,
|
unavailableReason,
|
||||||
} = await createAndRegisterDefaultExecApprovalRequest({
|
} = await createAndRegisterDefaultExecApprovalRequest({
|
||||||
...requestArgs,
|
warnings: params.warnings,
|
||||||
register: registerGatewayApproval,
|
approvalRunningNoticeMs: params.approvalRunningNoticeMs,
|
||||||
|
createApprovalSlug,
|
||||||
|
turnSourceChannel: params.turnSourceChannel,
|
||||||
|
turnSourceAccountId: params.turnSourceAccountId,
|
||||||
|
register: async (approvalId) =>
|
||||||
|
await registerExecApprovalRequestForHostOrThrow({
|
||||||
|
approvalId,
|
||||||
|
command: params.command,
|
||||||
|
workdir: params.workdir,
|
||||||
|
host: "gateway",
|
||||||
|
security: hostSecurity,
|
||||||
|
ask: hostAsk,
|
||||||
|
...buildExecApprovalRequesterContext({
|
||||||
|
agentId: params.agentId,
|
||||||
|
sessionKey: params.sessionKey,
|
||||||
|
}),
|
||||||
|
resolvedPath: allowlistEval.segments[0]?.resolution?.resolvedPath,
|
||||||
|
...buildExecApprovalTurnSourceContext(params),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const resolvedPath = allowlistEval.segments[0]?.resolution?.resolvedPath;
|
const resolvedPath = allowlistEval.segments[0]?.resolution?.resolvedPath;
|
||||||
const effectiveTimeout =
|
const effectiveTimeout =
|
||||||
typeof params.timeoutSec === "number" ? params.timeoutSec : params.defaultTimeoutSec;
|
typeof params.timeoutSec === "number" ? params.timeoutSec : params.defaultTimeoutSec;
|
||||||
const followupTarget = buildExecApprovalFollowupTarget({
|
|
||||||
approvalId,
|
|
||||||
sessionKey: params.notifySessionKey,
|
|
||||||
turnSourceChannel: params.turnSourceChannel,
|
|
||||||
turnSourceTo: params.turnSourceTo,
|
|
||||||
turnSourceAccountId: params.turnSourceAccountId,
|
|
||||||
turnSourceThreadId: params.turnSourceThreadId,
|
|
||||||
});
|
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const decision = await resolveApprovalDecisionOrUndefined({
|
const decision = await resolveApprovalDecisionOrUndefined({
|
||||||
approvalId,
|
approvalId,
|
||||||
preResolvedDecision,
|
preResolvedDecision,
|
||||||
onFailure: () =>
|
onFailure: () =>
|
||||||
void sendExecApprovalFollowupResult(
|
void sendExecApprovalFollowup({
|
||||||
followupTarget,
|
approvalId,
|
||||||
`Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`,
|
sessionKey: params.notifySessionKey,
|
||||||
),
|
turnSourceChannel: params.turnSourceChannel,
|
||||||
|
turnSourceTo: params.turnSourceTo,
|
||||||
|
turnSourceAccountId: params.turnSourceAccountId,
|
||||||
|
turnSourceThreadId: params.turnSourceThreadId,
|
||||||
|
resultText: `Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (decision === undefined) {
|
if (decision === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const baseDecision = resolveBaseExecApprovalDecision({
|
||||||
baseDecision,
|
|
||||||
approvedByAsk: initialApprovedByAsk,
|
|
||||||
deniedReason: initialDeniedReason,
|
|
||||||
} = createExecApprovalDecisionState({
|
|
||||||
decision,
|
decision,
|
||||||
askFallback,
|
askFallback,
|
||||||
obfuscationDetected: obfuscation.detected,
|
obfuscationDetected: obfuscation.detected,
|
||||||
});
|
});
|
||||||
let approvedByAsk = initialApprovedByAsk;
|
let approvedByAsk = baseDecision.approvedByAsk;
|
||||||
let deniedReason = initialDeniedReason;
|
let deniedReason = baseDecision.deniedReason;
|
||||||
|
|
||||||
if (baseDecision.timedOut && askFallback === "allowlist") {
|
if (baseDecision.timedOut && askFallback === "allowlist") {
|
||||||
if (!analysisOk || !allowlistSatisfied) {
|
if (!analysisOk || !allowlistSatisfied) {
|
||||||
|
|
@ -244,10 +234,15 @@ export async function processGatewayAllowlist(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deniedReason) {
|
if (deniedReason) {
|
||||||
await sendExecApprovalFollowupResult(
|
await sendExecApprovalFollowup({
|
||||||
followupTarget,
|
approvalId,
|
||||||
`Exec denied (gateway id=${approvalId}, ${deniedReason}): ${params.command}`,
|
sessionKey: params.notifySessionKey,
|
||||||
);
|
turnSourceChannel: params.turnSourceChannel,
|
||||||
|
turnSourceTo: params.turnSourceTo,
|
||||||
|
turnSourceAccountId: params.turnSourceAccountId,
|
||||||
|
turnSourceThreadId: params.turnSourceThreadId,
|
||||||
|
resultText: `Exec denied (gateway id=${approvalId}, ${deniedReason}): ${params.command}`,
|
||||||
|
}).catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -271,12 +266,18 @@ export async function processGatewayAllowlist(
|
||||||
scopeKey: params.scopeKey,
|
scopeKey: params.scopeKey,
|
||||||
sessionKey: params.notifySessionKey,
|
sessionKey: params.notifySessionKey,
|
||||||
timeoutSec: effectiveTimeout,
|
timeoutSec: effectiveTimeout,
|
||||||
|
permissions: params.permissions,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
await sendExecApprovalFollowupResult(
|
await sendExecApprovalFollowup({
|
||||||
followupTarget,
|
approvalId,
|
||||||
`Exec denied (gateway id=${approvalId}, spawn-failed): ${params.command}`,
|
sessionKey: params.notifySessionKey,
|
||||||
);
|
turnSourceChannel: params.turnSourceChannel,
|
||||||
|
turnSourceTo: params.turnSourceTo,
|
||||||
|
turnSourceAccountId: params.turnSourceAccountId,
|
||||||
|
turnSourceThreadId: params.turnSourceThreadId,
|
||||||
|
resultText: `Exec denied (gateway id=${approvalId}, spawn-failed): ${params.command}`,
|
||||||
|
}).catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -290,25 +291,70 @@ export async function processGatewayAllowlist(
|
||||||
const summary = output
|
const summary = output
|
||||||
? `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})\n${output}`
|
? `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})\n${output}`
|
||||||
: `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})`;
|
: `Exec finished (gateway id=${approvalId}, session=${run.session.id}, ${exitLabel})`;
|
||||||
await sendExecApprovalFollowupResult(followupTarget, summary);
|
await sendExecApprovalFollowup({
|
||||||
|
approvalId,
|
||||||
|
sessionKey: params.notifySessionKey,
|
||||||
|
turnSourceChannel: params.turnSourceChannel,
|
||||||
|
turnSourceTo: params.turnSourceTo,
|
||||||
|
turnSourceAccountId: params.turnSourceAccountId,
|
||||||
|
turnSourceThreadId: params.turnSourceThreadId,
|
||||||
|
resultText: summary,
|
||||||
|
}).catch(() => {});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pendingResult: buildExecApprovalPendingToolResult({
|
pendingResult: {
|
||||||
host: "gateway",
|
content: [
|
||||||
command: params.command,
|
{
|
||||||
cwd: params.workdir,
|
type: "text",
|
||||||
warningText,
|
text:
|
||||||
approvalId,
|
unavailableReason !== null
|
||||||
approvalSlug,
|
? (buildExecApprovalUnavailableReplyPayload({
|
||||||
expiresAtMs,
|
warningText,
|
||||||
initiatingSurface,
|
reason: unavailableReason,
|
||||||
sentApproverDms,
|
channelLabel: initiatingSurface.channelLabel,
|
||||||
unavailableReason,
|
sentApproverDms,
|
||||||
}),
|
}).text ?? "")
|
||||||
|
: buildApprovalPendingMessage({
|
||||||
|
warningText,
|
||||||
|
approvalSlug,
|
||||||
|
approvalId,
|
||||||
|
command: params.command,
|
||||||
|
cwd: params.workdir,
|
||||||
|
host: "gateway",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details:
|
||||||
|
unavailableReason !== null
|
||||||
|
? ({
|
||||||
|
status: "approval-unavailable",
|
||||||
|
reason: unavailableReason,
|
||||||
|
channelLabel: initiatingSurface.channelLabel,
|
||||||
|
sentApproverDms,
|
||||||
|
host: "gateway",
|
||||||
|
command: params.command,
|
||||||
|
cwd: params.workdir,
|
||||||
|
warningText,
|
||||||
|
} satisfies ExecToolDetails)
|
||||||
|
: ({
|
||||||
|
status: "approval-pending",
|
||||||
|
approvalId,
|
||||||
|
approvalSlug,
|
||||||
|
expiresAtMs,
|
||||||
|
host: "gateway",
|
||||||
|
command: params.command,
|
||||||
|
cwd: params.workdir,
|
||||||
|
warningText,
|
||||||
|
} satisfies ExecToolDetails),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allowlist and path-level sandboxing (seatbelt/bwrap) are orthogonal controls:
|
||||||
|
// allowlist governs which binaries may run; seatbelt governs which paths they touch.
|
||||||
|
// Both must be enforced independently — having seatbelt active does not exempt a
|
||||||
|
// command from passing allowlist analysis.
|
||||||
if (hostSecurity === "allowlist" && (!analysisOk || !allowlistSatisfied)) {
|
if (hostSecurity === "allowlist" && (!analysisOk || !allowlistSatisfied)) {
|
||||||
throw new Error("exec denied: allowlist miss");
|
throw new Error("exec denied: allowlist miss");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,11 @@ vi.mock("../infra/system-events.js", () => ({
|
||||||
|
|
||||||
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
import { emitExecSystemEvent } from "./bash-tools.exec-runtime.js";
|
import {
|
||||||
|
_resetBwrapUnavailableWarnedForTest,
|
||||||
|
_resetWindowsUnconfiguredWarnedForTest,
|
||||||
|
emitExecSystemEvent,
|
||||||
|
} from "./bash-tools.exec-runtime.js";
|
||||||
|
|
||||||
const requestHeartbeatNowMock = vi.mocked(requestHeartbeatNow);
|
const requestHeartbeatNowMock = vi.mocked(requestHeartbeatNow);
|
||||||
const enqueueSystemEventMock = vi.mocked(enqueueSystemEvent);
|
const enqueueSystemEventMock = vi.mocked(enqueueSystemEvent);
|
||||||
|
|
@ -62,3 +66,21 @@ describe("emitExecSystemEvent", () => {
|
||||||
expect(requestHeartbeatNowMock).not.toHaveBeenCalled();
|
expect(requestHeartbeatNowMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// One-time warning reset helpers (exported for tests)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("_resetBwrapUnavailableWarnedForTest / _resetWindowsUnconfiguredWarnedForTest", () => {
|
||||||
|
it("exports _resetBwrapUnavailableWarnedForTest as a function", () => {
|
||||||
|
// Verify the export exists and is callable — the reset enables repeated
|
||||||
|
// warning tests without cross-test state leakage.
|
||||||
|
expect(typeof _resetBwrapUnavailableWarnedForTest).toBe("function");
|
||||||
|
expect(() => _resetBwrapUnavailableWarnedForTest()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exports _resetWindowsUnconfiguredWarnedForTest as a function", () => {
|
||||||
|
expect(typeof _resetWindowsUnconfiguredWarnedForTest).toBe("function");
|
||||||
|
expect(() => _resetWindowsUnconfiguredWarnedForTest()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
@ -16,6 +17,18 @@ export {
|
||||||
normalizeExecHost,
|
normalizeExecHost,
|
||||||
normalizeExecSecurity,
|
normalizeExecSecurity,
|
||||||
} from "../infra/exec-approvals.js";
|
} from "../infra/exec-approvals.js";
|
||||||
|
import type { AccessPolicyConfig } from "../config/types.tools.js";
|
||||||
|
import {
|
||||||
|
applyScriptPolicyOverride,
|
||||||
|
checkAccessPolicy,
|
||||||
|
resolveArgv0,
|
||||||
|
resolveScriptKey,
|
||||||
|
} from "../infra/access-policy.js";
|
||||||
|
import { isBwrapAvailable, wrapCommandWithBwrap } from "../infra/exec-sandbox-bwrap.js";
|
||||||
|
import {
|
||||||
|
generateSeatbeltProfile,
|
||||||
|
wrapCommandWithSeatbelt,
|
||||||
|
} from "../infra/exec-sandbox-seatbelt.js";
|
||||||
import { logWarn } from "../logger.js";
|
import { logWarn } from "../logger.js";
|
||||||
import type { ManagedRun } from "../process/supervisor/index.js";
|
import type { ManagedRun } from "../process/supervisor/index.js";
|
||||||
import { getProcessSupervisor } from "../process/supervisor/index.js";
|
import { getProcessSupervisor } from "../process/supervisor/index.js";
|
||||||
|
|
@ -286,6 +299,40 @@ export function emitExecSystemEvent(
|
||||||
requestHeartbeatNow(scopedHeartbeatWakeOptions(sessionKey, { reason: "exec-event" }));
|
requestHeartbeatNow(scopedHeartbeatWakeOptions(sessionKey, { reason: "exec-event" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Warn once per process when OS-level exec enforcement is unavailable and
|
||||||
|
// access-policy permissions are configured — so operators know exec runs unconfined.
|
||||||
|
let _bwrapUnavailableWarned = false;
|
||||||
|
function _warnBwrapUnavailableOnce(): void {
|
||||||
|
if (_bwrapUnavailableWarned) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_bwrapUnavailableWarned = true;
|
||||||
|
console.error(
|
||||||
|
"[access-policy] WARNING: bwrap is not available on this Linux host — exec commands run unconfined. Install bubblewrap to enable OS-level exec enforcement.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset the one-time bwrap-unavailable warning flag. Only for use in tests. */
|
||||||
|
export function _resetBwrapUnavailableWarnedForTest(): void {
|
||||||
|
_bwrapUnavailableWarned = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _windowsUnconfiguredWarned = false;
|
||||||
|
function _warnWindowsUnconfiguredOnce(): void {
|
||||||
|
if (_windowsUnconfiguredWarned) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_windowsUnconfiguredWarned = true;
|
||||||
|
console.error(
|
||||||
|
"[access-policy] WARNING: OS-level exec enforcement is not supported on Windows — exec commands run unconfined even when access-policy permissions are configured.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset the one-time Windows-unconfigured warning flag. Only for use in tests. */
|
||||||
|
export function _resetWindowsUnconfiguredWarnedForTest(): void {
|
||||||
|
_windowsUnconfiguredWarned = false;
|
||||||
|
}
|
||||||
|
|
||||||
export async function runExecProcess(opts: {
|
export async function runExecProcess(opts: {
|
||||||
command: string;
|
command: string;
|
||||||
// Execute this instead of `command` (which is kept for display/session/logging).
|
// Execute this instead of `command` (which is kept for display/session/logging).
|
||||||
|
|
@ -305,10 +352,84 @@ export async function runExecProcess(opts: {
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
timeoutSec: number | null;
|
timeoutSec: number | null;
|
||||||
onUpdate?: (partialResult: AgentToolResult<ExecToolDetails>) => void;
|
onUpdate?: (partialResult: AgentToolResult<ExecToolDetails>) => void;
|
||||||
|
/** When set, wrap the exec command with OS-level path enforcement. */
|
||||||
|
permissions?: AccessPolicyConfig;
|
||||||
}): Promise<ExecProcessHandle> {
|
}): Promise<ExecProcessHandle> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const sessionId = createSessionSlug();
|
const sessionId = createSessionSlug();
|
||||||
const execCommand = opts.execCommand ?? opts.command;
|
const baseCommand = opts.execCommand ?? opts.command;
|
||||||
|
|
||||||
|
// Apply access-policy enforcement when permissions are configured.
|
||||||
|
// Hash verification and tool-layer exec checks run unconditionally — container
|
||||||
|
// sandboxes (opts.sandbox) share the host filesystem via volume mounts so a
|
||||||
|
// tampered script is still reachable. Only OS-level wrapping (seatbelt/bwrap)
|
||||||
|
// is skipped when a container sandbox already provides filesystem isolation.
|
||||||
|
let execCommand = baseCommand;
|
||||||
|
if (opts.permissions) {
|
||||||
|
// Fall back to the first token rather than the full command string so that
|
||||||
|
// checkAccessPolicy matches against a path-like token instead of a multi-word
|
||||||
|
// string that never matches any absolute-path rule (and would pass unconditionally
|
||||||
|
// under a permissive default).
|
||||||
|
const argv0 =
|
||||||
|
resolveArgv0(baseCommand, opts.workdir) ?? baseCommand.trim().split(/\s+/)[0] ?? baseCommand;
|
||||||
|
const {
|
||||||
|
policy: effectivePermissions,
|
||||||
|
overrideRules,
|
||||||
|
hashMismatch,
|
||||||
|
} = applyScriptPolicyOverride(opts.permissions, argv0);
|
||||||
|
if (hashMismatch) {
|
||||||
|
throw new Error(`exec denied: script hash mismatch for ${argv0}`);
|
||||||
|
}
|
||||||
|
// Tool-layer exec path check — defense-in-depth for platforms where OS-level
|
||||||
|
// enforcement (seatbelt/bwrap) is unavailable (Linux without bwrap, Windows).
|
||||||
|
// Mirrors the checkAccessPolicy calls in read/write tools for consistency.
|
||||||
|
//
|
||||||
|
// For scripts{} entries, skip the broader rules check — a sha256-matched script
|
||||||
|
// doesn't also need an explicit exec rule in the base policy.
|
||||||
|
// Use resolveScriptKey so that both tilde keys ("~/bin/deploy.sh") and
|
||||||
|
// symlink keys ("/usr/bin/python" → /usr/bin/python3.12) match argv0, which
|
||||||
|
// is always the realpathSync result from resolveArgv0.
|
||||||
|
const _scripts = opts.permissions.scripts ?? {};
|
||||||
|
// Skip the reserved "policy" key and malformed (non-object) entries — only a
|
||||||
|
// validated ScriptPolicyEntry object counts as a legitimate script override.
|
||||||
|
// A truthy primitive (true, "oops") would otherwise bypass the base exec gate
|
||||||
|
// even though applyScriptPolicyOverride already rejected the entry.
|
||||||
|
const hasScriptOverride = Object.entries(_scripts).some(
|
||||||
|
([k, v]) =>
|
||||||
|
k !== "policy" &&
|
||||||
|
v != null &&
|
||||||
|
typeof v === "object" &&
|
||||||
|
!Array.isArray(v) &&
|
||||||
|
path.normalize(resolveScriptKey(k)) === path.normalize(argv0),
|
||||||
|
);
|
||||||
|
if (!hasScriptOverride && checkAccessPolicy(argv0, "exec", effectivePermissions) === "deny") {
|
||||||
|
throw new Error(`exec denied by access policy: ${argv0}`);
|
||||||
|
}
|
||||||
|
// OS-level sandbox wrapping — skip when a container sandbox already isolates the process.
|
||||||
|
if (!opts.sandbox) {
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
const profile = generateSeatbeltProfile(effectivePermissions, os.homedir(), overrideRules);
|
||||||
|
execCommand = wrapCommandWithSeatbelt(baseCommand, profile);
|
||||||
|
} else if (process.platform === "linux") {
|
||||||
|
if (await isBwrapAvailable()) {
|
||||||
|
// Pass overrideRules separately so they are emitted AFTER deny[] mounts,
|
||||||
|
// giving script-specific grants precedence over base deny entries — matching
|
||||||
|
// the Seatbelt path where scriptOverrideRules are emitted last in the profile.
|
||||||
|
execCommand = wrapCommandWithBwrap(
|
||||||
|
baseCommand,
|
||||||
|
effectivePermissions,
|
||||||
|
os.homedir(),
|
||||||
|
overrideRules,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_warnBwrapUnavailableOnce();
|
||||||
|
}
|
||||||
|
} else if (process.platform === "win32") {
|
||||||
|
_warnWindowsUnconfiguredOnce();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const supervisor = getProcessSupervisor();
|
const supervisor = getProcessSupervisor();
|
||||||
const shellRuntimeEnv: Record<string, string> = {
|
const shellRuntimeEnv: Record<string, string> = {
|
||||||
...opts.env,
|
...opts.env,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
|
import type { AccessPolicyConfig } from "../config/types.tools.js";
|
||||||
import type { ExecAsk, ExecHost, ExecSecurity } from "../infra/exec-approvals.js";
|
import type { ExecAsk, ExecHost, ExecSecurity } from "../infra/exec-approvals.js";
|
||||||
import type { SafeBinProfileFixture } from "../infra/exec-safe-bin-policy.js";
|
import type { SafeBinProfileFixture } from "../infra/exec-safe-bin-policy.js";
|
||||||
import type { BashSandboxConfig } from "./bash-tools.shared.js";
|
import type { BashSandboxConfig } from "./bash-tools.shared.js";
|
||||||
|
|
||||||
export type ExecToolDefaults = {
|
export type ExecToolDefaults = {
|
||||||
|
/** Path-scoped RWX permissions — x bit gates binary execution. */
|
||||||
|
permissions?: AccessPolicyConfig;
|
||||||
host?: ExecHost;
|
host?: ExecHost;
|
||||||
security?: ExecSecurity;
|
security?: ExecSecurity;
|
||||||
ask?: ExecAsk;
|
ask?: ExecAsk;
|
||||||
|
|
|
||||||
|
|
@ -449,6 +449,7 @@ export function createExecTool(
|
||||||
maxOutput,
|
maxOutput,
|
||||||
pendingMaxOutput,
|
pendingMaxOutput,
|
||||||
trustedSafeBinDirs,
|
trustedSafeBinDirs,
|
||||||
|
permissions: defaults?.permissions,
|
||||||
});
|
});
|
||||||
if (gatewayResult.pendingResult) {
|
if (gatewayResult.pendingResult) {
|
||||||
return gatewayResult.pendingResult;
|
return gatewayResult.pendingResult;
|
||||||
|
|
@ -486,6 +487,7 @@ export function createExecTool(
|
||||||
sessionKey: notifySessionKey,
|
sessionKey: notifySessionKey,
|
||||||
timeoutSec: effectiveTimeout,
|
timeoutSec: effectiveTimeout,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
|
permissions: defaults?.permissions,
|
||||||
});
|
});
|
||||||
|
|
||||||
let yielded = false;
|
let yielded = false;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { AccessPolicyConfig } from "../config/types.tools.js";
|
||||||
|
|
||||||
|
type CapturedEditOperations = {
|
||||||
|
readFile: (absolutePath: string) => Promise<Buffer>;
|
||||||
|
writeFile: (absolutePath: string, content: string) => Promise<void>;
|
||||||
|
access: (absolutePath: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
operations: undefined as CapturedEditOperations | undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@mariozechner/pi-coding-agent")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
createEditTool: (_cwd: string, options?: { operations?: CapturedEditOperations }) => {
|
||||||
|
mocks.operations = options?.operations;
|
||||||
|
return {
|
||||||
|
name: "edit",
|
||||||
|
description: "test edit tool",
|
||||||
|
parameters: { type: "object", properties: {} },
|
||||||
|
execute: async () => ({
|
||||||
|
content: [{ type: "text" as const, text: "ok" }],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createHostWorkspaceEditTool } = await import("./pi-tools.read.js");
|
||||||
|
|
||||||
|
describe("createHostWorkspaceEditTool edit read-permission check", () => {
|
||||||
|
let tmpDir = "";
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
mocks.operations = undefined;
|
||||||
|
if (tmpDir) {
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
tmpDir = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.runIf(process.platform !== "win32")(
|
||||||
|
"readFile throws when read access is denied by permissions (write-only policy)",
|
||||||
|
async () => {
|
||||||
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-edit-perm-test-"));
|
||||||
|
const filePath = path.join(tmpDir, "protected.txt");
|
||||||
|
await fs.writeFile(filePath, "secret content", "utf8");
|
||||||
|
|
||||||
|
// "-w-" policy: write allowed, read denied.
|
||||||
|
// Edit must NOT be allowed to read the file even if write is permitted.
|
||||||
|
const permissions: AccessPolicyConfig = {
|
||||||
|
policy: { [`${tmpDir}/**`]: "-w-" },
|
||||||
|
};
|
||||||
|
createHostWorkspaceEditTool(tmpDir, { workspaceOnly: false, permissions });
|
||||||
|
expect(mocks.operations).toBeDefined();
|
||||||
|
|
||||||
|
await expect(mocks.operations!.readFile(filePath)).rejects.toThrow(/Permission denied.*read/);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.runIf(process.platform !== "win32")(
|
||||||
|
"readFile succeeds when read access is granted by permissions",
|
||||||
|
async () => {
|
||||||
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-edit-perm-test-"));
|
||||||
|
const filePath = path.join(tmpDir, "allowed.txt");
|
||||||
|
await fs.writeFile(filePath, "content", "utf8");
|
||||||
|
|
||||||
|
const permissions: AccessPolicyConfig = {
|
||||||
|
policy: { [`${tmpDir}/**`]: "rw-" },
|
||||||
|
};
|
||||||
|
createHostWorkspaceEditTool(tmpDir, { workspaceOnly: false, permissions });
|
||||||
|
expect(mocks.operations).toBeDefined();
|
||||||
|
|
||||||
|
await expect(mocks.operations!.readFile(filePath)).resolves.toBeDefined();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.runIf(process.platform !== "win32")(
|
||||||
|
"writeFile throws when write access is denied by permissions",
|
||||||
|
async () => {
|
||||||
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-edit-perm-test-"));
|
||||||
|
const filePath = path.join(tmpDir, "readonly.txt");
|
||||||
|
await fs.writeFile(filePath, "content", "utf8");
|
||||||
|
|
||||||
|
// "r--" policy: read allowed, write denied.
|
||||||
|
const permissions: AccessPolicyConfig = {
|
||||||
|
policy: { [`${tmpDir}/**`]: "r--" },
|
||||||
|
};
|
||||||
|
createHostWorkspaceEditTool(tmpDir, { workspaceOnly: false, permissions });
|
||||||
|
expect(mocks.operations).toBeDefined();
|
||||||
|
|
||||||
|
await expect(mocks.operations!.writeFile(filePath, "new")).rejects.toThrow(
|
||||||
|
/Permission denied.*write/,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
|
import { realpathSync } from "node:fs";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
import { createEditTool, createReadTool, createWriteTool } from "@mariozechner/pi-coding-agent";
|
import { createEditTool, createReadTool, createWriteTool } from "@mariozechner/pi-coding-agent";
|
||||||
|
import type { AccessPolicyConfig } from "../config/types.tools.js";
|
||||||
|
import { checkAccessPolicy } from "../infra/access-policy.js";
|
||||||
import {
|
import {
|
||||||
appendFileWithinRoot,
|
appendFileWithinRoot,
|
||||||
SafeOpenError,
|
SafeOpenError,
|
||||||
|
|
@ -47,9 +50,44 @@ const ADAPTIVE_READ_CONTEXT_SHARE = 0.2;
|
||||||
const CHARS_PER_TOKEN_ESTIMATE = 4;
|
const CHARS_PER_TOKEN_ESTIMATE = 4;
|
||||||
const MAX_ADAPTIVE_READ_PAGES = 8;
|
const MAX_ADAPTIVE_READ_PAGES = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve symlinks before a policy check. For paths that don't exist yet
|
||||||
|
* (e.g. a new file being created), resolves the parent directory so that
|
||||||
|
* intermediate symlinks are followed. Without this, a write to
|
||||||
|
* `/allowed/link/new.txt` where `link → /denied` would pass the check
|
||||||
|
* (path.resolve does not follow symlinks) and then land in the denied
|
||||||
|
* target when fs.writeFile follows the symlink.
|
||||||
|
*/
|
||||||
|
function safeRealpath(p: string): string {
|
||||||
|
try {
|
||||||
|
return realpathSync(p);
|
||||||
|
} catch {
|
||||||
|
// Path doesn't exist yet — walk up ancestors until we find one that exists,
|
||||||
|
// resolve it, then reconstruct the full path.
|
||||||
|
const parts: string[] = [];
|
||||||
|
let ancestor = p;
|
||||||
|
while (true) {
|
||||||
|
const parent = path.dirname(ancestor);
|
||||||
|
if (parent === ancestor) {
|
||||||
|
return path.resolve(p);
|
||||||
|
}
|
||||||
|
parts.unshift(path.basename(ancestor));
|
||||||
|
ancestor = parent;
|
||||||
|
try {
|
||||||
|
return path.join(realpathSync(ancestor), ...parts);
|
||||||
|
} catch {
|
||||||
|
// Keep walking up.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type OpenClawReadToolOptions = {
|
type OpenClawReadToolOptions = {
|
||||||
modelContextWindowTokens?: number;
|
modelContextWindowTokens?: number;
|
||||||
imageSanitization?: ImageSanitizationLimits;
|
imageSanitization?: ImageSanitizationLimits;
|
||||||
|
permissions?: AccessPolicyConfig;
|
||||||
|
/** Workspace root used to resolve relative paths for permission checks. */
|
||||||
|
workspaceRoot?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ReadTruncationDetails = {
|
type ReadTruncationDetails = {
|
||||||
|
|
@ -621,14 +659,20 @@ export function createSandboxedEditTool(params: SandboxToolParams) {
|
||||||
return wrapToolParamNormalization(base, CLAUDE_PARAM_GROUPS.edit);
|
return wrapToolParamNormalization(base, CLAUDE_PARAM_GROUPS.edit);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createHostWorkspaceWriteTool(root: string, options?: { workspaceOnly?: boolean }) {
|
export function createHostWorkspaceWriteTool(
|
||||||
|
root: string,
|
||||||
|
options?: { workspaceOnly?: boolean; permissions?: AccessPolicyConfig },
|
||||||
|
) {
|
||||||
const base = createWriteTool(root, {
|
const base = createWriteTool(root, {
|
||||||
operations: createHostWriteOperations(root, options),
|
operations: createHostWriteOperations(root, options),
|
||||||
}) as unknown as AnyAgentTool;
|
}) as unknown as AnyAgentTool;
|
||||||
return wrapToolParamNormalization(base, CLAUDE_PARAM_GROUPS.write);
|
return wrapToolParamNormalization(base, CLAUDE_PARAM_GROUPS.write);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createHostWorkspaceEditTool(root: string, options?: { workspaceOnly?: boolean }) {
|
export function createHostWorkspaceEditTool(
|
||||||
|
root: string,
|
||||||
|
options?: { workspaceOnly?: boolean; permissions?: AccessPolicyConfig },
|
||||||
|
) {
|
||||||
const base = createEditTool(root, {
|
const base = createEditTool(root, {
|
||||||
operations: createHostEditOperations(root, options),
|
operations: createHostEditOperations(root, options),
|
||||||
}) as unknown as AnyAgentTool;
|
}) as unknown as AnyAgentTool;
|
||||||
|
|
@ -649,14 +693,31 @@ export function createOpenClawReadTool(
|
||||||
normalized ??
|
normalized ??
|
||||||
(params && typeof params === "object" ? (params as Record<string, unknown>) : undefined);
|
(params && typeof params === "object" ? (params as Record<string, unknown>) : undefined);
|
||||||
assertRequiredParams(record, CLAUDE_PARAM_GROUPS.read, base.name);
|
assertRequiredParams(record, CLAUDE_PARAM_GROUPS.read, base.name);
|
||||||
|
const filePath = typeof record?.path === "string" ? String(record.path) : "<unknown>";
|
||||||
|
// Path-level permission check (when tools.fs.permissions is configured).
|
||||||
|
// Use the resolved path for the actual read — closes the TOCTOU window where a
|
||||||
|
// symlink swapped between check and open could redirect I/O to an unchecked path.
|
||||||
|
// This mirrors the write/edit tools which return the resolved path from
|
||||||
|
// assertWritePermitted/assertEditPermitted and use it for the subsequent I/O call.
|
||||||
|
let readArgs = (normalized ?? params ?? {}) as Record<string, unknown>;
|
||||||
|
if (options?.permissions && filePath !== "<unknown>") {
|
||||||
|
const resolvedPath = safeRealpath(
|
||||||
|
path.isAbsolute(filePath)
|
||||||
|
? filePath
|
||||||
|
: path.resolve(options.workspaceRoot ?? process.cwd(), filePath),
|
||||||
|
);
|
||||||
|
if (checkAccessPolicy(resolvedPath, "read", options.permissions) === "deny") {
|
||||||
|
throw new Error(`Permission denied: read access to ${resolvedPath} is not allowed.`);
|
||||||
|
}
|
||||||
|
readArgs = { ...readArgs, path: resolvedPath };
|
||||||
|
}
|
||||||
const result = await executeReadWithAdaptivePaging({
|
const result = await executeReadWithAdaptivePaging({
|
||||||
base,
|
base,
|
||||||
toolCallId,
|
toolCallId,
|
||||||
args: (normalized ?? params ?? {}) as Record<string, unknown>,
|
args: readArgs,
|
||||||
signal,
|
signal,
|
||||||
maxBytes: resolveAdaptiveReadMaxBytes(options),
|
maxBytes: resolveAdaptiveReadMaxBytes(options),
|
||||||
});
|
});
|
||||||
const filePath = typeof record?.path === "string" ? String(record.path) : "<unknown>";
|
|
||||||
const strippedDetailsResult = stripReadTruncationContentDetails(result);
|
const strippedDetailsResult = stripReadTruncationContentDetails(result);
|
||||||
const normalizedResult = await normalizeReadImageResult(strippedDetailsResult, filePath);
|
const normalizedResult = await normalizeReadImageResult(strippedDetailsResult, filePath);
|
||||||
return sanitizeToolResultImages(
|
return sanitizeToolResultImages(
|
||||||
|
|
@ -718,32 +779,58 @@ async function writeHostFile(absolutePath: string, content: string) {
|
||||||
await fs.writeFile(resolved, content, "utf-8");
|
await fs.writeFile(resolved, content, "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
function createHostWriteOperations(root: string, options?: { workspaceOnly?: boolean }) {
|
function createHostWriteOperations(
|
||||||
|
root: string,
|
||||||
|
options?: { workspaceOnly?: boolean; permissions?: AccessPolicyConfig },
|
||||||
|
) {
|
||||||
const workspaceOnly = options?.workspaceOnly ?? false;
|
const workspaceOnly = options?.workspaceOnly ?? false;
|
||||||
|
const permissions = options?.permissions;
|
||||||
|
// Resolve root once so that safeRealpath(child) paths can be compared against
|
||||||
|
// it — if root itself is a symlink, toRelativeWorkspacePath would otherwise
|
||||||
|
// throw "path escapes workspace root" for every path inside the workspace.
|
||||||
|
const resolvedRoot = safeRealpath(root);
|
||||||
|
|
||||||
|
// Returns the safeRealpath-resolved path so callers use the same concrete path
|
||||||
|
// for I/O that was checked by the policy — closes the TOCTOU window where a
|
||||||
|
// symlink swap between permission check and fs call could redirect I/O.
|
||||||
|
function assertWritePermitted(absolutePath: string): string {
|
||||||
|
const resolved = safeRealpath(absolutePath);
|
||||||
|
if (permissions && checkAccessPolicy(resolved, "write", permissions) === "deny") {
|
||||||
|
throw new Error(`Permission denied: write access to ${resolved} is not allowed.`);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
if (!workspaceOnly) {
|
if (!workspaceOnly) {
|
||||||
// When workspaceOnly is false, allow writes anywhere on the host
|
// When workspaceOnly is false, allow writes anywhere on the host
|
||||||
return {
|
return {
|
||||||
mkdir: async (dir: string) => {
|
mkdir: async (dir: string) => {
|
||||||
const resolved = path.resolve(dir);
|
const resolved = assertWritePermitted(dir);
|
||||||
await fs.mkdir(resolved, { recursive: true });
|
await fs.mkdir(resolved, { recursive: true });
|
||||||
},
|
},
|
||||||
writeFile: writeHostFile,
|
writeFile: async (absolutePath: string, content: string) => {
|
||||||
|
const resolved = assertWritePermitted(absolutePath);
|
||||||
|
await writeHostFile(resolved, content);
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
// When workspaceOnly is true, enforce workspace boundary
|
// When workspaceOnly is true, enforce workspace boundary
|
||||||
return {
|
return {
|
||||||
mkdir: async (dir: string) => {
|
mkdir: async (dir: string) => {
|
||||||
const relative = toRelativeWorkspacePath(root, dir, { allowRoot: true });
|
const resolved = assertWritePermitted(dir);
|
||||||
const resolved = relative ? path.resolve(root, relative) : path.resolve(root);
|
const relative = toRelativeWorkspacePath(resolvedRoot, resolved, { allowRoot: true });
|
||||||
await assertSandboxPath({ filePath: resolved, cwd: root, root });
|
const absResolved = relative
|
||||||
await fs.mkdir(resolved, { recursive: true });
|
? path.resolve(resolvedRoot, relative)
|
||||||
|
: path.resolve(resolvedRoot);
|
||||||
|
await assertSandboxPath({ filePath: absResolved, cwd: resolvedRoot, root: resolvedRoot });
|
||||||
|
await fs.mkdir(absResolved, { recursive: true });
|
||||||
},
|
},
|
||||||
writeFile: async (absolutePath: string, content: string) => {
|
writeFile: async (absolutePath: string, content: string) => {
|
||||||
const relative = toRelativeWorkspacePath(root, absolutePath);
|
const resolved = assertWritePermitted(absolutePath);
|
||||||
|
const relative = toRelativeWorkspacePath(resolvedRoot, resolved);
|
||||||
await writeFileWithinRoot({
|
await writeFileWithinRoot({
|
||||||
rootDir: root,
|
rootDir: resolvedRoot,
|
||||||
relativePath: relative,
|
relativePath: relative,
|
||||||
data: content,
|
data: content,
|
||||||
mkdir: true,
|
mkdir: true,
|
||||||
|
|
@ -752,19 +839,55 @@ function createHostWriteOperations(root: string, options?: { workspaceOnly?: boo
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createHostEditOperations(root: string, options?: { workspaceOnly?: boolean }) {
|
function createHostEditOperations(
|
||||||
|
root: string,
|
||||||
|
options?: { workspaceOnly?: boolean; permissions?: AccessPolicyConfig },
|
||||||
|
) {
|
||||||
const workspaceOnly = options?.workspaceOnly ?? false;
|
const workspaceOnly = options?.workspaceOnly ?? false;
|
||||||
|
const permissions = options?.permissions;
|
||||||
|
const resolvedRoot = safeRealpath(root);
|
||||||
|
|
||||||
|
// Edit = read + write the same file; check both permissions and return the
|
||||||
|
// safeRealpath-resolved path so callers use the same concrete path for I/O
|
||||||
|
// that was checked — closes the TOCTOU window where a symlink swap between
|
||||||
|
// permission check and fs call could redirect I/O to an unchecked target.
|
||||||
|
function assertEditPermitted(absolutePath: string): string {
|
||||||
|
const resolved = safeRealpath(absolutePath);
|
||||||
|
if (permissions) {
|
||||||
|
if (checkAccessPolicy(resolved, "read", permissions) === "deny") {
|
||||||
|
throw new Error(`Permission denied: read access to ${resolved} is not allowed.`);
|
||||||
|
}
|
||||||
|
if (checkAccessPolicy(resolved, "write", permissions) === "deny") {
|
||||||
|
throw new Error(`Permission denied: write access to ${resolved} is not allowed.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// access() checks existence only — requires read permission but not write.
|
||||||
|
// Using assertEditPermitted here would block existence checks on r-- paths before
|
||||||
|
// any write is attempted, producing a misleading "write access denied" error.
|
||||||
|
function assertReadPermitted(absolutePath: string): string {
|
||||||
|
const resolved = safeRealpath(absolutePath);
|
||||||
|
if (permissions && checkAccessPolicy(resolved, "read", permissions) === "deny") {
|
||||||
|
throw new Error(`Permission denied: read access to ${resolved} is not allowed.`);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
if (!workspaceOnly) {
|
if (!workspaceOnly) {
|
||||||
// When workspaceOnly is false, allow edits anywhere on the host
|
// When workspaceOnly is false, allow edits anywhere on the host
|
||||||
return {
|
return {
|
||||||
readFile: async (absolutePath: string) => {
|
readFile: async (absolutePath: string) => {
|
||||||
const resolved = path.resolve(absolutePath);
|
const resolved = assertEditPermitted(absolutePath);
|
||||||
return await fs.readFile(resolved);
|
return await fs.readFile(resolved);
|
||||||
},
|
},
|
||||||
writeFile: writeHostFile,
|
writeFile: async (absolutePath: string, content: string) => {
|
||||||
|
const resolved = assertEditPermitted(absolutePath);
|
||||||
|
await writeHostFile(resolved, content);
|
||||||
|
},
|
||||||
access: async (absolutePath: string) => {
|
access: async (absolutePath: string) => {
|
||||||
const resolved = path.resolve(absolutePath);
|
const resolved = assertReadPermitted(absolutePath);
|
||||||
await fs.access(resolved);
|
await fs.access(resolved);
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
@ -773,26 +896,29 @@ function createHostEditOperations(root: string, options?: { workspaceOnly?: bool
|
||||||
// When workspaceOnly is true, enforce workspace boundary
|
// When workspaceOnly is true, enforce workspace boundary
|
||||||
return {
|
return {
|
||||||
readFile: async (absolutePath: string) => {
|
readFile: async (absolutePath: string) => {
|
||||||
const relative = toRelativeWorkspacePath(root, absolutePath);
|
const resolved = assertEditPermitted(absolutePath);
|
||||||
|
const relative = toRelativeWorkspacePath(resolvedRoot, resolved);
|
||||||
const safeRead = await readFileWithinRoot({
|
const safeRead = await readFileWithinRoot({
|
||||||
rootDir: root,
|
rootDir: resolvedRoot,
|
||||||
relativePath: relative,
|
relativePath: relative,
|
||||||
});
|
});
|
||||||
return safeRead.buffer;
|
return safeRead.buffer;
|
||||||
},
|
},
|
||||||
writeFile: async (absolutePath: string, content: string) => {
|
writeFile: async (absolutePath: string, content: string) => {
|
||||||
const relative = toRelativeWorkspacePath(root, absolutePath);
|
const resolved = assertEditPermitted(absolutePath);
|
||||||
|
const relative = toRelativeWorkspacePath(resolvedRoot, resolved);
|
||||||
await writeFileWithinRoot({
|
await writeFileWithinRoot({
|
||||||
rootDir: root,
|
rootDir: resolvedRoot,
|
||||||
relativePath: relative,
|
relativePath: relative,
|
||||||
data: content,
|
data: content,
|
||||||
mkdir: true,
|
mkdir: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
access: async (absolutePath: string) => {
|
access: async (absolutePath: string) => {
|
||||||
|
const resolved = assertReadPermitted(absolutePath);
|
||||||
let relative: string;
|
let relative: string;
|
||||||
try {
|
try {
|
||||||
relative = toRelativeWorkspacePath(root, absolutePath);
|
relative = toRelativeWorkspacePath(resolvedRoot, resolved);
|
||||||
} catch {
|
} catch {
|
||||||
// Path escapes workspace root. Don't throw here – the upstream
|
// Path escapes workspace root. Don't throw here – the upstream
|
||||||
// library replaces any `access` error with a misleading "File not
|
// library replaces any `access` error with a misleading "File not
|
||||||
|
|
|
||||||
|
|
@ -339,6 +339,7 @@ export function createOpenClawCodingTools(options?: {
|
||||||
const fsConfig = resolveToolFsConfig({ cfg: options?.config, agentId });
|
const fsConfig = resolveToolFsConfig({ cfg: options?.config, agentId });
|
||||||
const fsPolicy = createToolFsPolicy({
|
const fsPolicy = createToolFsPolicy({
|
||||||
workspaceOnly: isMemoryFlushRun || fsConfig.workspaceOnly,
|
workspaceOnly: isMemoryFlushRun || fsConfig.workspaceOnly,
|
||||||
|
permissions: fsConfig.permissions,
|
||||||
});
|
});
|
||||||
const sandboxRoot = sandbox?.workspaceDir;
|
const sandboxRoot = sandbox?.workspaceDir;
|
||||||
const sandboxFsBridge = sandbox?.fsBridge;
|
const sandboxFsBridge = sandbox?.fsBridge;
|
||||||
|
|
@ -384,6 +385,8 @@ export function createOpenClawCodingTools(options?: {
|
||||||
const wrapped = createOpenClawReadTool(freshReadTool, {
|
const wrapped = createOpenClawReadTool(freshReadTool, {
|
||||||
modelContextWindowTokens: options?.modelContextWindowTokens,
|
modelContextWindowTokens: options?.modelContextWindowTokens,
|
||||||
imageSanitization,
|
imageSanitization,
|
||||||
|
permissions: fsPolicy.permissions,
|
||||||
|
workspaceRoot,
|
||||||
});
|
});
|
||||||
return [workspaceOnly ? wrapToolWorkspaceRootGuard(wrapped, workspaceRoot) : wrapped];
|
return [workspaceOnly ? wrapToolWorkspaceRootGuard(wrapped, workspaceRoot) : wrapped];
|
||||||
}
|
}
|
||||||
|
|
@ -394,14 +397,20 @@ export function createOpenClawCodingTools(options?: {
|
||||||
if (sandboxRoot) {
|
if (sandboxRoot) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const wrapped = createHostWorkspaceWriteTool(workspaceRoot, { workspaceOnly });
|
const wrapped = createHostWorkspaceWriteTool(workspaceRoot, {
|
||||||
|
workspaceOnly,
|
||||||
|
permissions: fsPolicy.permissions,
|
||||||
|
});
|
||||||
return [workspaceOnly ? wrapToolWorkspaceRootGuard(wrapped, workspaceRoot) : wrapped];
|
return [workspaceOnly ? wrapToolWorkspaceRootGuard(wrapped, workspaceRoot) : wrapped];
|
||||||
}
|
}
|
||||||
if (tool.name === "edit") {
|
if (tool.name === "edit") {
|
||||||
if (sandboxRoot) {
|
if (sandboxRoot) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const wrapped = createHostWorkspaceEditTool(workspaceRoot, { workspaceOnly });
|
const wrapped = createHostWorkspaceEditTool(workspaceRoot, {
|
||||||
|
workspaceOnly,
|
||||||
|
permissions: fsPolicy.permissions,
|
||||||
|
});
|
||||||
return [workspaceOnly ? wrapToolWorkspaceRootGuard(wrapped, workspaceRoot) : wrapped];
|
return [workspaceOnly ? wrapToolWorkspaceRootGuard(wrapped, workspaceRoot) : wrapped];
|
||||||
}
|
}
|
||||||
return [tool];
|
return [tool];
|
||||||
|
|
@ -409,6 +418,7 @@ export function createOpenClawCodingTools(options?: {
|
||||||
const { cleanupMs: cleanupMsOverride, ...execDefaults } = options?.exec ?? {};
|
const { cleanupMs: cleanupMsOverride, ...execDefaults } = options?.exec ?? {};
|
||||||
const execTool = createExecTool({
|
const execTool = createExecTool({
|
||||||
...execDefaults,
|
...execDefaults,
|
||||||
|
permissions: options?.exec?.permissions ?? fsPolicy.permissions,
|
||||||
host: options?.exec?.host ?? execConfig.host,
|
host: options?.exec?.host ?? execConfig.host,
|
||||||
security: options?.exec?.security ?? execConfig.security,
|
security: options?.exec?.security ?? execConfig.security,
|
||||||
ask: options?.exec?.ask ?? execConfig.ask,
|
ask: options?.exec?.ask ?? execConfig.ask,
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,26 @@
|
||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
|
import type { AccessPolicyConfig } from "../config/types.tools.js";
|
||||||
|
import { resolveAccessPolicyForAgent } from "../infra/access-policy-file.js";
|
||||||
import { resolveAgentConfig } from "./agent-scope.js";
|
import { resolveAgentConfig } from "./agent-scope.js";
|
||||||
|
|
||||||
export type ToolFsPolicy = {
|
export type ToolFsPolicy = {
|
||||||
workspaceOnly: boolean;
|
workspaceOnly: boolean;
|
||||||
|
permissions?: AccessPolicyConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createToolFsPolicy(params: { workspaceOnly?: boolean }): ToolFsPolicy {
|
export function createToolFsPolicy(params: {
|
||||||
|
workspaceOnly?: boolean;
|
||||||
|
permissions?: AccessPolicyConfig;
|
||||||
|
}): ToolFsPolicy {
|
||||||
return {
|
return {
|
||||||
workspaceOnly: params.workspaceOnly === true,
|
workspaceOnly: params.workspaceOnly === true,
|
||||||
|
permissions: params.permissions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveToolFsConfig(params: { cfg?: OpenClawConfig; agentId?: string }): {
|
export function resolveToolFsConfig(params: { cfg?: OpenClawConfig; agentId?: string }): {
|
||||||
workspaceOnly?: boolean;
|
workspaceOnly?: boolean;
|
||||||
|
permissions?: AccessPolicyConfig;
|
||||||
} {
|
} {
|
||||||
const cfg = params.cfg;
|
const cfg = params.cfg;
|
||||||
const globalFs = cfg?.tools?.fs;
|
const globalFs = cfg?.tools?.fs;
|
||||||
|
|
@ -20,6 +28,7 @@ export function resolveToolFsConfig(params: { cfg?: OpenClawConfig; agentId?: st
|
||||||
cfg && params.agentId ? resolveAgentConfig(cfg, params.agentId)?.tools?.fs : undefined;
|
cfg && params.agentId ? resolveAgentConfig(cfg, params.agentId)?.tools?.fs : undefined;
|
||||||
return {
|
return {
|
||||||
workspaceOnly: agentFs?.workspaceOnly ?? globalFs?.workspaceOnly,
|
workspaceOnly: agentFs?.workspaceOnly ?? globalFs?.workspaceOnly,
|
||||||
|
permissions: resolveAccessPolicyForAgent(params.agentId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,39 @@ import type { SafeBinProfileFixture } from "../infra/exec-safe-bin-policy.js";
|
||||||
import type { AgentElevatedAllowFromConfig, SessionSendPolicyAction } from "./types.base.js";
|
import type { AgentElevatedAllowFromConfig, SessionSendPolicyAction } from "./types.base.js";
|
||||||
import type { SecretInput } from "./types.secrets.js";
|
import type { SecretInput } from "./types.secrets.js";
|
||||||
|
|
||||||
|
/** Three-character permission string: `rwx`, `r--`, `rw-`, `---`, etc. */
|
||||||
|
export type PermStr = string;
|
||||||
|
|
||||||
|
/** Per-script policy entry — allows narrower permissions for a specific script binary. */
|
||||||
|
export type ScriptPolicyEntry = {
|
||||||
|
/** Extra path rules for this script, merged over the shared script policy. */
|
||||||
|
policy?: Record<string, PermStr>;
|
||||||
|
/** SHA-256 hex of the script file for integrity checking (best-effort, not atomic). */
|
||||||
|
sha256?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filesystem RWX access-policy config loaded from `access-policy.json`.
|
||||||
|
* Applied per-agent to read, write, and exec tool calls.
|
||||||
|
*
|
||||||
|
* Implicit fallback when no rule matches: `"---"` (deny-all).
|
||||||
|
* To set a permissive default, add a `"/**"` rule (e.g. `"/**": "r--"`).
|
||||||
|
*/
|
||||||
|
export type AccessPolicyConfig = {
|
||||||
|
/** Glob-pattern rules: path → permission string. Longest prefix wins. */
|
||||||
|
policy?: Record<string, PermStr>;
|
||||||
|
/**
|
||||||
|
* Per-script argv0 policy overrides keyed by resolved binary path.
|
||||||
|
* Reserved key "policy" holds shared rules applied to every script before
|
||||||
|
* per-script policy is merged in.
|
||||||
|
*/
|
||||||
|
scripts?: {
|
||||||
|
/** Shared rules applied to all scripts; per-script policy wins on collision. */
|
||||||
|
policy?: Record<string, PermStr>;
|
||||||
|
[path: string]: ScriptPolicyEntry | Record<string, PermStr> | undefined;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type MediaUnderstandingScopeMatch = {
|
export type MediaUnderstandingScopeMatch = {
|
||||||
channel?: string;
|
channel?: string;
|
||||||
chatType?: ChatType;
|
chatType?: ChatType;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,508 @@
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
BROKEN_POLICY_FILE,
|
||||||
|
_resetFileCacheForTest,
|
||||||
|
_resetNotFoundWarnedForTest,
|
||||||
|
loadAccessPolicyFile,
|
||||||
|
mergeAccessPolicy,
|
||||||
|
resolveAccessPolicyForAgent,
|
||||||
|
resolveAccessPolicyPath,
|
||||||
|
type AccessPolicyFile,
|
||||||
|
} from "./access-policy-file.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// The global test setup (test/setup.ts → withIsolatedTestHome) sets HOME to a
|
||||||
|
// per-worker temp directory before any tests run. os.homedir() respects HOME on
|
||||||
|
// macOS/Linux, so resolveAccessPolicyPath() resolves to
|
||||||
|
// "<testHome>/.openclaw/access-policy.json" — already isolated from the real
|
||||||
|
// user home. We just need to ensure the .openclaw dir exists and clean up the
|
||||||
|
// file after each test.
|
||||||
|
|
||||||
|
const FP_FILE = resolveAccessPolicyPath();
|
||||||
|
const FP_DIR = path.dirname(FP_FILE);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fs.mkdirSync(FP_DIR, { recursive: true });
|
||||||
|
_resetNotFoundWarnedForTest();
|
||||||
|
_resetFileCacheForTest();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Remove the file if a test wrote it; leave the directory to avoid races.
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(FP_FILE);
|
||||||
|
} catch {
|
||||||
|
/* file may not exist — that's fine */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function writeFile(content: AccessPolicyFile | object) {
|
||||||
|
fs.writeFileSync(FP_FILE, JSON.stringify(content, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// mergeAccessPolicy
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("mergeAccessPolicy", () => {
|
||||||
|
it("returns undefined when both are undefined", () => {
|
||||||
|
expect(mergeAccessPolicy(undefined, undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns base when override is undefined", () => {
|
||||||
|
const base = { policy: { "/**": "r--" as const } };
|
||||||
|
expect(mergeAccessPolicy(base, undefined)).toEqual(base);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns override when base is undefined", () => {
|
||||||
|
const override = { policy: { "/**": "rwx" as const } };
|
||||||
|
expect(mergeAccessPolicy(undefined, override)).toEqual(override);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a copy when base is undefined — mutations do not corrupt the original", () => {
|
||||||
|
// mergeAccessPolicy(undefined, x) was returning x by reference. validateAccessPolicyConfig
|
||||||
|
// calls autoExpandBareDir which mutates .policy in-place, permanently corrupting the cached
|
||||||
|
// agents["*"] object for all subsequent calls in the same process.
|
||||||
|
const override = { policy: { "/tmp": "rwx" as const } };
|
||||||
|
const result = mergeAccessPolicy(undefined, override);
|
||||||
|
// Simulate autoExpandBareDir mutating the result.
|
||||||
|
if (result?.policy) {
|
||||||
|
result.policy["/tmp/**"] = "rwx";
|
||||||
|
delete result.policy["/tmp"];
|
||||||
|
}
|
||||||
|
// The original override must be unchanged.
|
||||||
|
expect(override.policy).toEqual({ "/tmp": "rwx" });
|
||||||
|
expect(override.policy["/tmp/**" as keyof typeof override.policy]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deep-copies nested scripts policy maps — mutations do not corrupt cache", () => {
|
||||||
|
// Shallow-copying scripts is not enough: scripts["policy"] and per-script entry.policy
|
||||||
|
// are nested objects that would still be references into the cached _fileCache object.
|
||||||
|
const scriptPolicy = { "/tmp/**": "rwx" as const };
|
||||||
|
const entryPolicy = { "/data/**": "r--" as const };
|
||||||
|
const override = {
|
||||||
|
scripts: {
|
||||||
|
policy: scriptPolicy,
|
||||||
|
"/deploy.sh": { policy: entryPolicy },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = mergeAccessPolicy(undefined, override);
|
||||||
|
// Mutate the returned scripts["policy"] and per-script policy.
|
||||||
|
const sp = result?.scripts?.["policy"];
|
||||||
|
if (sp) {
|
||||||
|
sp["/added/**"] = "---";
|
||||||
|
}
|
||||||
|
const entry = result?.scripts?.["/deploy.sh"] as
|
||||||
|
| { policy?: Record<string, string> }
|
||||||
|
| undefined;
|
||||||
|
if (entry?.policy) {
|
||||||
|
entry.policy["/added/**"] = "---";
|
||||||
|
}
|
||||||
|
// Originals must be unchanged.
|
||||||
|
expect(scriptPolicy).toEqual({ "/tmp/**": "rwx" });
|
||||||
|
expect(entryPolicy).toEqual({ "/data/**": "r--" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rules are shallow-merged, override key wins on collision", () => {
|
||||||
|
const result = mergeAccessPolicy(
|
||||||
|
{ policy: { "/**": "r--", "~/**": "rw-" } },
|
||||||
|
{ policy: { "~/**": "rwx", "~/dev/**": "rwx" } },
|
||||||
|
);
|
||||||
|
expect(result?.policy?.["/**"]).toBe("r--"); // base survives
|
||||||
|
expect(result?.policy?.["~/**"]).toBe("rwx"); // override wins
|
||||||
|
expect(result?.policy?.["~/dev/**"]).toBe("rwx"); // override adds
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits empty rules from result", () => {
|
||||||
|
const result = mergeAccessPolicy({ scripts: { "/s.sh": { sha256: "abc" } } }, {});
|
||||||
|
expect(result?.policy).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scripts deep-merge: base sha256 is preserved when override supplies same script key", () => {
|
||||||
|
// Security regression: a shallow spread ({ ...base.scripts, ...override.scripts }) would
|
||||||
|
// silently drop the admin-configured sha256 hash check, defeating integrity enforcement.
|
||||||
|
const base = {
|
||||||
|
scripts: {
|
||||||
|
"/usr/local/bin/deploy.sh": {
|
||||||
|
sha256: "abc123",
|
||||||
|
policy: { "~/deploy/**": "rwx" as const },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const override = {
|
||||||
|
scripts: {
|
||||||
|
"/usr/local/bin/deploy.sh": {
|
||||||
|
// Agent block supplies same key — must NOT be able to drop sha256.
|
||||||
|
policy: { "~/deploy/**": "r--" as const }, // narrower override — fine
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = mergeAccessPolicy(base, override);
|
||||||
|
const merged = result?.scripts?.["/usr/local/bin/deploy.sh"] as
|
||||||
|
| import("../config/types.tools.js").ScriptPolicyEntry
|
||||||
|
| undefined;
|
||||||
|
// sha256 from base must survive.
|
||||||
|
expect(merged?.sha256).toBe("abc123");
|
||||||
|
// policy: override key wins on collision.
|
||||||
|
expect(merged?.policy?.["~/deploy/**"]).toBe("r--");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scripts deep-merge: override-only script key is added verbatim", () => {
|
||||||
|
const base = { scripts: { "/bin/existing.sh": { sha256: "deadbeef" } } };
|
||||||
|
const override = {
|
||||||
|
scripts: { "/bin/new.sh": { policy: { "/tmp/**": "rwx" as const } } },
|
||||||
|
};
|
||||||
|
const result = mergeAccessPolicy(base, override);
|
||||||
|
// Base script untouched.
|
||||||
|
expect(result?.scripts?.["/bin/existing.sh"]?.sha256).toBe("deadbeef");
|
||||||
|
// New script from override is added.
|
||||||
|
const newScript = result?.scripts?.["/bin/new.sh"] as
|
||||||
|
| import("../config/types.tools.js").ScriptPolicyEntry
|
||||||
|
| undefined;
|
||||||
|
expect(newScript?.policy?.["/tmp/**"]).toBe("rwx");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// loadAccessPolicyFile
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("loadAccessPolicyFile", () => {
|
||||||
|
it("returns null when file does not exist", () => {
|
||||||
|
expect(loadAccessPolicyFile()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns BROKEN_POLICY_FILE and logs error when file is invalid JSON", () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
const p = resolveAccessPolicyPath();
|
||||||
|
fs.writeFileSync(p, "not json {{ broken");
|
||||||
|
const result = loadAccessPolicyFile();
|
||||||
|
expect(result).toBe(BROKEN_POLICY_FILE);
|
||||||
|
expect(spy).toHaveBeenCalledWith(expect.stringContaining("Cannot parse"));
|
||||||
|
expect(spy).toHaveBeenCalledWith(expect.stringContaining("Failing closed"));
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns BROKEN_POLICY_FILE and logs error when version is not 1", () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
writeFile({ version: 2, base: {} });
|
||||||
|
const result = loadAccessPolicyFile();
|
||||||
|
expect(result).toBe(BROKEN_POLICY_FILE);
|
||||||
|
expect(spy).toHaveBeenCalledWith(expect.stringContaining("unsupported version"));
|
||||||
|
expect(spy).toHaveBeenCalledWith(expect.stringContaining("Failing closed"));
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns BROKEN_POLICY_FILE and logs error when base is placed at top level (use agents["*"])', () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
writeFile({ version: 1, base: { policy: { "/**": "r--" } } });
|
||||||
|
const result = loadAccessPolicyFile();
|
||||||
|
expect(result).toBe(BROKEN_POLICY_FILE);
|
||||||
|
expect(spy).toHaveBeenCalledWith(expect.stringContaining('unexpected top-level key "base"'));
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns BROKEN_POLICY_FILE and logs error when agents is not an object", () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
writeFile({ version: 1, agents: "bad" });
|
||||||
|
const result = loadAccessPolicyFile();
|
||||||
|
expect(result).toBe(BROKEN_POLICY_FILE);
|
||||||
|
expect(spy).toHaveBeenCalledWith(expect.stringContaining('"agents" must be an object'));
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns BROKEN_POLICY_FILE and logs error when a top-level key like 'policy' is misplaced", () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
// Common mistake: policy at top level instead of under agents["*"]
|
||||||
|
writeFile({ version: 1, policy: { "/**": "r--" } });
|
||||||
|
const result = loadAccessPolicyFile();
|
||||||
|
expect(result).toBe(BROKEN_POLICY_FILE);
|
||||||
|
expect(spy).toHaveBeenCalledWith(expect.stringContaining('unexpected top-level key "policy"'));
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns BROKEN_POLICY_FILE and logs error when 'scripts' is misplaced at top level", () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
writeFile({ version: 1, scripts: { "/bin/s.sh": { sha256: "abc" } } });
|
||||||
|
const result = loadAccessPolicyFile();
|
||||||
|
expect(result).toBe(BROKEN_POLICY_FILE);
|
||||||
|
expect(spy).toHaveBeenCalledWith(expect.stringContaining('unexpected top-level key "scripts"'));
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns BROKEN_POLICY_FILE and logs error when an agent block is not an object", () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
writeFile({ version: 1, agents: { subri: "rwx" } });
|
||||||
|
const result = loadAccessPolicyFile();
|
||||||
|
expect(result).toBe(BROKEN_POLICY_FILE);
|
||||||
|
expect(spy).toHaveBeenCalledWith(expect.stringContaining('agents["subri"] must be an object'));
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns BROKEN_POLICY_FILE when scripts["policy"] is a primitive (not an object)', () => {
|
||||||
|
// scripts["policy"] must be a Record<string, PermStr>; a primitive like `true`
|
||||||
|
// silently passes structural validation and is treated as an empty shared policy.
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
writeFile({
|
||||||
|
version: 1,
|
||||||
|
agents: { "*": { scripts: { policy: true as unknown as Record<string, string> } } },
|
||||||
|
});
|
||||||
|
const result = loadAccessPolicyFile();
|
||||||
|
expect(result).toBe(BROKEN_POLICY_FILE);
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('scripts["policy"] must be an object'),
|
||||||
|
);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns parsed file when valid", () => {
|
||||||
|
const content: AccessPolicyFile = {
|
||||||
|
version: 1,
|
||||||
|
agents: {
|
||||||
|
"*": { policy: { "/**": "r--", "~/.ssh/**": "---" } },
|
||||||
|
subri: { policy: { "~/dev/**": "rwx" } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
writeFile(content);
|
||||||
|
const result = loadAccessPolicyFile();
|
||||||
|
expect(result).not.toBe(BROKEN_POLICY_FILE);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
if (result === null || result === BROKEN_POLICY_FILE) {
|
||||||
|
throw new Error("unexpected");
|
||||||
|
}
|
||||||
|
expect(result.version).toBe(1);
|
||||||
|
expect(result.agents?.["*"]?.policy?.["/**"]).toBe("r--");
|
||||||
|
expect(result.agents?.subri?.policy?.["~/dev/**"]).toBe("rwx");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// loadAccessPolicyFile — mtime cache
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("loadAccessPolicyFile — mtime cache", () => {
|
||||||
|
it("returns cached result on second call without re-reading the file", () => {
|
||||||
|
writeFile({ version: 1, agents: { "*": { policy: { "/**": "r--" } } } });
|
||||||
|
const spy = vi.spyOn(fs, "readFileSync");
|
||||||
|
loadAccessPolicyFile(); // populate cache
|
||||||
|
loadAccessPolicyFile(); // should hit cache
|
||||||
|
// readFileSync should only be called once despite two loadAccessPolicyFile calls.
|
||||||
|
expect(spy.mock.calls.filter((c) => String(c[0]).includes("access-policy")).length).toBe(1);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-reads when mtime changes (file updated)", () => {
|
||||||
|
writeFile({ version: 1, agents: { "*": { policy: { "/**": "r--" } } } });
|
||||||
|
loadAccessPolicyFile(); // populate cache
|
||||||
|
// Rewrite the file — on most filesystems this bumps mtime. Force a detectable
|
||||||
|
// mtime change by setting it explicitly via utimesSync.
|
||||||
|
writeFile({ version: 1, agents: { "*": { policy: { "/**": "rwx" } } } });
|
||||||
|
const future = Date.now() / 1000 + 1;
|
||||||
|
fs.utimesSync(FP_FILE, future, future);
|
||||||
|
const result = loadAccessPolicyFile();
|
||||||
|
expect(result).not.toBe(BROKEN_POLICY_FILE);
|
||||||
|
if (result === null || result === BROKEN_POLICY_FILE) {
|
||||||
|
throw new Error("unexpected");
|
||||||
|
}
|
||||||
|
expect(result.agents?.["*"]?.policy?.["/**"]).toBe("rwx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears cache when file is deleted", () => {
|
||||||
|
writeFile({ version: 1, agents: { "*": { policy: { "/**": "r--" } } } });
|
||||||
|
loadAccessPolicyFile(); // populate cache
|
||||||
|
fs.unlinkSync(FP_FILE);
|
||||||
|
expect(loadAccessPolicyFile()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caches BROKEN_POLICY_FILE result for broken files", () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
fs.writeFileSync(FP_FILE, "not json {{ broken");
|
||||||
|
loadAccessPolicyFile(); // populate cache with BROKEN
|
||||||
|
const spy2 = vi.spyOn(fs, "readFileSync");
|
||||||
|
const result = loadAccessPolicyFile(); // should hit cache
|
||||||
|
expect(result).toBe(BROKEN_POLICY_FILE);
|
||||||
|
expect(spy2.mock.calls.filter((c) => String(c[0]).includes("access-policy")).length).toBe(0);
|
||||||
|
spy.mockRestore();
|
||||||
|
spy2.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// resolveAccessPolicyForAgent
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("resolveAccessPolicyForAgent", () => {
|
||||||
|
it("returns undefined when file does not exist", () => {
|
||||||
|
expect(resolveAccessPolicyForAgent("subri")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not warn when config file is not found (feature is opt-in)", () => {
|
||||||
|
// access-policy is opt-in; absence of the file is the normal state and
|
||||||
|
// must not produce console noise for users who have not configured it.
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
resolveAccessPolicyForAgent("subri");
|
||||||
|
resolveAccessPolicyForAgent("subri");
|
||||||
|
expect(warnSpy).not.toHaveBeenCalled();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not warn when config file exists and is valid", () => {
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
writeFile({ version: 1, agents: { "*": { policy: { "/**": "r--" } } } });
|
||||||
|
resolveAccessPolicyForAgent("subri");
|
||||||
|
expect(warnSpy).not.toHaveBeenCalled();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns deny-all and logs error when config file is broken (fail-closed)", () => {
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
writeFile({ version: 1, policy: { "/**": "r--" } }); // misplaced key — triggers error
|
||||||
|
const result = resolveAccessPolicyForAgent("subri");
|
||||||
|
expect(warnSpy).not.toHaveBeenCalled();
|
||||||
|
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("Failing closed"));
|
||||||
|
// Broken file must fail-closed: deny-all policy (empty rules = implicit "---"), not undefined
|
||||||
|
expect(result).toEqual({});
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
errSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deny-all policy returned on broken file is frozen — mutation does not corrupt future calls", () => {
|
||||||
|
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
writeFile({ version: 1, policy: { "/**": "r--" } }); // misplaced key — broken
|
||||||
|
const result = resolveAccessPolicyForAgent("subri");
|
||||||
|
expect(result).toEqual({});
|
||||||
|
// Attempt to mutate the returned object — must not affect the next call.
|
||||||
|
// If DENY_ALL_POLICY is not frozen this would silently corrupt it.
|
||||||
|
try {
|
||||||
|
(result as Record<string, unknown>)["rules"] = { "/**": "rwx" };
|
||||||
|
} catch {
|
||||||
|
// Object.freeze throws in strict mode — that's fine too.
|
||||||
|
}
|
||||||
|
_resetNotFoundWarnedForTest();
|
||||||
|
const result2 = resolveAccessPolicyForAgent("subri");
|
||||||
|
expect(result2).toEqual({});
|
||||||
|
errSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns base when no agent block exists", () => {
|
||||||
|
writeFile({
|
||||||
|
version: 1,
|
||||||
|
agents: { "*": { policy: { "/**": "r--", [`~/.ssh/**`]: "---" } } },
|
||||||
|
});
|
||||||
|
const result = resolveAccessPolicyForAgent("subri");
|
||||||
|
expect(result?.policy?.["/**"]).toBe("r--");
|
||||||
|
expect(result?.policy?.["~/.ssh/**"]).toBe("---");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges base + named agent", () => {
|
||||||
|
writeFile({
|
||||||
|
version: 1,
|
||||||
|
agents: {
|
||||||
|
"*": { policy: { "/**": "r--", [`~/.ssh/**`]: "---" } },
|
||||||
|
subri: { policy: { "~/dev/**": "rwx" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = resolveAccessPolicyForAgent("subri");
|
||||||
|
// policy: merged, agent rule wins on collision
|
||||||
|
expect(result?.policy?.["/**"]).toBe("r--");
|
||||||
|
expect(result?.policy?.["~/dev/**"]).toBe("rwx");
|
||||||
|
// base "---" rule preserved
|
||||||
|
expect(result?.policy?.["~/.ssh/**"]).toBe("---");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wildcard agent applies before named agent", () => {
|
||||||
|
writeFile({
|
||||||
|
version: 1,
|
||||||
|
agents: {
|
||||||
|
"*": { policy: { "/usr/bin/**": "r-x" } },
|
||||||
|
subri: { policy: { "~/dev/**": "rwx" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = resolveAccessPolicyForAgent("subri");
|
||||||
|
expect(result?.policy?.["/usr/bin/**"]).toBe("r-x"); // from wildcard
|
||||||
|
expect(result?.policy?.["~/dev/**"]).toBe("rwx"); // from named agent
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wildcard applies even when no named agent block", () => {
|
||||||
|
writeFile({
|
||||||
|
version: 1,
|
||||||
|
agents: { "*": { policy: { [`~/.ssh/**`]: "---" } } },
|
||||||
|
});
|
||||||
|
const result = resolveAccessPolicyForAgent("other-agent");
|
||||||
|
expect(result?.policy?.["~/.ssh/**"]).toBe("---");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wildcard key itself is not treated as a named agent", () => {
|
||||||
|
writeFile({
|
||||||
|
version: 1,
|
||||||
|
agents: { "*": { policy: { [`~/.ssh/**`]: "---" } } },
|
||||||
|
});
|
||||||
|
// Requesting agentId "*" should not double-apply wildcard as named
|
||||||
|
const result = resolveAccessPolicyForAgent("*");
|
||||||
|
expect(result?.policy?.["~/.ssh/**"]).toBe("---");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when file is empty (no base, no agents)", () => {
|
||||||
|
writeFile({ version: 1 });
|
||||||
|
// No base and no agents → nothing to merge → undefined
|
||||||
|
expect(resolveAccessPolicyForAgent("subri")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs console.error (not warn) when perm string is invalid", () => {
|
||||||
|
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
writeFile({
|
||||||
|
version: 1,
|
||||||
|
agents: { "*": { policy: { "/**": "BAD" } } },
|
||||||
|
});
|
||||||
|
resolveAccessPolicyForAgent("subri");
|
||||||
|
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("BAD"));
|
||||||
|
expect(warnSpy).not.toHaveBeenCalled();
|
||||||
|
errSpy.mockRestore();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not print 'Bad permission strings' footer when only auto-expand diagnostics are present", () => {
|
||||||
|
// Greptile: footer was printed after auto-expand messages ("rule auto-expanded to ..."),
|
||||||
|
// misleading operators into thinking their policy was broken when it was fine.
|
||||||
|
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
// Write a file whose rules entry is a bare directory — triggers auto-expand diagnostic
|
||||||
|
// but no real perm-string error.
|
||||||
|
const dir = os.tmpdir();
|
||||||
|
writeFile({ version: 1, agents: { "*": { policy: { [dir]: "r--" } } } });
|
||||||
|
resolveAccessPolicyForAgent("subri");
|
||||||
|
const calls = errSpy.mock.calls.map((c) => String(c[0]));
|
||||||
|
expect(calls.some((m) => m.includes("auto-expanded"))).toBe(true);
|
||||||
|
expect(calls.some((m) => m.includes("Bad permission strings"))).toBe(false);
|
||||||
|
errSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints 'Bad permission strings' footer when a real perm-string error is present", () => {
|
||||||
|
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
writeFile({ version: 1, agents: { "*": { policy: { "/**": "BAD" } } } });
|
||||||
|
resolveAccessPolicyForAgent("subri");
|
||||||
|
const calls = errSpy.mock.calls.map((c) => String(c[0]));
|
||||||
|
expect(calls.some((m) => m.includes("Bad permission strings"))).toBe(true);
|
||||||
|
errSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("narrowing rules from base and agent are all preserved in merged result", () => {
|
||||||
|
writeFile({
|
||||||
|
version: 1,
|
||||||
|
agents: {
|
||||||
|
"*": { policy: { [`~/.ssh/**`]: "---" } },
|
||||||
|
paranoid: { policy: { [`~/.aws/**`]: "---" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = resolveAccessPolicyForAgent("paranoid");
|
||||||
|
expect(result?.policy?.["~/.ssh/**"]).toBe("---");
|
||||||
|
expect(result?.policy?.["~/.aws/**"]).toBe("---");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,376 @@
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { AccessPolicyConfig } from "../config/types.tools.js";
|
||||||
|
import { validateAccessPolicyConfig } from "./access-policy.js";
|
||||||
|
|
||||||
|
export type AccessPolicyFile = {
|
||||||
|
version: 1;
|
||||||
|
/**
|
||||||
|
* Per-agent overrides keyed by agent ID.
|
||||||
|
*
|
||||||
|
* Reserved key:
|
||||||
|
* "*" — base policy applied to every agent before the named agent block is merged in.
|
||||||
|
*
|
||||||
|
* Merge order (each layer wins over the previous):
|
||||||
|
* agents["*"] → agents[agentId]
|
||||||
|
*
|
||||||
|
* Within each layer:
|
||||||
|
* - policy: shallow-merge, override key wins on collision
|
||||||
|
* - scripts: deep-merge per key; base sha256 is preserved
|
||||||
|
*/
|
||||||
|
agents?: Record<string, AccessPolicyConfig>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use os.homedir() directly — NOT expandHomePrefix — so that OPENCLAW_HOME
|
||||||
|
// (which points at ~/.openclaw, the data dir) does not produce a double-nested
|
||||||
|
// path like ~/.openclaw/.openclaw/access-policy.json.
|
||||||
|
export function resolveAccessPolicyPath(): string {
|
||||||
|
return path.join(os.homedir(), ".openclaw", "access-policy.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge two AccessPolicyConfig layers.
|
||||||
|
* - rules: shallow merge, override key wins
|
||||||
|
* - scripts: deep-merge per key; base sha256 is preserved (cannot be removed by override)
|
||||||
|
*/
|
||||||
|
export function mergeAccessPolicy(
|
||||||
|
base: AccessPolicyConfig | undefined,
|
||||||
|
override: AccessPolicyConfig | undefined,
|
||||||
|
): AccessPolicyConfig | undefined {
|
||||||
|
if (!base && !override) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!base) {
|
||||||
|
// Return a shallow copy so that validateAccessPolicyConfig → autoExpandBareDir
|
||||||
|
// does not mutate the cached agents["*"] object in _fileCache. Without this,
|
||||||
|
// the first call permanently corrupts policy entries for all subsequent calls
|
||||||
|
// in the same process.
|
||||||
|
// Deep-copy nested policy records so validateAccessPolicyConfig → autoExpandBareDir
|
||||||
|
// cannot mutate the cached _fileCache object. Shallow-copying `scripts` is not enough:
|
||||||
|
// `scripts["policy"]` (shared rules map) and per-script `entry.policy` maps are nested
|
||||||
|
// objects that would still be references into the cache.
|
||||||
|
const overrideScripts = override.scripts;
|
||||||
|
const scriptsCopy: AccessPolicyConfig["scripts"] | undefined = overrideScripts
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(overrideScripts).map(([k, v]) => {
|
||||||
|
if (k === "policy") {
|
||||||
|
// scripts["policy"] is a Record<string, PermStr> — shallow copy is sufficient.
|
||||||
|
return [k, v != null && typeof v === "object" ? { ...v } : v];
|
||||||
|
}
|
||||||
|
// Per-script entries: copy the entry and its nested policy map.
|
||||||
|
if (v != null && typeof v === "object" && !Array.isArray(v)) {
|
||||||
|
const entry = v as import("../config/types.tools.js").ScriptPolicyEntry;
|
||||||
|
return [k, { ...entry, policy: entry.policy ? { ...entry.policy } : undefined }];
|
||||||
|
}
|
||||||
|
return [k, v];
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
...override,
|
||||||
|
policy: override.policy ? { ...override.policy } : undefined,
|
||||||
|
scripts: scriptsCopy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!override) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
const rules = { ...base.policy, ...override.policy };
|
||||||
|
// scripts: deep-merge per key — base sha256 is preserved regardless of
|
||||||
|
// what the agent override supplies. A plain spread ({ ...base.scripts, ...override.scripts })
|
||||||
|
// would silently drop the admin-configured hash integrity check when an agent block
|
||||||
|
// supplies the same script key, defeating the security intent.
|
||||||
|
const mergedScripts: NonNullable<AccessPolicyConfig["scripts"]> = { ...base.scripts };
|
||||||
|
for (const [key, overrideEntry] of Object.entries(override.scripts ?? {})) {
|
||||||
|
if (key === "policy") {
|
||||||
|
// "policy" holds shared rules (Record<string, PermStr>) — merge as flat rules,
|
||||||
|
// override key wins. No sha256 preservation applies here.
|
||||||
|
mergedScripts["policy"] = { ...base.scripts?.["policy"], ...overrideEntry } as Record<
|
||||||
|
string,
|
||||||
|
string
|
||||||
|
>;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const baseEntry = base.scripts?.[key] as
|
||||||
|
| import("../config/types.tools.js").ScriptPolicyEntry
|
||||||
|
| undefined;
|
||||||
|
// Reject non-object entries — a primitive (true, "rwx") is not a valid ScriptPolicyEntry.
|
||||||
|
// validateAccessPolicyConfig also catches this, but the merge layer must not propagate it.
|
||||||
|
if (
|
||||||
|
overrideEntry == null ||
|
||||||
|
typeof overrideEntry !== "object" ||
|
||||||
|
Array.isArray(overrideEntry)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const overrideScriptEntry =
|
||||||
|
overrideEntry as import("../config/types.tools.js").ScriptPolicyEntry;
|
||||||
|
if (!baseEntry) {
|
||||||
|
mergedScripts[key] = overrideScriptEntry;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
mergedScripts[key] = {
|
||||||
|
// sha256: base always wins — cannot be removed or replaced by an agent override.
|
||||||
|
...(baseEntry.sha256 !== undefined ? { sha256: baseEntry.sha256 } : {}),
|
||||||
|
// policy: shallow-merge, override key wins on collision.
|
||||||
|
...(Object.keys({ ...baseEntry.policy, ...overrideScriptEntry.policy }).length > 0
|
||||||
|
? { policy: { ...baseEntry.policy, ...overrideScriptEntry.policy } }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const scripts = Object.keys(mergedScripts).length > 0 ? mergedScripts : undefined;
|
||||||
|
const result: AccessPolicyConfig = {};
|
||||||
|
if (Object.keys(rules).length > 0) {
|
||||||
|
result.policy = rules;
|
||||||
|
}
|
||||||
|
if (scripts) {
|
||||||
|
result.scripts = scripts;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the top-level structure of a parsed access-policy file.
|
||||||
|
* Returns an array of error strings; empty = valid.
|
||||||
|
*/
|
||||||
|
function validateAccessPolicyFileStructure(filePath: string, parsed: unknown): string[] {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const p = parsed as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Removed fields: "deny" and "default" were dropped in favour of "---" rules.
|
||||||
|
// A user who configures these fields would receive no protection because the
|
||||||
|
// fields are silently discarded. Reject them explicitly so the file fails-closed.
|
||||||
|
const REMOVED_KEYS = ["deny", "default"] as const;
|
||||||
|
|
||||||
|
function checkRemovedKeys(block: Record<string, unknown>, context: string): void {
|
||||||
|
for (const key of REMOVED_KEYS) {
|
||||||
|
if (block[key] !== undefined) {
|
||||||
|
errors.push(
|
||||||
|
`${filePath}: ${context} "${key}" is no longer supported — use "---" rules instead (e.g. "~/.ssh/**": "---"). Failing closed until removed.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p["agents"] !== undefined) {
|
||||||
|
if (typeof p["agents"] !== "object" || p["agents"] === null || Array.isArray(p["agents"])) {
|
||||||
|
errors.push(`${filePath}: "agents" must be an object`);
|
||||||
|
} else {
|
||||||
|
for (const [agentId, block] of Object.entries(p["agents"] as Record<string, unknown>)) {
|
||||||
|
if (typeof block !== "object" || block === null || Array.isArray(block)) {
|
||||||
|
errors.push(`${filePath}: agents["${agentId}"] must be an object`);
|
||||||
|
} else {
|
||||||
|
const agentBlock = block as Record<string, unknown>;
|
||||||
|
checkRemovedKeys(agentBlock, `agents["${agentId}"]`);
|
||||||
|
// Recurse into per-script entries so old "deny"/"default" fields are caught there too.
|
||||||
|
const scripts = agentBlock["scripts"];
|
||||||
|
if (scripts != null && typeof scripts === "object" && !Array.isArray(scripts)) {
|
||||||
|
for (const [scriptKey, scriptEntry] of Object.entries(
|
||||||
|
scripts as Record<string, unknown>,
|
||||||
|
)) {
|
||||||
|
if (scriptKey === "policy") {
|
||||||
|
// scripts["policy"] must be an object (Record<string, PermStr>), not a primitive.
|
||||||
|
if (
|
||||||
|
scriptEntry != null &&
|
||||||
|
(typeof scriptEntry !== "object" || Array.isArray(scriptEntry))
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`${filePath}: agents["${agentId}"].scripts["policy"] must be an object (Record<string, PermStr>), got ${JSON.stringify(scriptEntry)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
scriptEntry != null &&
|
||||||
|
typeof scriptEntry === "object" &&
|
||||||
|
!Array.isArray(scriptEntry)
|
||||||
|
) {
|
||||||
|
checkRemovedKeys(
|
||||||
|
scriptEntry as Record<string, unknown>,
|
||||||
|
`agents["${agentId}"].scripts["${scriptKey}"]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catch common mistakes: keys placed at the top level that belong inside agents["*"].
|
||||||
|
// "base" and "policy" were old top-level fields; "rules"/"scripts" are often misplaced here too.
|
||||||
|
for (const key of ["base", "policy", "rules", "scripts"] as const) {
|
||||||
|
if (p[key] !== undefined) {
|
||||||
|
errors.push(
|
||||||
|
`${filePath}: unexpected top-level key "${key}" — did you mean to put it under agents["*"]?`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sentinel returned by loadAccessPolicyFile when the file exists but is broken.
|
||||||
|
* Callers must treat this as a deny-all policy (default:"---") rather than
|
||||||
|
* disabling enforcement — a corrupted file should fail-closed, not fail-open.
|
||||||
|
*/
|
||||||
|
export const BROKEN_POLICY_FILE = Symbol("broken-policy-file");
|
||||||
|
|
||||||
|
// In-process mtime cache — avoids re-parsing the file on every agent turn.
|
||||||
|
// Cache is keyed by mtime so any write to the file automatically invalidates it.
|
||||||
|
type _FileCacheEntry = {
|
||||||
|
mtimeMs: number;
|
||||||
|
result: AccessPolicyFile | typeof BROKEN_POLICY_FILE;
|
||||||
|
};
|
||||||
|
let _fileCache: _FileCacheEntry | undefined;
|
||||||
|
|
||||||
|
/** Reset the in-process file cache. Only for use in tests. */
|
||||||
|
export function _resetFileCacheForTest(): void {
|
||||||
|
_fileCache = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and parse the sidecar file.
|
||||||
|
* - Returns null if the file does not exist (opt-in not configured).
|
||||||
|
* - Returns BROKEN_POLICY_FILE if the file exists but is malformed/unreadable
|
||||||
|
* (callers must treat this as default:"---" — fail-closed).
|
||||||
|
* - Returns the parsed file on success.
|
||||||
|
*
|
||||||
|
* Result is cached in-process by mtime — re-parses only when the file changes.
|
||||||
|
*/
|
||||||
|
export function loadAccessPolicyFile(): AccessPolicyFile | null | typeof BROKEN_POLICY_FILE {
|
||||||
|
const filePath = resolveAccessPolicyPath();
|
||||||
|
|
||||||
|
// Single statSync per call: cheap enough and detects file changes.
|
||||||
|
let mtimeMs: number;
|
||||||
|
try {
|
||||||
|
mtimeMs = fs.statSync(filePath).mtimeMs;
|
||||||
|
} catch {
|
||||||
|
// File does not exist — clear cache and return null (feature is opt-in).
|
||||||
|
_fileCache = undefined;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache hit: same mtime means same content — skip readFileSync + JSON.parse.
|
||||||
|
if (_fileCache && _fileCache.mtimeMs === mtimeMs) {
|
||||||
|
return _fileCache.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss: parse fresh and store result (including BROKEN_POLICY_FILE).
|
||||||
|
const result = _parseAccessPolicyFile(filePath);
|
||||||
|
_fileCache = { mtimeMs, result: result ?? BROKEN_POLICY_FILE };
|
||||||
|
// _parseAccessPolicyFile returns null only for the non-existent case, which we
|
||||||
|
// handle above via statSync. If it somehow returns null here treat as broken.
|
||||||
|
return result ?? BROKEN_POLICY_FILE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _parseAccessPolicyFile(
|
||||||
|
filePath: string,
|
||||||
|
): AccessPolicyFile | typeof BROKEN_POLICY_FILE | null {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(filePath, "utf8");
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`[access-policy] Cannot parse ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
console.error(`[access-policy] Failing closed (default: "---") until the file is fixed.`);
|
||||||
|
return BROKEN_POLICY_FILE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||||
|
console.error(`[access-policy] ${filePath}: must be a JSON object at the top level.`);
|
||||||
|
console.error(`[access-policy] Failing closed (default: "---") until the file is fixed.`);
|
||||||
|
return BROKEN_POLICY_FILE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = parsed as Record<string, unknown>;
|
||||||
|
if (p["version"] !== 1) {
|
||||||
|
console.error(
|
||||||
|
`[access-policy] ${filePath}: unsupported version ${JSON.stringify(p["version"])} (expected 1).`,
|
||||||
|
);
|
||||||
|
console.error(`[access-policy] Failing closed (default: "---") until the file is fixed.`);
|
||||||
|
return BROKEN_POLICY_FILE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Structural validation — catches wrong nesting, misplaced keys, etc.
|
||||||
|
const structErrors = validateAccessPolicyFileStructure(filePath, parsed);
|
||||||
|
if (structErrors.length > 0) {
|
||||||
|
for (const err of structErrors) {
|
||||||
|
console.error(`[access-policy] ${err}`);
|
||||||
|
}
|
||||||
|
console.error(`[access-policy] Failing closed (default: "---") until the file is fixed.`);
|
||||||
|
return BROKEN_POLICY_FILE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed as AccessPolicyFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suppress repeated validation error spam — resolveAccessPolicyForAgent is called
|
||||||
|
// on every agent turn; a single bad perm string would otherwise flood stderr.
|
||||||
|
// Keyed by agentId (or "__default__") so each agent's errors are shown once,
|
||||||
|
// rather than a global flag that silently swallows errors for all agents after the first.
|
||||||
|
const _validationErrorsWarnedFor = new Set<string>();
|
||||||
|
|
||||||
|
/** Reset the one-time warning flags. Only for use in tests. */
|
||||||
|
export function _resetNotFoundWarnedForTest(): void {
|
||||||
|
_validationErrorsWarnedFor.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the effective AccessPolicyConfig for a given agent.
|
||||||
|
*
|
||||||
|
* Merge order: agents["*"] → agents[agentId]
|
||||||
|
*
|
||||||
|
* Returns undefined when no sidecar file exists (no-op — all operations pass through).
|
||||||
|
* Logs errors on invalid perm strings but does not throw — bad strings fall back to
|
||||||
|
* deny-all for that entry (handled downstream by checkAccessPolicy's permAllows logic).
|
||||||
|
*/
|
||||||
|
/** Deny-all policy returned when the policy file is present but broken (fail-closed).
|
||||||
|
* Empty rules + implicit "---" fallback = deny everything. */
|
||||||
|
const DENY_ALL_POLICY: AccessPolicyConfig = Object.freeze({});
|
||||||
|
|
||||||
|
export function resolveAccessPolicyForAgent(agentId?: string): AccessPolicyConfig | undefined {
|
||||||
|
const file = loadAccessPolicyFile();
|
||||||
|
if (file === BROKEN_POLICY_FILE) {
|
||||||
|
// File exists but is malformed — fail-closed: deny everything until fixed.
|
||||||
|
return DENY_ALL_POLICY;
|
||||||
|
}
|
||||||
|
if (!file) {
|
||||||
|
// access-policy.json is entirely opt-in — silently return undefined when the
|
||||||
|
// file is absent so users who have not configured the feature see no noise.
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// agents["*"] is the base — applies to every agent before the named block.
|
||||||
|
let merged = mergeAccessPolicy(undefined, file.agents?.["*"]);
|
||||||
|
if (agentId && agentId !== "*") {
|
||||||
|
const agentBlock = file.agents?.[agentId];
|
||||||
|
if (agentBlock) {
|
||||||
|
merged = mergeAccessPolicy(merged, agentBlock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (merged) {
|
||||||
|
const errors = validateAccessPolicyConfig(merged);
|
||||||
|
const dedupeKey = agentId ?? "__default__";
|
||||||
|
if (errors.length > 0 && !_validationErrorsWarnedFor.has(dedupeKey)) {
|
||||||
|
_validationErrorsWarnedFor.add(dedupeKey);
|
||||||
|
const filePath = resolveAccessPolicyPath();
|
||||||
|
for (const err of errors) {
|
||||||
|
console.error(`[access-policy] ${filePath}: ${err}`);
|
||||||
|
}
|
||||||
|
// Only print the footer when there are real permission-string errors —
|
||||||
|
// auto-expand diagnostics ("rule auto-expanded to ...") are informational
|
||||||
|
// and the footer would mislead operators into thinking the policy is broken.
|
||||||
|
if (errors.some((e) => !e.includes("auto-expanded") && !e.includes("mid-path wildcard"))) {
|
||||||
|
console.error(
|
||||||
|
`[access-policy] Bad permission strings are treated as "---" (deny all) at the tool layer and OS sandbox layer.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,690 @@
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { AccessPolicyConfig, PermStr } from "../config/types.tools.js";
|
||||||
|
import { matchesExecAllowlistPattern } from "./exec-allowlist-pattern.js";
|
||||||
|
|
||||||
|
export type FsOp = "read" | "write" | "exec";
|
||||||
|
|
||||||
|
const PERM_STR_RE = /^[r-][w-][x-]$/;
|
||||||
|
|
||||||
|
// Track patterns already auto-expanded so the diagnostic fires once per process,
|
||||||
|
// not once per agent turn (the policy file is re-read fresh on every turn).
|
||||||
|
const _autoExpandedWarned = new Set<string>();
|
||||||
|
|
||||||
|
/** Reset the one-time auto-expand warning set. Only for use in tests. */
|
||||||
|
export function _resetAutoExpandedWarnedForTest(): void {
|
||||||
|
_autoExpandedWarned.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track mid-path wildcard patterns already warned about — one diagnostic per pattern.
|
||||||
|
const _midPathWildcardWarned = new Set<string>();
|
||||||
|
|
||||||
|
/** Reset the mid-path wildcard warning set. Only for use in tests. */
|
||||||
|
export function _resetMidPathWildcardWarnedForTest(): void {
|
||||||
|
_midPathWildcardWarned.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when a glob pattern has a wildcard character (*, ?, or bracket)
|
||||||
|
* in a non-final path segment (e.g. "/home/*\/secrets/**").
|
||||||
|
* bwrap and Seatbelt both skip such patterns at the OS layer because the
|
||||||
|
* concrete mount/deny path cannot be derived — only the tool layer enforces them.
|
||||||
|
*/
|
||||||
|
function hasMidPathWildcard(pattern: string): boolean {
|
||||||
|
const wildcardIdx = pattern.search(/[*?[]/);
|
||||||
|
if (wildcardIdx === -1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return /[/\\]/.test(pattern.slice(wildcardIdx));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If `pattern` is a bare path (no glob metacharacters, no trailing /) that resolves
|
||||||
|
* to a real directory, auto-expand it to `pattern/**` in-place inside `rules` and push
|
||||||
|
* a diagnostic. A bare directory path matches only the directory entry itself, not its
|
||||||
|
* contents — the expanded form is almost always what the operator intended.
|
||||||
|
*
|
||||||
|
* Any stat failure is silently ignored: if the path doesn't exist the rule is a no-op.
|
||||||
|
*/
|
||||||
|
function autoExpandBareDir(
|
||||||
|
rules: Record<string, PermStr>,
|
||||||
|
pattern: string,
|
||||||
|
perm: PermStr,
|
||||||
|
errors: string[],
|
||||||
|
): void {
|
||||||
|
if (!pattern || pattern.endsWith("/") || /[*?[]/.test(pattern)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const expanded = pattern.startsWith("~") ? pattern.replace(/^~(?=$|\/)/, os.homedir()) : pattern;
|
||||||
|
try {
|
||||||
|
if (fs.statSync(expanded).isDirectory()) {
|
||||||
|
const fixed = `${pattern}/**`;
|
||||||
|
// Only write the expanded key if no explicit glob for this path already
|
||||||
|
// exists — overwriting an existing "/**" rule would silently widen access
|
||||||
|
// (e.g. {"/tmp":"rwx","/tmp/**":"---"} would become {"/tmp/**":"rwx"}).
|
||||||
|
if (!(fixed in rules)) {
|
||||||
|
rules[fixed] = perm;
|
||||||
|
}
|
||||||
|
delete rules[pattern];
|
||||||
|
if (!_autoExpandedWarned.has(pattern)) {
|
||||||
|
_autoExpandedWarned.add(pattern);
|
||||||
|
errors.push(
|
||||||
|
`access-policy.policy["${pattern}"] is a directory — rule auto-expanded to "${fixed}" so it covers all contents.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Path inaccessible or missing — no action needed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates and normalizes an AccessPolicyConfig for well-formedness.
|
||||||
|
* Returns an array of human-readable diagnostic strings; empty = valid.
|
||||||
|
* May mutate config.policy in place (e.g. auto-expanding bare directory paths).
|
||||||
|
*/
|
||||||
|
export function validateAccessPolicyConfig(config: AccessPolicyConfig): string[] {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (config.policy) {
|
||||||
|
for (const [pattern, perm] of Object.entries(config.policy)) {
|
||||||
|
if (!pattern) {
|
||||||
|
errors.push("access-policy.policy: rule key must be a non-empty glob pattern");
|
||||||
|
}
|
||||||
|
if (!PERM_STR_RE.test(perm)) {
|
||||||
|
errors.push(
|
||||||
|
`access-policy.policy["${pattern}"] "${perm}" is invalid: must be exactly 3 chars (e.g. "rwx", "r--", "---")`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (hasMidPathWildcard(pattern) && !_midPathWildcardWarned.has(`policy:${pattern}`)) {
|
||||||
|
_midPathWildcardWarned.add(`policy:${pattern}`);
|
||||||
|
if (perm === "---") {
|
||||||
|
// Deny-all on a mid-path wildcard prefix would be too broad at the OS layer
|
||||||
|
// (e.g. "secrets/**/*.env: ---" → deny all of secrets/). Skip OS emission entirely.
|
||||||
|
errors.push(
|
||||||
|
`access-policy.policy["${pattern}"] contains a mid-path wildcard with "---" — OS-level (bwrap/Seatbelt) enforcement cannot apply; tool-layer enforcement is still active.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// For non-deny rules the OS layer uses the longest concrete prefix as an
|
||||||
|
// approximate mount/subpath target. The file-type filter (e.g. *.sh) is enforced
|
||||||
|
// precisely by the tool layer only.
|
||||||
|
errors.push(
|
||||||
|
`access-policy.policy["${pattern}"] contains a mid-path wildcard — OS-level enforcement uses prefix match (file-type filter is tool-layer only).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If a bare path (no glob metacharacters, no trailing /) points to a real
|
||||||
|
// directory it would match only the directory entry itself, not its
|
||||||
|
// contents. Auto-expand to "/**" and notify — the fix is unambiguous.
|
||||||
|
autoExpandBareDir(config.policy, pattern, perm, errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.scripts) {
|
||||||
|
// scripts["policy"] is a shared Record<string, PermStr> — validate as flat rules.
|
||||||
|
const sharedPolicy = config.scripts["policy"];
|
||||||
|
if (sharedPolicy) {
|
||||||
|
for (const [pattern, perm] of Object.entries(sharedPolicy)) {
|
||||||
|
if (!PERM_STR_RE.test(perm)) {
|
||||||
|
errors.push(
|
||||||
|
`access-policy.scripts["policy"]["${pattern}"] "${perm}" is invalid: must be exactly 3 chars (e.g. "rwx", "r--", "---")`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
hasMidPathWildcard(pattern) &&
|
||||||
|
!_midPathWildcardWarned.has(`scripts:policy:${pattern}`)
|
||||||
|
) {
|
||||||
|
_midPathWildcardWarned.add(`scripts:policy:${pattern}`);
|
||||||
|
if (perm === "---") {
|
||||||
|
errors.push(
|
||||||
|
`access-policy.scripts["policy"]["${pattern}"] contains a mid-path wildcard with "---" — OS-level (bwrap/Seatbelt) enforcement cannot apply; tool-layer enforcement is still active.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
errors.push(
|
||||||
|
`access-policy.scripts["policy"]["${pattern}"] contains a mid-path wildcard — OS-level enforcement uses prefix match (file-type filter is tool-layer only).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
autoExpandBareDir(sharedPolicy, pattern, perm, errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [scriptPath, entry] of Object.entries(config.scripts)) {
|
||||||
|
if (scriptPath === "policy") {
|
||||||
|
continue; // handled above
|
||||||
|
}
|
||||||
|
// Reject non-object entries (e.g. true, "rwx") — a truthy primitive would
|
||||||
|
// bypass the exec gate in hasScriptOverride and applyScriptPolicyOverride.
|
||||||
|
if (entry == null || typeof entry !== "object" || Array.isArray(entry)) {
|
||||||
|
errors.push(
|
||||||
|
`access-policy.scripts["${scriptPath}"] must be an object (e.g. { sha256: "...", policy: {...} }), got ${JSON.stringify(entry)}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const scriptEntry = entry as import("../config/types.tools.js").ScriptPolicyEntry;
|
||||||
|
// Validate sha256 format when present — a typo causes silent exec denial at runtime.
|
||||||
|
if (scriptEntry.sha256 !== undefined) {
|
||||||
|
if (!/^[0-9a-f]{64}$/i.test(scriptEntry.sha256)) {
|
||||||
|
errors.push(
|
||||||
|
`access-policy.scripts["${scriptPath}"].sha256 "${scriptEntry.sha256}" is invalid: must be a 64-character lowercase hex string`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (scriptEntry.policy) {
|
||||||
|
for (const [pattern, perm] of Object.entries(scriptEntry.policy)) {
|
||||||
|
if (!PERM_STR_RE.test(perm)) {
|
||||||
|
errors.push(
|
||||||
|
`access-policy.scripts["${scriptPath}"].policy["${pattern}"] "${perm}" is invalid: must be exactly 3 chars (e.g. "rwx", "r--", "---")`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Emit mid-path wildcard diagnostic for per-script policy blocks,
|
||||||
|
// matching the same warning emitted for config.policy entries.
|
||||||
|
if (
|
||||||
|
hasMidPathWildcard(pattern) &&
|
||||||
|
!_midPathWildcardWarned.has(`scripts:${scriptPath}:${pattern}`)
|
||||||
|
) {
|
||||||
|
_midPathWildcardWarned.add(`scripts:${scriptPath}:${pattern}`);
|
||||||
|
if (perm === "---") {
|
||||||
|
errors.push(
|
||||||
|
`access-policy.scripts["${scriptPath}"].policy["${pattern}"] contains a mid-path wildcard with "---" — OS-level (bwrap/Seatbelt) enforcement cannot apply; tool-layer enforcement is still active.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
errors.push(
|
||||||
|
`access-policy.scripts["${scriptPath}"].policy["${pattern}"] contains a mid-path wildcard — OS-level enforcement uses prefix match (file-type filter is tool-layer only).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
autoExpandBareDir(scriptEntry.policy, pattern, perm, errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize and expand a config pattern before matching:
|
||||||
|
* - Trailing "/" is shorthand for "/**" (everything under this directory).
|
||||||
|
* e.g. "/tmp/" → "/tmp/**", "~/" → "~/**"
|
||||||
|
* - Leading "~" is expanded to the OS home directory.
|
||||||
|
*
|
||||||
|
* We intentionally use os.homedir() rather than expandHomePrefix() so that
|
||||||
|
* OPENCLAW_HOME does not redirect ~ to the OpenClaw config directory.
|
||||||
|
*/
|
||||||
|
function expandPattern(pattern: string, homeDir: string): string {
|
||||||
|
// Trailing / shorthand: "/tmp/" → "/tmp/**"
|
||||||
|
const normalized = pattern.endsWith("/") ? pattern + "**" : pattern;
|
||||||
|
if (!normalized.startsWith("~")) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return normalized.replace(/^~(?=$|[/\\])/, homeDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* macOS maps several traditional Unix root directories to /private/* via symlinks.
|
||||||
|
* Kernel-level enforcement (seatbelt) sees the real /private/* paths, but users
|
||||||
|
* naturally write /tmp, /var, /etc in their config.
|
||||||
|
*
|
||||||
|
* We normalize the target path to its "friendly" alias before matching so that
|
||||||
|
* /private/tmp/foo is treated as /tmp/foo everywhere — no need to write both.
|
||||||
|
*
|
||||||
|
* Only applied on darwin; on other platforms the map is empty (no-op).
|
||||||
|
*/
|
||||||
|
const MACOS_PRIVATE_ALIASES: ReadonlyArray<[real: string, alias: string]> =
|
||||||
|
process.platform === "darwin"
|
||||||
|
? [
|
||||||
|
["/private/tmp", "/tmp"],
|
||||||
|
["/private/var", "/var"],
|
||||||
|
["/private/etc", "/etc"],
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
function normalizePlatformPath(p: string): string {
|
||||||
|
for (const [real, alias] of MACOS_PRIVATE_ALIASES) {
|
||||||
|
if (p === real) {
|
||||||
|
return alias;
|
||||||
|
}
|
||||||
|
if (p.startsWith(real + "/")) {
|
||||||
|
return alias + p.slice(real.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps operation to its index in the rwx permission string.
|
||||||
|
const OP_INDEX: Record<FsOp, number> = {
|
||||||
|
read: 0,
|
||||||
|
write: 1,
|
||||||
|
exec: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
// The exact character that grants each operation. Any other character (including
|
||||||
|
// typos like "1", "y", "R") is treated as deny — fail-closed on malformed input.
|
||||||
|
const OP_GRANT_CHAR: Record<FsOp, string> = {
|
||||||
|
read: "r",
|
||||||
|
write: "w",
|
||||||
|
exec: "x",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Valid perm strings are exactly 3 chars: [r-][w-][x-].
|
||||||
|
// Validated at parse time by validateAccessPolicyConfig, but also checked here
|
||||||
|
// as defense-in-depth so a malformed value never accidentally grants access.
|
||||||
|
const VALID_PERM_RE = /^[r-][w-][x-]$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the given permission string grants the requested operation.
|
||||||
|
* An absent or malformed string is treated as "---" (deny all).
|
||||||
|
* Only the exact grant character ("r"/"w"/"x") is accepted — any other value
|
||||||
|
* including typos fails closed rather than accidentally granting access.
|
||||||
|
*/
|
||||||
|
function permAllows(perm: PermStr | undefined, op: FsOp): boolean {
|
||||||
|
if (!perm || !VALID_PERM_RE.test(perm)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return perm[OP_INDEX[op]] === OP_GRANT_CHAR[op];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the most specific matching rule for targetPath using longest-glob-wins.
|
||||||
|
* Returns the permission string for that rule, or null if nothing matches.
|
||||||
|
*/
|
||||||
|
export function findBestRule(
|
||||||
|
targetPath: string,
|
||||||
|
rules: Record<string, PermStr>,
|
||||||
|
homeDir: string = os.homedir(),
|
||||||
|
): PermStr | null {
|
||||||
|
let bestPerm: PermStr | null = null;
|
||||||
|
let bestLen = -1;
|
||||||
|
|
||||||
|
for (const [pattern, perm] of Object.entries(rules)) {
|
||||||
|
// Normalize the expanded pattern so /private/tmp/** matches /tmp/** on macOS.
|
||||||
|
const expanded = normalizePlatformPath(expandPattern(pattern, homeDir));
|
||||||
|
// Test both the bare path and path + "/" so that "dir/**"-style rules match
|
||||||
|
// the directory itself — mirrors the dual-probe in checkAccessPolicy so
|
||||||
|
// callers don't need to remember to append "/." when passing a directory.
|
||||||
|
if (
|
||||||
|
matchesExecAllowlistPattern(expanded, targetPath) ||
|
||||||
|
matchesExecAllowlistPattern(expanded, targetPath + "/")
|
||||||
|
) {
|
||||||
|
// Longer *expanded* pattern = more specific. Compare expanded lengths so
|
||||||
|
// a tilde rule like "~/.ssh/**" (expanded: "/home/user/.ssh/**", 20 chars)
|
||||||
|
// correctly beats a broader absolute rule like "/home/user/**" (14 chars).
|
||||||
|
if (expanded.length > bestLen) {
|
||||||
|
bestLen = expanded.length;
|
||||||
|
bestPerm = perm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestPerm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a given operation on targetPath is permitted by the config.
|
||||||
|
*
|
||||||
|
* Precedence:
|
||||||
|
* 1. rules — longest matching glob wins; check the relevant bit.
|
||||||
|
* 2. implicit fallback — `"---"` (deny-all) when no rule matches.
|
||||||
|
* Use `"/**": "r--"` (or any other perm) as an explicit catch-all rule.
|
||||||
|
*/
|
||||||
|
export function checkAccessPolicy(
|
||||||
|
targetPath: string,
|
||||||
|
op: FsOp,
|
||||||
|
config: AccessPolicyConfig,
|
||||||
|
homeDir: string = os.homedir(),
|
||||||
|
): "allow" | "deny" {
|
||||||
|
// Expand leading ~ in targetPath so callers don't have to pre-expand tilde paths.
|
||||||
|
const expandedTarget = targetPath.startsWith("~")
|
||||||
|
? targetPath.replace(/^~(?=$|\/)/, homeDir)
|
||||||
|
: targetPath;
|
||||||
|
// Normalize /private/tmp → /tmp etc. so macOS symlink aliases are transparent.
|
||||||
|
const normalizedPath = normalizePlatformPath(expandedTarget);
|
||||||
|
// For directory-level checks (e.g. mkdir), also try path + "/" so that a
|
||||||
|
// trailing-/ rule ("~/.openclaw/heartbeat/" → "/**") covers the directory
|
||||||
|
// itself and not only its descendants.
|
||||||
|
const normalizedPathDir = normalizedPath + "/";
|
||||||
|
|
||||||
|
function matchesPattern(expanded: string): boolean {
|
||||||
|
return (
|
||||||
|
matchesExecAllowlistPattern(expanded, normalizedPath) ||
|
||||||
|
matchesExecAllowlistPattern(expanded, normalizedPathDir)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// rules — longest match wins (check both path and path + "/" variants).
|
||||||
|
let bestPerm: PermStr | null = null;
|
||||||
|
let bestLen = -1;
|
||||||
|
for (const [pattern, perm] of Object.entries(config.policy ?? {})) {
|
||||||
|
// Normalize so /private/tmp/** patterns match /tmp/** targets on macOS.
|
||||||
|
const expanded = normalizePlatformPath(expandPattern(pattern, homeDir));
|
||||||
|
if (matchesPattern(expanded) && expanded.length > bestLen) {
|
||||||
|
bestLen = expanded.length;
|
||||||
|
bestPerm = perm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestPerm !== null) {
|
||||||
|
return permAllows(bestPerm, op) ? "allow" : "deny";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implicit fallback: "---" (deny-all) when no rule matches.
|
||||||
|
return "deny";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search PATH for a bare binary name, returning the first executable found.
|
||||||
|
* Returns null when not found. The caller applies realpathSync afterwards.
|
||||||
|
*/
|
||||||
|
function findOnPath(name: string, pathOverride?: string): string | null {
|
||||||
|
const pathEnv = pathOverride ?? process.env.PATH ?? "";
|
||||||
|
// On Windows, bare names like "node" resolve to "node.exe" or "node.cmd" via
|
||||||
|
// PATHEXT. Without probing extensions, accessSync finds nothing and we fall back
|
||||||
|
// to the cwd-relative path, causing checkAccessPolicy to evaluate the wrong path.
|
||||||
|
const extensions =
|
||||||
|
process.platform === "win32"
|
||||||
|
? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(path.delimiter)
|
||||||
|
: [""];
|
||||||
|
for (const dir of pathEnv.split(path.delimiter)) {
|
||||||
|
if (!dir) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const ext of extensions) {
|
||||||
|
const candidate = path.join(dir, name + ext);
|
||||||
|
try {
|
||||||
|
fs.accessSync(candidate, fs.constants.X_OK);
|
||||||
|
return candidate;
|
||||||
|
} catch {
|
||||||
|
// not in this dir/ext combo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract and resolve the argv[0] token from a shell command string.
|
||||||
|
*
|
||||||
|
* Handles leading-quoted paths ("..." and '...') and simple unquoted tokens.
|
||||||
|
* Expands ~ to os.homedir(). Resolves relative paths against cwd.
|
||||||
|
* Follows symlinks via realpathSync so the result matches an absolute-path key.
|
||||||
|
*
|
||||||
|
* Returns null when the command is empty or the path cannot be determined.
|
||||||
|
*/
|
||||||
|
export function resolveArgv0(command: string, cwd?: string, _depth = 0): string | null {
|
||||||
|
// Guard against deeply nested env -S "env -S '...'" constructs that would
|
||||||
|
// otherwise overflow the call stack. 8 levels is far more than any real usage.
|
||||||
|
if (_depth > 8) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = command.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Extract the first token, respecting simple leading quotes.
|
||||||
|
// Skip leading shell env-prefix assignments (e.g. FOO=1 /script.sh → /script.sh)
|
||||||
|
// so that script policy lookups and sha256 checks are not bypassed by prefixed envs.
|
||||||
|
let token: string;
|
||||||
|
// commandRest holds the tail of the command string after argv0 — used to look
|
||||||
|
// through `env` invocations where the real script follows the launcher.
|
||||||
|
let commandRest = "";
|
||||||
|
// Literal PATH= override extracted from env-prefix assignments (no shell vars).
|
||||||
|
// Used so `PATH=/alt deploy.sh` looks up deploy.sh on /alt rather than process PATH.
|
||||||
|
let commandScopedPath: string | undefined;
|
||||||
|
if (trimmed[0] === '"' || trimmed[0] === "'") {
|
||||||
|
const quote = trimmed[0];
|
||||||
|
const end = trimmed.indexOf(quote, 1);
|
||||||
|
token = end !== -1 ? trimmed.slice(1, end) : trimmed.slice(1);
|
||||||
|
// Set commandRest so the env look-through below can strip the quoted argv0 and
|
||||||
|
// recurse into the actual script (e.g. `"/usr/bin/env" /my/script.sh` → /my/script.sh).
|
||||||
|
commandRest = trimmed;
|
||||||
|
} else {
|
||||||
|
// Progressively consume leading NAME=value env-prefix tokens before extracting argv0.
|
||||||
|
// Using a regex that matches the full assignment including quoted values (e.g.
|
||||||
|
// FOO='a b') prevents misparse when a quoted env value contains spaces — a naive
|
||||||
|
// whitespace-split would break FOO='a b' /script.sh into ["FOO='a", "b'", "/script.sh"]
|
||||||
|
// and incorrectly treat "b'" as the argv0, bypassing script policy lookups.
|
||||||
|
// Double-quoted values: allow backslash-escaped characters (e.g. "a\"b") so the
|
||||||
|
// regex doesn't truncate at the escaped quote and misidentify the next token as argv0.
|
||||||
|
// Single-quoted values: no escaping in POSIX sh single quotes, so [^']* is correct.
|
||||||
|
const envPrefixRe = /^[A-Za-z_][A-Za-z0-9_]*=(?:"(?:[^"\\]|\\.)*"|'[^']*'|\S*)\s*/;
|
||||||
|
let rest = trimmed;
|
||||||
|
while (envPrefixRe.test(rest)) {
|
||||||
|
// Capture a literal PATH= override; skip if the value contains $ (unexpandable).
|
||||||
|
const pathM = rest.match(/^PATH=(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+))\s*/);
|
||||||
|
if (pathM) {
|
||||||
|
const val = pathM[1] ?? pathM[2] ?? pathM[3] ?? "";
|
||||||
|
if (!val.includes("$")) {
|
||||||
|
commandScopedPath = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rest = rest.replace(envPrefixRe, "");
|
||||||
|
}
|
||||||
|
const raw = rest.split(/\s+/)[0] ?? "";
|
||||||
|
// If the argv0 token is quoted (e.g. FOO=1 "/opt/my script.sh"), strip quotes.
|
||||||
|
if (raw[0] === '"' || raw[0] === "'") {
|
||||||
|
const quote = raw[0];
|
||||||
|
const end = rest.indexOf(quote, 1);
|
||||||
|
token = end !== -1 ? rest.slice(1, end) : rest.slice(1);
|
||||||
|
} else {
|
||||||
|
token = raw;
|
||||||
|
}
|
||||||
|
commandRest = rest;
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Expand leading ~
|
||||||
|
if (token.startsWith("~")) {
|
||||||
|
token = token.replace(/^~(?=$|\/)/, os.homedir());
|
||||||
|
}
|
||||||
|
// Resolve relative paths. For bare names with no path separator (e.g. "deploy.sh"),
|
||||||
|
// try PATH lookup first so script-policy keys match the real on-PATH binary rather
|
||||||
|
// than <cwd>/deploy.sh. Explicitly relative tokens (./foo, ../foo) contain a separator
|
||||||
|
// and are resolved against cwd only, matching the shell's own behaviour.
|
||||||
|
if (!path.isAbsolute(token)) {
|
||||||
|
const hasPathSep = token.includes("/") || token.includes("\\");
|
||||||
|
if (!hasPathSep) {
|
||||||
|
const onPath = findOnPath(token, commandScopedPath);
|
||||||
|
if (onPath) {
|
||||||
|
token = onPath;
|
||||||
|
} else if (cwd) {
|
||||||
|
token = path.resolve(cwd, token);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else if (cwd) {
|
||||||
|
token = path.resolve(cwd, token);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Follow symlinks so keys always refer to the real file
|
||||||
|
try {
|
||||||
|
token = fs.realpathSync(token);
|
||||||
|
} catch {
|
||||||
|
// token stays as-is
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the resolved binary is `env` (e.g. `env FOO=1 /script.sh`), look past it
|
||||||
|
// to the actual script so script-policy lookups and sha256 checks are not bypassed
|
||||||
|
// by prepending `env`. Recurse so the inner command gets the same full treatment
|
||||||
|
// (NAME=value stripping, quoting, cwd-relative resolution, symlink following).
|
||||||
|
if (path.basename(token, path.extname(token)) === "env" && commandRest) {
|
||||||
|
// Strip the env/"/usr/bin/env" token itself from commandRest.
|
||||||
|
// When argv0 was quoted (e.g. `"/usr/bin env" /script.sh`), a bare /^\S+\s*/ would
|
||||||
|
// stop at the first space inside the quoted token. Handle the quoted case explicitly.
|
||||||
|
let afterEnv: string;
|
||||||
|
if (commandRest[0] === '"' || commandRest[0] === "'") {
|
||||||
|
const q = commandRest[0];
|
||||||
|
const closeIdx = commandRest.indexOf(q, 1);
|
||||||
|
afterEnv = closeIdx !== -1 ? commandRest.slice(closeIdx + 1).trimStart() : "";
|
||||||
|
} else {
|
||||||
|
afterEnv = commandRest.replace(/^\S+\s*/, "");
|
||||||
|
}
|
||||||
|
// Skip env options and their arguments so `env -i /script.sh` resolves to
|
||||||
|
// /script.sh rather than treating `-i` as argv0. Short options that consume
|
||||||
|
// the next token as their argument (-u VAR, -C DIR) are stripped including
|
||||||
|
// any quoted value (e.g. -C "/path with space"). -S/--split-string is special:
|
||||||
|
// its value IS a command string, so we recurse into it rather than discard it.
|
||||||
|
// All other flags (e.g. -i, --ignore-environment) are single standalone tokens.
|
||||||
|
// NAME=value pairs are handled naturally when we recurse into resolveArgv0.
|
||||||
|
// --block-signal, --default-signal, --ignore-signal use [=SIG] syntax (never space-separated).
|
||||||
|
const envOptWithArgRe = /^(-[uC]|--(unset|chdir))\s+/;
|
||||||
|
while (afterEnv) {
|
||||||
|
if (afterEnv === "--" || afterEnv.startsWith("-- ")) {
|
||||||
|
afterEnv = afterEnv.slice(2).trimStart();
|
||||||
|
break; // -- terminates env options; what follows is the command
|
||||||
|
}
|
||||||
|
// -S/--split-string: the argument is itself a command string — recurse into it.
|
||||||
|
// Handle all three forms GNU env accepts:
|
||||||
|
// space: -S CMD / --split-string CMD
|
||||||
|
// equals: -S=CMD / --split-string=CMD
|
||||||
|
// compact: -SCMD (short flag only, value starts immediately after -S)
|
||||||
|
const splitEqM = afterEnv.match(/^(?:-S|--(split-string))=([\s\S]*)/);
|
||||||
|
const splitSpM = afterEnv.match(/^(?:-S|--(split-string))\s+([\s\S]*)/);
|
||||||
|
const splitCmM = afterEnv.match(/^-S([^\s=][\s\S]*)/);
|
||||||
|
const splitArg = splitEqM
|
||||||
|
? splitEqM[splitEqM.length - 1]
|
||||||
|
: splitSpM
|
||||||
|
? splitSpM[splitSpM.length - 1]
|
||||||
|
: splitCmM
|
||||||
|
? splitCmM[1]
|
||||||
|
: null;
|
||||||
|
if (splitArg !== null) {
|
||||||
|
let inner = splitArg.trim();
|
||||||
|
// Strip surrounding quotes that the shell added around the embedded command.
|
||||||
|
if (
|
||||||
|
(inner.startsWith('"') && inner.endsWith('"')) ||
|
||||||
|
(inner.startsWith("'") && inner.endsWith("'"))
|
||||||
|
) {
|
||||||
|
inner = inner.slice(1, -1);
|
||||||
|
}
|
||||||
|
return inner ? resolveArgv0(inner, cwd, _depth + 1) : null;
|
||||||
|
}
|
||||||
|
if (envOptWithArgRe.test(afterEnv)) {
|
||||||
|
// Strip option + its argument; handle quoted values with spaces.
|
||||||
|
afterEnv = afterEnv.replace(/^\S+\s+(?:"[^"]*"|'[^']*'|\S+)\s*/, "");
|
||||||
|
} else if (afterEnv[0] === "-") {
|
||||||
|
afterEnv = afterEnv.replace(/^\S+\s*/, ""); // strip standalone flag
|
||||||
|
} else {
|
||||||
|
break; // first non-option token — may still be NAME=value, handled by recursion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return afterEnv ? resolveArgv0(afterEnv, cwd, _depth + 1) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a scripts config key for comparison against a resolveArgv0 result.
|
||||||
|
*
|
||||||
|
* Expands a leading ~ and resolves symlinks for absolute paths, so that a key
|
||||||
|
* like "/usr/bin/python" (symlink → /usr/bin/python3.12) still matches when
|
||||||
|
* resolveArgv0 returns the real path "/usr/bin/python3.12".
|
||||||
|
*/
|
||||||
|
export function resolveScriptKey(k: string): string {
|
||||||
|
// path.normalize converts forward slashes to OS-native separators on Windows so that
|
||||||
|
// a tilde key like "~/bin/script.sh" compares correctly against a resolved argv0
|
||||||
|
// that uses backslashes on Windows.
|
||||||
|
const expanded = k.startsWith("~") ? path.normalize(k.replace(/^~(?=$|[/\\])/, os.homedir())) : k;
|
||||||
|
if (!path.isAbsolute(expanded)) {
|
||||||
|
return expanded;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return fs.realpathSync(expanded);
|
||||||
|
} catch {
|
||||||
|
// Key path doesn't exist — keep expanded; the lookup will simply not match.
|
||||||
|
return expanded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a per-script policy overlay to the base agent policy.
|
||||||
|
*
|
||||||
|
* Looks up resolvedArgv0 in policy.scripts. If found:
|
||||||
|
* - verifies sha256 when set (returns hashMismatch=true on failure → caller should deny exec)
|
||||||
|
* - merges grant over base rules (override key wins)
|
||||||
|
* - appends restrict.deny to base deny (additive)
|
||||||
|
* - strips the scripts block from the result so the overlay doesn't apply to future
|
||||||
|
* unrelated exec calls in the same agent turn (seatbelt/bwrap still covers the full
|
||||||
|
* subprocess tree of the wrapped command at the OS level — that is correct behavior)
|
||||||
|
*
|
||||||
|
* Returns the base policy unchanged when no matching script entry exists.
|
||||||
|
*/
|
||||||
|
export function applyScriptPolicyOverride(
|
||||||
|
policy: AccessPolicyConfig,
|
||||||
|
resolvedArgv0: string,
|
||||||
|
): { policy: AccessPolicyConfig; overrideRules?: Record<string, PermStr>; hashMismatch?: true } {
|
||||||
|
// Normalize scripts keys via resolveScriptKey so that:
|
||||||
|
// - tilde keys ("~/bin/deploy.sh") expand to absolute paths
|
||||||
|
// - symlink keys ("/usr/bin/python" → /usr/bin/python3.12) resolve to real paths
|
||||||
|
// resolveArgv0 always returns the realpathSync result, so both forms must be
|
||||||
|
// normalized the same way or the lookup silently misses, skipping sha256 verification.
|
||||||
|
const scripts = policy.scripts;
|
||||||
|
const rawOverride = scripts
|
||||||
|
? Object.entries(scripts).find(
|
||||||
|
([k]) =>
|
||||||
|
k !== "policy" && path.normalize(resolveScriptKey(k)) === path.normalize(resolvedArgv0),
|
||||||
|
)?.[1]
|
||||||
|
: undefined;
|
||||||
|
// Reject non-object entries (e.g. true, "oops") — a truthy primitive would
|
||||||
|
// otherwise skip sha256 verification and act as an unchecked override grant.
|
||||||
|
const override =
|
||||||
|
rawOverride != null && typeof rawOverride === "object" && !Array.isArray(rawOverride)
|
||||||
|
? (rawOverride as import("../config/types.tools.js").ScriptPolicyEntry)
|
||||||
|
: undefined;
|
||||||
|
if (!override) {
|
||||||
|
return { policy };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify sha256 when configured — reduces script swap risk.
|
||||||
|
// Known limitation: there is an inherent TOCTOU window between the hash read
|
||||||
|
// here and the kernel exec() call. An attacker who can swap the file between
|
||||||
|
// these two moments could run a different payload under the per-script policy.
|
||||||
|
// Fully closing this would require atomic open-and-exec (e.g. execveat + memfd)
|
||||||
|
// which is not available in Node.js userspace. This check is a best-effort guard,
|
||||||
|
// not a cryptographic guarantee. Use OS-level filesystem permissions to restrict
|
||||||
|
// who can modify script files for stronger protection.
|
||||||
|
if (override.sha256) {
|
||||||
|
let actualHash: string;
|
||||||
|
try {
|
||||||
|
// Policy-engine internal read: intentionally bypasses checkAccessPolicy.
|
||||||
|
// The policy engine must verify the script's integrity before deciding
|
||||||
|
// whether to grant the script's extra permissions — checking the policy
|
||||||
|
// first would be circular. This read is safe: it never exposes content
|
||||||
|
// to the agent; it only computes a hash for comparison.
|
||||||
|
const contents = fs.readFileSync(resolvedArgv0);
|
||||||
|
actualHash = crypto.createHash("sha256").update(contents).digest("hex");
|
||||||
|
} catch {
|
||||||
|
return { policy, hashMismatch: true };
|
||||||
|
}
|
||||||
|
// Normalize to lowercase: crypto.digest("hex") always returns lowercase, but
|
||||||
|
// the validation regex accepts uppercase (/i). Without normalization an uppercase
|
||||||
|
// sha256 in config passes validation and then silently fails here at runtime.
|
||||||
|
if (actualHash !== override.sha256.toLowerCase()) {
|
||||||
|
return { policy, hashMismatch: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the merged policy WITHOUT the override rules merged in.
|
||||||
|
// Override rules are returned separately so the caller can emit them AFTER
|
||||||
|
// the base rules in the seatbelt profile (last-match-wins — grants must come
|
||||||
|
// after broader rules to override them, e.g. a script-specific grant inside
|
||||||
|
// a broadly denied subtree).
|
||||||
|
const { scripts: _scripts, ...base } = policy;
|
||||||
|
const merged: AccessPolicyConfig = { ...base };
|
||||||
|
|
||||||
|
// Merge scripts["policy"] (shared base for all matching scripts) with the
|
||||||
|
// per-script entry policy. Per-script wins on conflict (applied last).
|
||||||
|
const sharedPolicy = scripts?.["policy"];
|
||||||
|
const mergedOverride: Record<string, PermStr> = {
|
||||||
|
...sharedPolicy,
|
||||||
|
...override.policy,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
policy: merged,
|
||||||
|
overrideRules: Object.keys(mergedOverride).length > 0 ? mergedOverride : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,513 @@
|
||||||
|
import os from "node:os";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { AccessPolicyConfig } from "../config/types.tools.js";
|
||||||
|
import {
|
||||||
|
_resetBwrapAvailableCacheForTest,
|
||||||
|
_resetBwrapFileDenyWarnedPathsForTest,
|
||||||
|
_warnBwrapFileDenyOnce,
|
||||||
|
generateBwrapArgs,
|
||||||
|
isBwrapAvailable,
|
||||||
|
wrapCommandWithBwrap,
|
||||||
|
} from "./exec-sandbox-bwrap.js";
|
||||||
|
|
||||||
|
const HOME = os.homedir();
|
||||||
|
|
||||||
|
// bwrap is Linux-only — skip the generateBwrapArgs tests on other platforms so
|
||||||
|
// Windows/macOS CI does not fail on fs.statSync calls against Unix-only paths
|
||||||
|
// like /etc/hosts that don't exist there.
|
||||||
|
describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => {
|
||||||
|
it("starts with --ro-bind / / when /** rule allows reads", () => {
|
||||||
|
const config: AccessPolicyConfig = { policy: { "/**": "r--" } };
|
||||||
|
const args = generateBwrapArgs(config, HOME);
|
||||||
|
expect(args.slice(0, 3)).toEqual(["--ro-bind", "/", "/"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not use --ro-bind / / when no /** rule (restrictive base)", () => {
|
||||||
|
const config: AccessPolicyConfig = {};
|
||||||
|
const args = generateBwrapArgs(config, HOME);
|
||||||
|
// Should not contain root bind
|
||||||
|
const rootBindIdx = args.findIndex(
|
||||||
|
(a, i) => a === "--ro-bind" && args[i + 1] === "/" && args[i + 2] === "/",
|
||||||
|
);
|
||||||
|
expect(rootBindIdx).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ends with --", () => {
|
||||||
|
const config: AccessPolicyConfig = { policy: { "/**": "r--" } };
|
||||||
|
const args = generateBwrapArgs(config, HOME);
|
||||||
|
expect(args[args.length - 1]).toBe("--");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds --tmpfs for "---" rules in permissive mode', () => {
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: { "/**": "r--", [`${HOME}/.ssh/**`]: "---", [`${HOME}/.gnupg/**`]: "---" },
|
||||||
|
};
|
||||||
|
const args = generateBwrapArgs(config, HOME);
|
||||||
|
const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean);
|
||||||
|
expect(tmpfsMounts).toContain(`${HOME}/.ssh`);
|
||||||
|
expect(tmpfsMounts).toContain(`${HOME}/.gnupg`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands ~ in "---" rules using homeDir', () => {
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: { "/**": "r--", "~/.ssh/**": "---" },
|
||||||
|
};
|
||||||
|
const args = generateBwrapArgs(config, HOME);
|
||||||
|
const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean);
|
||||||
|
expect(tmpfsMounts).toContain(`${HOME}/.ssh`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds --bind for paths with w bit in rules", () => {
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: { "/**": "r--", [`${HOME}/workspace/**`]: "rw-" },
|
||||||
|
};
|
||||||
|
const args = generateBwrapArgs(config, HOME);
|
||||||
|
const bindPairs: string[] = [];
|
||||||
|
for (let i = 0; i < args.length - 2; i++) {
|
||||||
|
if (args[i] === "--bind-try") {
|
||||||
|
bindPairs.push(args[i + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(bindPairs).toContain(`${HOME}/workspace`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add --bind for read-only rules on permissive base", () => {
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: { "/**": "r--", "/usr/bin/**": "r--" },
|
||||||
|
};
|
||||||
|
const args = generateBwrapArgs(config, HOME);
|
||||||
|
// /usr/bin should NOT appear as a --bind-try (it's already ro-bound via /)
|
||||||
|
const bindPairs: string[] = [];
|
||||||
|
for (let i = 0; i < args.length - 2; i++) {
|
||||||
|
if (args[i] === "--bind-try") {
|
||||||
|
bindPairs.push(args[i + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(bindPairs).not.toContain("/usr/bin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"---" rule for sensitive path appears in args regardless of broader rule', () => {
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: { "/**": "r--", [`${HOME}/**`]: "rwx", [`${HOME}/.ssh/**`]: "---" },
|
||||||
|
};
|
||||||
|
const args = generateBwrapArgs(config, HOME);
|
||||||
|
const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean);
|
||||||
|
expect(tmpfsMounts).toContain(`${HOME}/.ssh`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not crash on empty config", () => {
|
||||||
|
expect(() => generateBwrapArgs({}, HOME)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds --proc /proc in permissive mode so /proc is accessible inside the sandbox", () => {
|
||||||
|
// --ro-bind / / does not propagate kernel filesystems (procfs) into the new
|
||||||
|
// mount namespace; without --proc /proc, shells and Python fail in the sandbox.
|
||||||
|
const args = generateBwrapArgs({ policy: { "/**": "r--" } }, HOME);
|
||||||
|
const procIdx = args.indexOf("--proc");
|
||||||
|
expect(procIdx).toBeGreaterThan(-1);
|
||||||
|
expect(args[procIdx + 1]).toBe("/proc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds --tmpfs /tmp in permissive mode (/** allows reads)", () => {
|
||||||
|
const config: AccessPolicyConfig = { policy: { "/**": "r--" } };
|
||||||
|
const args = generateBwrapArgs(config, HOME);
|
||||||
|
const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean);
|
||||||
|
expect(tmpfsMounts).toContain("/tmp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add --tmpfs /tmp in restrictive mode (no /** rule)", () => {
|
||||||
|
// Without a "/**" rule, the base is restrictive — no /tmp by default.
|
||||||
|
const config: AccessPolicyConfig = {};
|
||||||
|
const args = generateBwrapArgs(config, HOME);
|
||||||
|
const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean);
|
||||||
|
expect(tmpfsMounts).not.toContain("/tmp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips --tmpfs /tmp in permissive mode when policy explicitly restricts /tmp writes", () => {
|
||||||
|
// A rule "/tmp/**": "r--" means the user wants /tmp read-only; the base --ro-bind / /
|
||||||
|
// already makes it readable. Adding --tmpfs /tmp would silently grant write access.
|
||||||
|
const config: AccessPolicyConfig = { policy: { "/**": "r--", "/tmp/**": "r--" } };
|
||||||
|
const args = generateBwrapArgs(config, HOME);
|
||||||
|
const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean);
|
||||||
|
expect(tmpfsMounts).not.toContain("/tmp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips --tmpfs /tmp when an explicit write rule covers /tmp (rules loop emits --bind-try)", () => {
|
||||||
|
// Regression: the old code also emitted --tmpfs /tmp when explicitTmpPerm[1] === "w",
|
||||||
|
// but the rules loop always follows with --bind-try /tmp /tmp which wins (last mount wins
|
||||||
|
// in bwrap). The --tmpfs was dead code. Confirm: explicit rw- rule → no --tmpfs /tmp,
|
||||||
|
// but --bind-try /tmp IS present.
|
||||||
|
const config: AccessPolicyConfig = { policy: { "/**": "r--", "/tmp/**": "rw-" } };
|
||||||
|
const args = generateBwrapArgs(config, HOME);
|
||||||
|
const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean);
|
||||||
|
const bindMounts = args
|
||||||
|
.map((a, i) => (a === "--bind-try" ? args[i + 1] : null))
|
||||||
|
.filter(Boolean);
|
||||||
|
expect(tmpfsMounts).not.toContain("/tmp");
|
||||||
|
expect(bindMounts).toContain("/tmp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add --tmpfs /tmp in restrictive mode (no /** rule) — regression guard", () => {
|
||||||
|
// When there is no "/**" rule at all, no /tmp mount should appear.
|
||||||
|
const config: AccessPolicyConfig = { policy: { [`${HOME}/workspace/**`]: "rwx" } };
|
||||||
|
const args = generateBwrapArgs(config, HOME);
|
||||||
|
const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean);
|
||||||
|
expect(tmpfsMounts).not.toContain("/tmp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"---" rule in permissive mode gets --tmpfs overlay to block reads', () => {
|
||||||
|
// With "/**":"r--", --ro-bind / / makes everything readable. A narrowing
|
||||||
|
// rule like "/secret/**": "---" must overlay --tmpfs so the path is hidden.
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: { "/**": "r--", [`${HOME}/secret/**`]: "---" },
|
||||||
|
};
|
||||||
|
const args = generateBwrapArgs(config, HOME);
|
||||||
|
const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean);
|
||||||
|
expect(tmpfsMounts).toContain(`${HOME}/secret`);
|
||||||
|
// Must NOT produce a bind mount for this path.
|
||||||
|
const bindMounts = args
|
||||||
|
.map((a, i) => (a === "--bind-try" ? args[i + 1] : null))
|
||||||
|
.filter(Boolean);
|
||||||
|
expect(bindMounts).not.toContain(`${HOME}/secret`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("narrowing rule on an existing file does not emit --tmpfs (bwrap only accepts dirs)", () => {
|
||||||
|
// process.execPath is always an existing file — use it as the test target.
|
||||||
|
const filePath = process.execPath;
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: { "/**": "r--", [filePath]: "---" },
|
||||||
|
};
|
||||||
|
const args = generateBwrapArgs(config, HOME);
|
||||||
|
const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean);
|
||||||
|
// Must NOT emit --tmpfs for a file path.
|
||||||
|
expect(tmpfsMounts).not.toContain(filePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"--x" rule in permissive mode gets --tmpfs overlay to block reads', () => {
|
||||||
|
// Execute-only rules have no read bit — same treatment as "---" in permissive mode.
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: { "/**": "r--", [`${HOME}/scripts/**`]: "--x" },
|
||||||
|
};
|
||||||
|
const args = generateBwrapArgs(config, HOME);
|
||||||
|
const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean);
|
||||||
|
expect(tmpfsMounts).toContain(`${HOME}/scripts`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"---" rule on SYSTEM_RO_BIND_PATHS path emits --tmpfs in restrictive mode', () => {
|
||||||
|
// SYSTEM_RO_BIND_PATHS (/etc, /usr, /bin, /lib, /lib64, /sbin, /opt) are unconditionally
|
||||||
|
// --ro-bind-try mounted in restrictive mode. Without a --tmpfs overlay, a "---" rule on
|
||||||
|
// e.g. "/etc/**" has no OS-level effect — syscalls inside the sandbox can still read
|
||||||
|
// /etc/passwd, /etc/shadow, etc. The fix: treat deny rules the same in both modes.
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: { "/etc/**": "---" },
|
||||||
|
};
|
||||||
|
const args = generateBwrapArgs(config, HOME);
|
||||||
|
const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean);
|
||||||
|
expect(tmpfsMounts).toContain("/etc");
|
||||||
|
// Must NOT emit a read mount for a deny rule.
|
||||||
|
const roBound = args
|
||||||
|
.map((a, i) => (a === "--ro-bind-try" ? args[i + 1] : null))
|
||||||
|
.filter(Boolean);
|
||||||
|
expect(roBound).not.toContain("/etc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"---" rules do not create --ro-bind-try mounts in restrictive mode', () => {
|
||||||
|
// A rule with "---" permission should NOT produce any bwrap mount — the
|
||||||
|
// restrictive base already denies by not mounting. Emitting --ro-bind-try
|
||||||
|
// for a "---" rule would silently grant read access to paths that should
|
||||||
|
// be fully blocked.
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: {
|
||||||
|
[`${HOME}/workspace/**`]: "rwx", // allowed: should produce --bind-try
|
||||||
|
[`${HOME}/workspace/private/**`]: "---", // denied: must NOT produce any mount
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const args = generateBwrapArgs(config, HOME);
|
||||||
|
const roBound = args
|
||||||
|
.map((a, i) => (a === "--ro-bind-try" ? args[i + 1] : null))
|
||||||
|
.filter(Boolean);
|
||||||
|
// "---" rule must not produce a read-only bind
|
||||||
|
expect(roBound).not.toContain(`${HOME}/workspace/private`);
|
||||||
|
// "rwx" rule must produce a read-write bind (sanity check)
|
||||||
|
const rwBound = args.map((a, i) => (a === "--bind-try" ? args[i + 1] : null)).filter(Boolean);
|
||||||
|
expect(rwBound).toContain(`${HOME}/workspace`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"--x" rules do not create --ro-bind-try mounts in restrictive mode', () => {
|
||||||
|
// Same as "---" case: execute-only rules also must not emit read mounts.
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: { [`${HOME}/scripts/**`]: "--x" },
|
||||||
|
};
|
||||||
|
const args = generateBwrapArgs(config, HOME);
|
||||||
|
const roBound = args
|
||||||
|
.map((a, i) => (a === "--ro-bind-try" ? args[i + 1] : null))
|
||||||
|
.filter(Boolean);
|
||||||
|
expect(roBound).not.toContain(`${HOME}/scripts`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"-w-" rule in restrictive mode emits --bind-try so writes do not silently fail', () => {
|
||||||
|
// A write-only rule ("-w-") without "/**" now emits --bind-try so the path
|
||||||
|
// exists in the bwrap namespace and writes succeed. bwrap cannot enforce
|
||||||
|
// write-without-read at the mount level; reads are also permitted at the OS layer,
|
||||||
|
// but the tool layer still denies read tool calls per the "-w-" rule.
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: { [`${HOME}/logs/**`]: "-w-" },
|
||||||
|
};
|
||||||
|
const args = generateBwrapArgs(config, HOME);
|
||||||
|
const bindMounts = args
|
||||||
|
.map((a, i) => (a === "--bind-try" ? args[i + 1] : null))
|
||||||
|
.filter(Boolean);
|
||||||
|
expect(bindMounts).toContain(`${HOME}/logs`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"-w-" rule in permissive mode emits --bind-try (write upgrade, reads already allowed)', () => {
|
||||||
|
// Under "/**":"r--", --ro-bind / / already grants reads everywhere.
|
||||||
|
// A "-w-" rule upgrades to rw for that path — reads are not newly leaked
|
||||||
|
// since the base already allowed them.
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: { "/**": "r--", [`${HOME}/output/**`]: "-w-" },
|
||||||
|
};
|
||||||
|
const args = generateBwrapArgs(config, HOME);
|
||||||
|
const bindMounts = args
|
||||||
|
.map((a, i) => (a === "--bind-try" ? args[i + 1] : null))
|
||||||
|
.filter(Boolean);
|
||||||
|
expect(bindMounts).toContain(`${HOME}/output`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips mid-path wildcard --- patterns — deny-all on truncated prefix would be too broad", () => {
|
||||||
|
// "/home/*/.config/**" with "---" truncates to "/home" — applying --tmpfs to /home
|
||||||
|
// would hide the entire home directory. Must be skipped.
|
||||||
|
const fakeHome = "/home/testuser";
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: { "/**": "r--", "/home/*/.config/**": "---" },
|
||||||
|
};
|
||||||
|
const args = generateBwrapArgs(config, fakeHome);
|
||||||
|
const allMountTargets = args
|
||||||
|
.map((a, i) =>
|
||||||
|
["--tmpfs", "--bind-try", "--ro-bind-try"].includes(args[i - 1] ?? "") ? a : null,
|
||||||
|
)
|
||||||
|
.filter(Boolean);
|
||||||
|
expect(allMountTargets).not.toContain("/home");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("non-deny mid-path wildcard emits prefix as approximate mount target", () => {
|
||||||
|
// "scripts/**/*.sh": "r-x" — mid-path wildcard, non-deny perm.
|
||||||
|
// OS layer uses the concrete prefix (/scripts) as an approximate ro-bind-try target;
|
||||||
|
// the tool layer enforces the *.sh filter precisely.
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: { "/scripts/**/*.sh": "r-x" },
|
||||||
|
};
|
||||||
|
const args = generateBwrapArgs(config, "/home/user");
|
||||||
|
const allMountTargets = args
|
||||||
|
.map((a, i) =>
|
||||||
|
["--tmpfs", "--bind-try", "--ro-bind-try"].includes(args[i - 1] ?? "") ? a : null,
|
||||||
|
)
|
||||||
|
.filter(Boolean);
|
||||||
|
expect(allMountTargets).toContain("/scripts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suffix-glob rule uses parent directory as mount target, not literal prefix", () => {
|
||||||
|
// "/var/log/secret*" must mount "/var/log", NOT the literal prefix "/var/log/secret"
|
||||||
|
// which is not a directory and leaves entries like "/var/log/secret.old" unprotected.
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: { "/**": "r--", "/var/log/secret*": "---" },
|
||||||
|
};
|
||||||
|
const args = generateBwrapArgs(config, HOME);
|
||||||
|
const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean);
|
||||||
|
expect(tmpfsMounts).toContain("/var/log");
|
||||||
|
expect(tmpfsMounts).not.toContain("/var/log/secret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits broader mounts before narrower ones so specific overrides win", () => {
|
||||||
|
// ~/dev/** is rw, ~/dev/secret/** is ro. The ro bind MUST come after the rw
|
||||||
|
// bind in the args so it takes precedence in bwrap's mount evaluation.
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
// Deliberately insert secret first so Object.entries() would yield it first
|
||||||
|
// without sorting — proving the sort is what fixes the order.
|
||||||
|
policy: {
|
||||||
|
[`${HOME}/dev/secret/**`]: "r--",
|
||||||
|
[`${HOME}/dev/**`]: "rw-",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const args = generateBwrapArgs(config, HOME);
|
||||||
|
const bindArgs = args.filter((a) => a === "--bind-try" || a === "--ro-bind-try");
|
||||||
|
const bindPaths = args
|
||||||
|
.map((a, i) => (args[i - 1] === "--bind-try" || args[i - 1] === "--ro-bind-try" ? a : null))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const devIdx = bindPaths.indexOf(`${HOME}/dev`);
|
||||||
|
const secretIdx = bindPaths.indexOf(`${HOME}/dev/secret`);
|
||||||
|
// ~/dev (broader) must appear before ~/dev/secret (narrower).
|
||||||
|
expect(devIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(secretIdx).toBeGreaterThan(devIdx);
|
||||||
|
// And the types must be right.
|
||||||
|
expect(bindArgs[devIdx]).toBe("--bind-try");
|
||||||
|
expect(bindArgs[secretIdx]).toBe("--ro-bind-try");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('script override "---" rule targeting a file does not emit --tmpfs (bwrap rejects file paths)', () => {
|
||||||
|
// The base-rules loop has an isDir guard before --tmpfs; the scriptOverrideRules loop must too.
|
||||||
|
// /etc/hosts is a real file; emitting --tmpfs /etc/hosts would make bwrap fail at runtime.
|
||||||
|
const config: AccessPolicyConfig = { policy: { "/**": "r--" } };
|
||||||
|
const overrides = { "/etc/hosts": "---" as const };
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
try {
|
||||||
|
const args = generateBwrapArgs(config, HOME, overrides);
|
||||||
|
const tmpfsMounts = args
|
||||||
|
.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null))
|
||||||
|
.filter(Boolean);
|
||||||
|
expect(tmpfsMounts).not.toContain("/etc/hosts");
|
||||||
|
expect(spy).toHaveBeenCalledWith(expect.stringContaining("/etc/hosts"));
|
||||||
|
} finally {
|
||||||
|
spy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('script override "---" rule targeting a non-existent path emits --tmpfs (assumed directory)', () => {
|
||||||
|
const config: AccessPolicyConfig = { policy: { "/**": "r--" } };
|
||||||
|
const overrides = { "/nonexistent-path-for-test/**": "---" as const };
|
||||||
|
const args = generateBwrapArgs(config, HOME, overrides);
|
||||||
|
const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean);
|
||||||
|
expect(tmpfsMounts).toContain("/nonexistent-path-for-test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('script override "-w-" under restrictive base emits --bind-try, not --tmpfs', () => {
|
||||||
|
// Greptile: permAllowsWrite && (r || defaultR) condition was wrong — for -w- without /**
|
||||||
|
// both flags are false so it fell to else → --tmpfs, silently blocking writes.
|
||||||
|
// Fix: any write-granting override always emits --bind-try.
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: { [`${HOME}/workspace/**`]: "rwx" },
|
||||||
|
};
|
||||||
|
const overrides = { [`${HOME}/logs/**`]: "-w-" as const };
|
||||||
|
const args = generateBwrapArgs(config, HOME, overrides);
|
||||||
|
const bindMounts = args
|
||||||
|
.map((a, i) => (a === "--bind-try" ? args[i + 1] : null))
|
||||||
|
.filter(Boolean);
|
||||||
|
const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean);
|
||||||
|
expect(bindMounts).toContain(`${HOME}/logs`);
|
||||||
|
expect(tmpfsMounts).not.toContain(`${HOME}/logs`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("narrowing rule that resolves to an existing file does not emit --tmpfs (bwrap only accepts dirs)", () => {
|
||||||
|
// /etc/hosts is a file on Linux; bwrap --tmpfs rejects file paths.
|
||||||
|
// generateBwrapArgs must not emit "--tmpfs /etc/hosts" — it should be silently skipped.
|
||||||
|
const config: AccessPolicyConfig = { policy: { "/**": "r--", "/etc/hosts/**": "---" } };
|
||||||
|
const args = generateBwrapArgs(config, HOME);
|
||||||
|
const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean);
|
||||||
|
expect(tmpfsMounts).not.toContain("/etc/hosts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits a console.error warning when a file-specific narrowing rule path is skipped", () => {
|
||||||
|
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
try {
|
||||||
|
_warnBwrapFileDenyOnce("/etc/passwd");
|
||||||
|
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("/etc/passwd"));
|
||||||
|
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("parent directory"));
|
||||||
|
} finally {
|
||||||
|
errSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still emits --tmpfs for "---" rule that resolves to a directory', () => {
|
||||||
|
// Non-existent paths are treated as directories (forward-protection).
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: { "/**": "r--", [`${HOME}/.nonexistent-dir/**`]: "---" },
|
||||||
|
};
|
||||||
|
const args = generateBwrapArgs(config, HOME);
|
||||||
|
const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean);
|
||||||
|
expect(tmpfsMounts).toContain(`${HOME}/.nonexistent-dir`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trailing-slash rule is treated as /** and resolves to correct path", () => {
|
||||||
|
// "/tmp/" is shorthand for "/tmp/**" — must produce the same mount target
|
||||||
|
// and sort-order length as an explicit "/tmp/**" rule.
|
||||||
|
const withSlash = generateBwrapArgs({ policy: { "/tmp/": "rw-" } }, HOME);
|
||||||
|
const withGlob = generateBwrapArgs({ policy: { "/tmp/**": "rw-" } }, HOME);
|
||||||
|
const bindOf = (args: string[]) =>
|
||||||
|
args.map((a, i) => (args[i - 1] === "--bind-try" ? a : null)).filter(Boolean);
|
||||||
|
expect(bindOf(withSlash)).toContain("/tmp");
|
||||||
|
expect(bindOf(withSlash)).toEqual(bindOf(withGlob));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("malformed perm string in base rules emits no mount (fail closed, not --ro-bind-try)", () => {
|
||||||
|
// A malformed perm like "rwxoops" must not produce a --ro-bind-try mount.
|
||||||
|
// Previously the else-if branch accessed perm[0] without VALID_PERM_RE guard,
|
||||||
|
// which could emit --ro-bind-try for a rule meant to be restrictive.
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: {
|
||||||
|
[`${HOME}/workspace/**`]:
|
||||||
|
"rwxoops" as unknown as import("../config/types.tools.js").PermStr,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const args = generateBwrapArgs(config, HOME);
|
||||||
|
const roBound = args
|
||||||
|
.map((a, i) => (a === "--ro-bind-try" ? args[i + 1] : null))
|
||||||
|
.filter(Boolean);
|
||||||
|
const rwBound = args.map((a, i) => (a === "--bind-try" ? args[i + 1] : null)).filter(Boolean);
|
||||||
|
// Malformed perm must not produce any mount for this path.
|
||||||
|
expect(roBound).not.toContain(`${HOME}/workspace`);
|
||||||
|
expect(rwBound).not.toContain(`${HOME}/workspace`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("malformed perm string in script override emits no --ro-bind-try (fail closed)", () => {
|
||||||
|
// Same VALID_PERM_RE guard required in the scriptOverrideRules loop.
|
||||||
|
const config: AccessPolicyConfig = { policy: { "/**": "r--" } };
|
||||||
|
const overrides = {
|
||||||
|
[`${HOME}/data/**`]: "rwxoops" as unknown as import("../config/types.tools.js").PermStr,
|
||||||
|
};
|
||||||
|
const args = generateBwrapArgs(config, HOME, overrides);
|
||||||
|
const roBound = args
|
||||||
|
.map((a, i) => (a === "--ro-bind-try" ? args[i + 1] : null))
|
||||||
|
.filter(Boolean);
|
||||||
|
const rwBound = args.map((a, i) => (a === "--bind-try" ? args[i + 1] : null)).filter(Boolean);
|
||||||
|
expect(roBound).not.toContain(`${HOME}/data`);
|
||||||
|
expect(rwBound).not.toContain(`${HOME}/data`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("wrapCommandWithBwrap", () => {
|
||||||
|
it("starts with bwrap", () => {
|
||||||
|
const result = wrapCommandWithBwrap("ls /tmp", { policy: { "/**": "r--" } }, HOME);
|
||||||
|
expect(result).toMatch(/^bwrap /);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("contains -- separator before the command", () => {
|
||||||
|
const result = wrapCommandWithBwrap("ls /tmp", { policy: { "/**": "r--" } }, HOME);
|
||||||
|
expect(result).toContain("-- /bin/sh -c");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps command in /bin/sh -c", () => {
|
||||||
|
const result = wrapCommandWithBwrap("cat /etc/hosts", { policy: { "/**": "r--" } }, HOME);
|
||||||
|
expect(result).toContain("/bin/sh -c");
|
||||||
|
expect(result).toContain("cat /etc/hosts");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("_resetBwrapAvailableCacheForTest", () => {
|
||||||
|
it("clears the availability cache so isBwrapAvailable re-probes", async () => {
|
||||||
|
// Prime the cache with one result, then reset and verify the next call re-checks.
|
||||||
|
await isBwrapAvailable(); // populates cache
|
||||||
|
_resetBwrapAvailableCacheForTest();
|
||||||
|
// After reset, isBwrapAvailable must re-probe (result may differ in test env — just
|
||||||
|
// verify it returns a boolean without throwing, proving the cache was cleared).
|
||||||
|
const result = await isBwrapAvailable();
|
||||||
|
expect(typeof result).toBe("boolean");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("_resetBwrapFileDenyWarnedPathsForTest", () => {
|
||||||
|
it("clears the warned-paths set so the same path can warn again", () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
// First warning — set is empty, should fire.
|
||||||
|
_warnBwrapFileDenyOnce("/tmp/secret.txt");
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
// Second call with same path — already warned, should NOT fire again.
|
||||||
|
_warnBwrapFileDenyOnce("/tmp/secret.txt");
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
// After reset the warning should fire again.
|
||||||
|
_resetBwrapFileDenyWarnedPathsForTest();
|
||||||
|
_warnBwrapFileDenyOnce("/tmp/secret.txt");
|
||||||
|
expect(spy).toHaveBeenCalledTimes(2);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,315 @@
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import type { AccessPolicyConfig, PermStr } from "../config/types.tools.js";
|
||||||
|
import { findBestRule } from "./access-policy.js";
|
||||||
|
import { shellEscape } from "./shell-escape.js";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* bwrap (bubblewrap) profile generator for Linux.
|
||||||
|
*
|
||||||
|
* Translates tools.fs.permissions into a mount-namespace spec so that exec
|
||||||
|
* commands see only the filesystem view defined by the policy. Denied paths
|
||||||
|
* are overlaid with an empty tmpfs — they appear to exist but contain nothing,
|
||||||
|
* preventing reads of sensitive files even when paths are expressed via
|
||||||
|
* variable expansion (cat $HOME/.ssh/id_rsa, etc.).
|
||||||
|
*
|
||||||
|
* Note: bwrap is not installed by default on all distributions. Use
|
||||||
|
* isBwrapAvailable() to check before calling generateBwrapArgs().
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Standard system paths to bind read-only so wrapped commands can run.
|
||||||
|
// These are read-only unless the user's rules grant write access.
|
||||||
|
const SYSTEM_RO_BIND_PATHS = ["/usr", "/bin", "/lib", "/lib64", "/sbin", "/etc", "/opt"] as const;
|
||||||
|
|
||||||
|
let bwrapAvailableCache: boolean | undefined;
|
||||||
|
|
||||||
|
// Warn once per process when a file-specific "---" rule cannot be enforced at
|
||||||
|
// the OS layer (bwrap --tmpfs only accepts directories). Tool-layer enforcement
|
||||||
|
// still applies for read/write/edit tool calls, but exec commands that access
|
||||||
|
// the file directly inside the sandbox are not blocked at the syscall level.
|
||||||
|
const _bwrapFileDenyWarnedPaths = new Set<string>();
|
||||||
|
/** Reset the one-time file-deny warning set. Only for use in tests. */
|
||||||
|
export function _resetBwrapFileDenyWarnedPathsForTest(): void {
|
||||||
|
_bwrapFileDenyWarnedPaths.clear();
|
||||||
|
}
|
||||||
|
/** Reset the bwrap availability cache. Only for use in tests. */
|
||||||
|
export function _resetBwrapAvailableCacheForTest(): void {
|
||||||
|
bwrapAvailableCache = undefined;
|
||||||
|
}
|
||||||
|
export function _warnBwrapFileDenyOnce(filePath: string): void {
|
||||||
|
if (_bwrapFileDenyWarnedPaths.has(filePath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_bwrapFileDenyWarnedPaths.add(filePath);
|
||||||
|
console.error(
|
||||||
|
`[access-policy] bwrap: "---" rule for "${filePath}" resolves to a file — ` +
|
||||||
|
`OS-level (bwrap) enforcement is not applied. ` +
|
||||||
|
`Tool-layer enforcement still blocks read/write/edit tool calls. ` +
|
||||||
|
`To protect this file at the OS layer on Linux, use a "---" rule on its parent directory instead.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if bwrap is installed and executable on this system.
|
||||||
|
* Result is cached after the first call.
|
||||||
|
*/
|
||||||
|
export async function isBwrapAvailable(): Promise<boolean> {
|
||||||
|
if (bwrapAvailableCache !== undefined) {
|
||||||
|
return bwrapAvailableCache;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await execFileAsync("bwrap", ["--version"]);
|
||||||
|
bwrapAvailableCache = true;
|
||||||
|
} catch {
|
||||||
|
bwrapAvailableCache = false;
|
||||||
|
}
|
||||||
|
return bwrapAvailableCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expand a leading ~ and trailing-slash shorthand (mirrors access-policy.ts expandPattern). */
|
||||||
|
function expandPattern(pattern: string, homeDir: string): string {
|
||||||
|
// Trailing / shorthand: "/tmp/" → "/tmp/**" so sort-order length matches a
|
||||||
|
// "/tmp/**" rule and patternToPath strips it to "/tmp" correctly.
|
||||||
|
const normalized = pattern.endsWith("/") ? pattern + "**" : pattern;
|
||||||
|
if (!normalized.startsWith("~")) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return normalized.replace(/^~(?=$|[/\\])/, homeDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip trailing wildcard segments to get the longest concrete path prefix.
|
||||||
|
* e.g. "/Users/kaveri/**" → "/Users/kaveri"
|
||||||
|
* "/tmp/foo" → "/tmp/foo"
|
||||||
|
*
|
||||||
|
* For mid-path wildcards (e.g. "skills/**\/*.sh"), returns the concrete prefix
|
||||||
|
* when perm is not "---" — the prefix is an intentional approximation for bwrap
|
||||||
|
* mounts; the tool layer enforces the file-type filter precisely. For "---" perms
|
||||||
|
* returns null so callers skip emission (a deny-all on the prefix would be too broad).
|
||||||
|
*/
|
||||||
|
function patternToPath(pattern: string, homeDir: string, perm?: PermStr): string | null {
|
||||||
|
const expanded = expandPattern(pattern, homeDir);
|
||||||
|
// Find the first wildcard character in the path.
|
||||||
|
const wildcardIdx = expanded.search(/[*?[]/);
|
||||||
|
if (wildcardIdx === -1) {
|
||||||
|
// No wildcards — the pattern is a concrete path.
|
||||||
|
return expanded || "/";
|
||||||
|
}
|
||||||
|
// Check whether there is a path separator AFTER the first wildcard.
|
||||||
|
// If so, the wildcard is in a non-final segment (e.g. skills/**/*.sh).
|
||||||
|
const afterWildcard = expanded.slice(wildcardIdx);
|
||||||
|
if (/[/\\]/.test(afterWildcard)) {
|
||||||
|
// Mid-path wildcard: for "---" perm a deny-all on the prefix is too broad — skip.
|
||||||
|
// For other perms, use the prefix as an approximate mount target; the tool layer
|
||||||
|
// enforces the file-type filter precisely.
|
||||||
|
if (!perm || perm === "---") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Fall through to use the concrete prefix below.
|
||||||
|
}
|
||||||
|
// Wildcard is only in the final segment — use the parent directory.
|
||||||
|
// e.g. "/var/log/secret*" → last sep before "*" is at 8 → "/var/log"
|
||||||
|
// We must NOT use the literal prefix up to "*" (e.g. "/var/log/secret")
|
||||||
|
// because that is not a directory and leaves suffix-glob matches uncovered.
|
||||||
|
const lastSep = expanded.lastIndexOf("/", wildcardIdx - 1);
|
||||||
|
const parentDir = lastSep > 0 ? expanded.slice(0, lastSep) : "/";
|
||||||
|
return parentDir || "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep in sync with VALID_PERM_RE in access-policy.ts and exec-sandbox-seatbelt.ts.
|
||||||
|
const VALID_PERM_RE = /^[r-][w-][x-]$/;
|
||||||
|
|
||||||
|
function permAllowsWrite(perm: PermStr): boolean {
|
||||||
|
return VALID_PERM_RE.test(perm) && perm[1] === "w";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate bwrap argument array for the given permissions config.
|
||||||
|
*
|
||||||
|
* Strategy:
|
||||||
|
* 1. Check the "/**" rule to determine permissive vs restrictive base.
|
||||||
|
* 2. Permissive base (r in "/**"): --ro-bind / / (read-only view of entire host FS).
|
||||||
|
* 3. Restrictive base (no r in "/**"): only bind system paths needed to run processes.
|
||||||
|
* 4. For each rule with w bit: upgrade to --bind (read-write).
|
||||||
|
* 5. For each "---" rule in permissive mode: overlay with --tmpfs to hide the path.
|
||||||
|
* 6. Add /tmp and /dev as writable tmpfs mounts (required for most processes).
|
||||||
|
*/
|
||||||
|
export function generateBwrapArgs(
|
||||||
|
config: AccessPolicyConfig,
|
||||||
|
homeDir: string = os.homedir(),
|
||||||
|
/**
|
||||||
|
* Script-specific override rules to emit AFTER the base rules so they win over
|
||||||
|
* broader patterns — mirrors the Seatbelt scriptOverrideRules behaviour.
|
||||||
|
* In bwrap, later mounts win, so script grants must come last.
|
||||||
|
*/
|
||||||
|
scriptOverrideRules?: Record<string, PermStr>,
|
||||||
|
): string[] {
|
||||||
|
const args: string[] = [];
|
||||||
|
// Determine base stance from the "/**" catch-all rule (replaces the removed `default` field).
|
||||||
|
const rawCatchAllPerm = findBestRule("/**", config.policy ?? {}, homeDir) ?? "---";
|
||||||
|
// Validate format before positional access — malformed perm falls back to "---" (fail closed).
|
||||||
|
const catchAllPerm = VALID_PERM_RE.test(rawCatchAllPerm) ? rawCatchAllPerm : "---";
|
||||||
|
const defaultAllowsRead = catchAllPerm[0] === "r";
|
||||||
|
|
||||||
|
if (defaultAllowsRead) {
|
||||||
|
// Permissive base: everything is read-only by default.
|
||||||
|
args.push("--ro-bind", "/", "/");
|
||||||
|
// --ro-bind / / is a recursive bind but does NOT propagate special kernel
|
||||||
|
// filesystems (procfs, devtmpfs) into the new mount namespace. Explicitly
|
||||||
|
// mount /proc so programs that read /proc/self/*, /proc/cpuinfo, etc. work
|
||||||
|
// correctly inside the sandbox (shells, Python, most build tools need this).
|
||||||
|
args.push("--proc", "/proc");
|
||||||
|
args.push("--dev", "/dev");
|
||||||
|
} else {
|
||||||
|
// Restrictive base: only bind system paths needed to run processes.
|
||||||
|
for (const p of SYSTEM_RO_BIND_PATHS) {
|
||||||
|
args.push("--ro-bind-try", p, p);
|
||||||
|
}
|
||||||
|
// proc and dev are needed for most processes.
|
||||||
|
args.push("--proc", "/proc");
|
||||||
|
args.push("--dev", "/dev");
|
||||||
|
// /tmp is intentionally NOT mounted here — a restrictive policy (default:"---")
|
||||||
|
// should not grant free write access to /tmp. Add a rule "/tmp/**": "rw-" if
|
||||||
|
// the enclosed process genuinely needs it.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writable /tmp tmpfs — only in permissive mode AND only when the policy does not
|
||||||
|
// explicitly restrict writes on /tmp. Keeping this outside the if/else block above
|
||||||
|
// makes the defaultAllowsRead guard self-evident and not implicit from nesting.
|
||||||
|
// In restrictive mode (default:"---"), /tmp is intentionally omitted so rules
|
||||||
|
// control tmpfs access explicitly (e.g. "/tmp/**":"rwx" is handled by the rules loop).
|
||||||
|
if (defaultAllowsRead) {
|
||||||
|
const explicitTmpPerm = findBestRule("/tmp", config.policy ?? {}, homeDir);
|
||||||
|
if (explicitTmpPerm === null) {
|
||||||
|
// Only emit --tmpfs /tmp when there is no explicit rule for /tmp.
|
||||||
|
// When an explicit write rule exists, the rules loop below emits --bind-try /tmp /tmp
|
||||||
|
// which (as a later mount) wins over --tmpfs anyway — emitting --tmpfs here too
|
||||||
|
// is dead code. When an explicit read-only rule exists, /tmp is already readable
|
||||||
|
// via --ro-bind / / and no extra mount is needed.
|
||||||
|
args.push("--tmpfs", "/tmp");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply rules: upgrade paths with w bit to read-write binds.
|
||||||
|
// Sort by concrete path length ascending so less-specific mounts are applied
|
||||||
|
// first — bwrap applies mounts in order, and later mounts win for overlapping
|
||||||
|
// paths. Without sorting, a broad rw bind (e.g. ~/dev) could be emitted after
|
||||||
|
// a narrow ro bind (~/dev/secret), wiping out the intended restriction.
|
||||||
|
const ruleEntries = Object.entries(config.policy ?? {}).toSorted(([a], [b]) => {
|
||||||
|
const pa = patternToPath(a, homeDir);
|
||||||
|
const pb = patternToPath(b, homeDir);
|
||||||
|
return (pa?.length ?? 0) - (pb?.length ?? 0);
|
||||||
|
});
|
||||||
|
for (const [pattern, perm] of ruleEntries) {
|
||||||
|
const p = patternToPath(pattern, homeDir, perm);
|
||||||
|
if (!p || p === "/") {
|
||||||
|
continue;
|
||||||
|
} // root already handled above
|
||||||
|
if (permAllowsWrite(perm)) {
|
||||||
|
// Emit --bind-try for any rule that permits writes, including write-only ("-w-").
|
||||||
|
// bwrap cannot enforce write-without-read at the mount level; a "-w-" rule under
|
||||||
|
// a restrictive base will also permit reads at the OS layer. The tool layer still
|
||||||
|
// denies read tool calls per the rule, so the practical exposure is exec-only paths.
|
||||||
|
args.push("--bind-try", p, p);
|
||||||
|
} else if (VALID_PERM_RE.test(perm) && catchAllPerm[0] !== "r" && perm[0] === "r") {
|
||||||
|
// Restrictive base: only bind paths that the rule explicitly allows reads on.
|
||||||
|
// Do NOT emit --ro-bind-try for "---" or "--x" rules — the base already denies
|
||||||
|
// by not mounting; emitting a mount here would grant read access.
|
||||||
|
// VALID_PERM_RE guard: malformed perm falls through to no-op (fail closed).
|
||||||
|
args.push("--ro-bind-try", p, p);
|
||||||
|
} else if (VALID_PERM_RE.test(perm) && perm[0] !== "r") {
|
||||||
|
// Deny/exec-only rule: overlay with --tmpfs to hide the path.
|
||||||
|
// Two cases handled identically:
|
||||||
|
// Permissive base (catchAllPerm[0] === "r"): --ro-bind / / made path readable;
|
||||||
|
// --tmpfs hides it.
|
||||||
|
// Restrictive base (catchAllPerm[0] !== "r"): SYSTEM_RO_BIND_PATHS unconditionally
|
||||||
|
// mounts /etc, /usr, /bin, /lib, /lib64, /sbin, /opt; a "---" rule on those paths
|
||||||
|
// had no effect without this branch because the three prior branches all require
|
||||||
|
// perm[0] === "r". For non-system paths in restrictive mode, --tmpfs is a no-op
|
||||||
|
// (nothing mounted there to overlay), so emitting it is harmless.
|
||||||
|
// Guard: bwrap --tmpfs only accepts a directory as the mount point. If the
|
||||||
|
// resolved path is a file, skip the mount and warn — same behaviour as deny[].
|
||||||
|
// Non-existent paths are assumed to be directories (forward-protection).
|
||||||
|
let isDir = true;
|
||||||
|
try {
|
||||||
|
isDir = fs.statSync(p).isDirectory();
|
||||||
|
} catch {
|
||||||
|
// Non-existent — assume directory.
|
||||||
|
}
|
||||||
|
if (isDir) {
|
||||||
|
args.push("--tmpfs", p);
|
||||||
|
} else {
|
||||||
|
_warnBwrapFileDenyOnce(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Permissive base + read-only rule: already covered by --ro-bind / /; no extra mount.
|
||||||
|
// Restrictive base + read-only rule: emitted as --ro-bind-try above.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Script-specific override mounts — emitted after base rules so they can reopen
|
||||||
|
// a base-denied path for a trusted script (same precedence as Seatbelt).
|
||||||
|
if (scriptOverrideRules) {
|
||||||
|
const overrideEntries = Object.entries(scriptOverrideRules).toSorted(([a], [b]) => {
|
||||||
|
const pa = patternToPath(a, homeDir);
|
||||||
|
const pb = patternToPath(b, homeDir);
|
||||||
|
return (pa?.length ?? 0) - (pb?.length ?? 0);
|
||||||
|
});
|
||||||
|
for (const [pattern, perm] of overrideEntries) {
|
||||||
|
const p = patternToPath(pattern, homeDir, perm);
|
||||||
|
if (!p || p === "/") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (permAllowsWrite(perm)) {
|
||||||
|
// Any write-granting override always needs --bind-try so the path exists
|
||||||
|
// and writes succeed. bwrap mounts are ordered; this override comes after
|
||||||
|
// deny[] tmpfs entries, so --bind-try wins regardless of the base policy.
|
||||||
|
args.push("--bind-try", p, p);
|
||||||
|
} else if (VALID_PERM_RE.test(perm) && perm[0] === "r") {
|
||||||
|
// VALID_PERM_RE guard: malformed perm falls through to the deny branch below.
|
||||||
|
args.push("--ro-bind-try", p, p);
|
||||||
|
} else {
|
||||||
|
// Mirror the base-rules isDir guard — bwrap --tmpfs only accepts directories.
|
||||||
|
let isDir = true;
|
||||||
|
try {
|
||||||
|
isDir = fs.statSync(p).isDirectory();
|
||||||
|
} catch {
|
||||||
|
// Non-existent — assume directory (forward-protection).
|
||||||
|
}
|
||||||
|
if (isDir) {
|
||||||
|
args.push("--tmpfs", p);
|
||||||
|
} else {
|
||||||
|
_warnBwrapFileDenyOnce(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separator before the command.
|
||||||
|
args.push("--");
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a shell command with bwrap using the given permissions config.
|
||||||
|
* Returns the wrapped command string ready to pass as execCommand.
|
||||||
|
*/
|
||||||
|
export function wrapCommandWithBwrap(
|
||||||
|
command: string,
|
||||||
|
config: AccessPolicyConfig,
|
||||||
|
homeDir: string = os.homedir(),
|
||||||
|
scriptOverrideRules?: Record<string, PermStr>,
|
||||||
|
): string {
|
||||||
|
const bwrapArgs = generateBwrapArgs(config, homeDir, scriptOverrideRules);
|
||||||
|
const argStr = bwrapArgs.map((a) => (a === "--" ? "--" : shellEscape(a))).join(" ");
|
||||||
|
// /bin/sh is intentional: sandboxed commands must use a shell whose path is
|
||||||
|
// within the bwrap mount namespace. The user's configured shell (getShellConfig)
|
||||||
|
// may live outside the mounted paths (e.g. /opt/homebrew/bin/fish) and would
|
||||||
|
// not be reachable inside the sandbox. /bin/sh is always available via
|
||||||
|
// SYSTEM_RO_BIND_PATHS or the permissive --ro-bind / / base mount.
|
||||||
|
return `bwrap ${argStr} /bin/sh -c ${shellEscape(command)}`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,323 @@
|
||||||
|
import os from "node:os";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
// Seatbelt (SBPL) path handling uses Unix forward-slash semantics.
|
||||||
|
// Tests that assert specific HOME paths in the profile are skipped on Windows
|
||||||
|
// where os.homedir() returns backslash paths that the generator does not emit.
|
||||||
|
const skipOnWindows = it.skipIf(process.platform === "win32");
|
||||||
|
import type { AccessPolicyConfig } from "../config/types.tools.js";
|
||||||
|
import { generateSeatbeltProfile, wrapCommandWithSeatbelt } from "./exec-sandbox-seatbelt.js";
|
||||||
|
|
||||||
|
const HOME = os.homedir();
|
||||||
|
|
||||||
|
describe("generateSeatbeltProfile", () => {
|
||||||
|
it("starts with (version 1)", () => {
|
||||||
|
const profile = generateSeatbeltProfile({}, HOME);
|
||||||
|
expect(profile).toMatch(/^\(version 1\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses (deny default) when no /** catch-all rule", () => {
|
||||||
|
const profile = generateSeatbeltProfile({}, HOME);
|
||||||
|
expect(profile).toContain("(deny default)");
|
||||||
|
expect(profile).not.toContain("(allow default)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses (allow default) when /** rule has any permission", () => {
|
||||||
|
const profile = generateSeatbeltProfile({ policy: { "/**": "r--" } }, HOME);
|
||||||
|
expect(profile).toContain("(allow default)");
|
||||||
|
expect(profile).not.toContain("(deny default)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes system baseline reads when no catch-all rule", () => {
|
||||||
|
const profile = generateSeatbeltProfile({}, HOME);
|
||||||
|
expect(profile).toContain("(allow file-read*");
|
||||||
|
expect(profile).toContain("/usr/lib");
|
||||||
|
expect(profile).toContain("/System/Library");
|
||||||
|
});
|
||||||
|
|
||||||
|
skipOnWindows("--- rule emits deny file-read*, file-write*, process-exec* for that path", () => {
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: { "/**": "rwx", [`${HOME}/.ssh/**`]: "---" },
|
||||||
|
};
|
||||||
|
const profile = generateSeatbeltProfile(config, HOME);
|
||||||
|
expect(profile).toContain(`(deny file-read*`);
|
||||||
|
expect(profile).toContain(`(deny file-write*`);
|
||||||
|
expect(profile).toContain(`(deny process-exec*`);
|
||||||
|
expect(profile).toContain(HOME + "/.ssh");
|
||||||
|
});
|
||||||
|
|
||||||
|
skipOnWindows("expands ~ in --- rules using provided homeDir", () => {
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: { "/**": "rwx", "~/.ssh/**": "---" },
|
||||||
|
};
|
||||||
|
const profile = generateSeatbeltProfile(config, HOME);
|
||||||
|
expect(profile).toContain(HOME + "/.ssh");
|
||||||
|
expect(profile).not.toContain("~/.ssh");
|
||||||
|
});
|
||||||
|
|
||||||
|
skipOnWindows("expands ~ in rules using provided homeDir", () => {
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: { "~/**": "rw-" },
|
||||||
|
};
|
||||||
|
const profile = generateSeatbeltProfile(config, HOME);
|
||||||
|
expect(profile).toContain(HOME);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rw- rule emits allow read+write, deny exec for that path", () => {
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: { [`${HOME}/workspace/**`]: "rw-" },
|
||||||
|
};
|
||||||
|
const profile = generateSeatbeltProfile(config, HOME);
|
||||||
|
expect(profile).toContain(`(allow file-read*`);
|
||||||
|
expect(profile).toContain(`(allow file-write*`);
|
||||||
|
expect(profile).toContain(`(deny process-exec*`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("r-x rule emits allow read+exec, deny write for that path", () => {
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: { "/usr/bin/**": "r-x" },
|
||||||
|
};
|
||||||
|
const profile = generateSeatbeltProfile(config, HOME);
|
||||||
|
const rulesSection = profile.split("; User-defined path rules")[1] ?? "";
|
||||||
|
expect(rulesSection).toContain("(allow file-read*");
|
||||||
|
expect(rulesSection).toContain("(allow process-exec*");
|
||||||
|
expect(rulesSection).toContain("(deny file-write*");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("narrowing --- rule appears after broader allow rule in profile", () => {
|
||||||
|
// SBPL last-match-wins: the --- rule for .ssh must appear after the broader rwx rule.
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: { [`${HOME}/**`]: "rwx", [`${HOME}/.ssh/**`]: "---" },
|
||||||
|
};
|
||||||
|
const profile = generateSeatbeltProfile(config, HOME);
|
||||||
|
const rulesIdx = profile.indexOf("; User-defined path rules");
|
||||||
|
expect(rulesIdx).toBeGreaterThan(-1);
|
||||||
|
// The broader allow must appear before the narrowing deny.
|
||||||
|
const allowIdx = profile.indexOf("(allow file-read*");
|
||||||
|
const denyIdx = profile.lastIndexOf("(deny file-read*");
|
||||||
|
expect(allowIdx).toBeGreaterThan(-1);
|
||||||
|
expect(denyIdx).toBeGreaterThan(allowIdx);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty config without throwing", () => {
|
||||||
|
expect(() => generateSeatbeltProfile({}, HOME)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("permissive base with no exec bit includes system baseline exec paths", () => {
|
||||||
|
// "/**": "r--" emits (deny process-exec* (subpath "/")) but must also allow
|
||||||
|
// system binaries — otherwise ls, grep, cat all fail inside the sandbox.
|
||||||
|
const profile = generateSeatbeltProfile({ policy: { "/**": "r--" } }, HOME);
|
||||||
|
expect(profile).toContain("(allow process-exec*");
|
||||||
|
expect(profile).toContain("/bin");
|
||||||
|
expect(profile).toContain("/usr/bin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("permissive base with exec bit does NOT add redundant exec baseline", () => {
|
||||||
|
// "/**": "rwx" already allows everything including exec — no extra baseline needed.
|
||||||
|
const profile = generateSeatbeltProfile({ policy: { "/**": "rwx" } }, HOME);
|
||||||
|
expect(profile).toContain("(allow default)");
|
||||||
|
expect(profile).not.toContain("System baseline exec");
|
||||||
|
});
|
||||||
|
|
||||||
|
skipOnWindows("script-override narrowing emits deny ops so access is actually reduced", () => {
|
||||||
|
// Base allows rw- on workspace; script override narrows to r-- for a subpath.
|
||||||
|
// Without deny ops in the override block, write would still be allowed.
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: { [`${HOME}/workspace/**`]: "rw-" },
|
||||||
|
};
|
||||||
|
const overrideRules: Record<string, string> = { [`${HOME}/workspace/locked/**`]: "r--" };
|
||||||
|
const profile = generateSeatbeltProfile(config, HOME, overrideRules);
|
||||||
|
// The override section must deny write for the locked path.
|
||||||
|
const overrideSection = profile.split("Script-override")[1] ?? "";
|
||||||
|
expect(overrideSection).toContain("(deny file-write*");
|
||||||
|
expect(overrideSection).toContain(`${HOME}/workspace/locked`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits /private/tmp baseline when no rule grants /tmp", () => {
|
||||||
|
const profile = generateSeatbeltProfile({}, HOME);
|
||||||
|
expect(profile).not.toContain(`(subpath "/private/tmp")`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes /private/tmp baseline when a rule grants read access to /tmp", () => {
|
||||||
|
const profile = generateSeatbeltProfile({ policy: { "/tmp/**": "rw-" } }, HOME);
|
||||||
|
expect(profile).toContain(`(subpath "/private/tmp")`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("read-only /tmp rule does not grant file-write* on /private/tmp", () => {
|
||||||
|
const profile = generateSeatbeltProfile({ policy: { "/tmp/**": "r--" } }, HOME);
|
||||||
|
expect(profile).toMatch(/allow file-read[^)]*\(subpath "\/private\/tmp"\)/);
|
||||||
|
expect(profile).not.toMatch(/allow file-write\*[^)]*\(subpath "\/private\/tmp"\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("write-only /tmp rule grants file-write* but not read ops on /private/tmp", () => {
|
||||||
|
const profile = generateSeatbeltProfile({ policy: { "/tmp/**": "-w-" } }, HOME);
|
||||||
|
expect(profile).toMatch(/allow file-write\*[^)]*\(subpath "\/private\/tmp"\)/);
|
||||||
|
expect(profile).not.toMatch(/allow file-read[^)]*\(subpath "\/private\/tmp"\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exec-only /tmp rule grants process-exec* on /private/tmp", () => {
|
||||||
|
const profile = generateSeatbeltProfile({ policy: { "/tmp/**": "--x" } }, HOME);
|
||||||
|
expect(profile).toMatch(/allow process-exec\*[^)]*\(subpath "\/private\/tmp"\)/);
|
||||||
|
expect(profile).not.toMatch(/allow file-read[^)]*\(subpath "\/private\/tmp"\)/);
|
||||||
|
expect(profile).not.toMatch(/allow file-write\*[^)]*\(subpath "\/private\/tmp"\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("r-x /tmp rule grants both read and exec on /private/tmp", () => {
|
||||||
|
const profile = generateSeatbeltProfile({ policy: { "/tmp/**": "r-x" } }, HOME);
|
||||||
|
expect(profile).toMatch(/allow file-read[^)]*\(subpath "\/private\/tmp"\)/);
|
||||||
|
expect(profile).toMatch(/allow process-exec\*[^)]*\(subpath "\/private\/tmp"\)/);
|
||||||
|
expect(profile).not.toMatch(/allow file-write\*[^)]*\(subpath "\/private\/tmp"\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Symlink attack mitigation — profile ordering
|
||||||
|
//
|
||||||
|
// macOS Seatbelt evaluates the *resolved* (real) path at syscall time, not
|
||||||
|
// the symlink path. So a symlink in an allowed directory pointing to a denied
|
||||||
|
// target is blocked by the deny rule for the real path — but only if that
|
||||||
|
// deny rule appears AFTER the allow rule for the workspace in the profile
|
||||||
|
// (SBPL: last matching rule wins).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
skipOnWindows(
|
||||||
|
"--- rule for sensitive path appears after workspace allow — symlink to deny target is blocked",
|
||||||
|
() => {
|
||||||
|
// If ~/workspace/link → ~/.ssh/id_rsa, seatbelt evaluates ~/.ssh/id_rsa.
|
||||||
|
// The --- rule for ~/.ssh must appear after the workspace allow so it wins.
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: {
|
||||||
|
[`${HOME}/workspace/**`]: "rw-",
|
||||||
|
[`${HOME}/.ssh/**`]: "---",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const profile = generateSeatbeltProfile(config, HOME);
|
||||||
|
const workspaceAllowIdx = profile.indexOf("(allow file-read*");
|
||||||
|
const sshDenyIdx = profile.lastIndexOf("(deny file-read*");
|
||||||
|
expect(workspaceAllowIdx).toBeGreaterThan(-1);
|
||||||
|
expect(sshDenyIdx).toBeGreaterThan(workspaceAllowIdx);
|
||||||
|
expect(profile).toContain(`${HOME}/.ssh`);
|
||||||
|
expect(profile).toContain(`${HOME}/workspace`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
skipOnWindows(
|
||||||
|
"restrictive rule on subdir appears after broader rw rule — covers symlink to restricted subtree",
|
||||||
|
() => {
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: {
|
||||||
|
[`${HOME}/workspace/**`]: "rw-",
|
||||||
|
[`${HOME}/workspace/secret/**`]: "r--",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const profile = generateSeatbeltProfile(config, HOME);
|
||||||
|
const workspaceWriteIdx = profile.indexOf("(allow file-write*");
|
||||||
|
const secretWriteDenyIdx = profile.lastIndexOf("(deny file-write*");
|
||||||
|
expect(workspaceWriteIdx).toBeGreaterThan(-1);
|
||||||
|
expect(secretWriteDenyIdx).toBeGreaterThan(workspaceWriteIdx);
|
||||||
|
expect(profile).toContain(`${HOME}/workspace/secret`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("glob patterns are stripped to their longest concrete prefix", () => {
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: { "/Users/kaveri/.ssh/**": "---", "/**": "rwx" },
|
||||||
|
};
|
||||||
|
const profile = generateSeatbeltProfile(config, "/Users/kaveri");
|
||||||
|
expect(profile).not.toContain("**");
|
||||||
|
expect(profile).toContain("/Users/kaveri/.ssh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bracket glob patterns are treated as wildcards, not SBPL literals", () => {
|
||||||
|
// Previously /[*?]/ missed [ — a pattern like "/usr/bin/[abc]" was emitted as
|
||||||
|
// sbplLiteral("/usr/bin/[abc]") which only matches a file literally named "[abc]".
|
||||||
|
// Fix: /[*?[]/ detects bracket globs and strips to the concrete prefix.
|
||||||
|
const config: AccessPolicyConfig = {
|
||||||
|
policy: { "/**": "rwx", "/usr/bin/[abc]": "---" as const },
|
||||||
|
};
|
||||||
|
const profile = generateSeatbeltProfile(config, HOME);
|
||||||
|
// Must NOT emit the literal bracket pattern.
|
||||||
|
expect(profile).not.toContain("[abc]");
|
||||||
|
// Must use the concrete prefix /usr/bin as an approximate subpath target.
|
||||||
|
expect(profile).toContain("/usr/bin");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("wrapCommandWithSeatbelt", () => {
|
||||||
|
it("wraps command with sandbox-exec -f <tmpfile>", () => {
|
||||||
|
const result = wrapCommandWithSeatbelt("ls /tmp", "(version 1)\n(allow default)");
|
||||||
|
expect(result).toMatch(/^sandbox-exec -f /);
|
||||||
|
expect(result).toContain("ls /tmp");
|
||||||
|
// Profile content is in a temp file, not inline — not visible in ps output.
|
||||||
|
expect(result).not.toContain("(version 1)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("profile file is not embedded in the command string", () => {
|
||||||
|
const result = wrapCommandWithSeatbelt("echo hi", "(allow default) ; it's a test");
|
||||||
|
expect(result).not.toContain("it's a test");
|
||||||
|
expect(result).toContain("openclaw-sb-");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a distinct profile file per call to avoid concurrent-exec policy races", () => {
|
||||||
|
const r1 = wrapCommandWithSeatbelt("echo 1", "(allow default)");
|
||||||
|
const r2 = wrapCommandWithSeatbelt("echo 2", "(allow default)");
|
||||||
|
// Each call must get its own file so overlapping execs with different profiles don't race.
|
||||||
|
const extract = (cmd: string) => cmd.match(/-f (\S+)/)?.[1];
|
||||||
|
expect(extract(r1)).not.toBe(extract(r2));
|
||||||
|
expect(extract(r1)).toContain("openclaw-sb-");
|
||||||
|
expect(extract(r2)).toContain("openclaw-sb-");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps command in /bin/sh -c", () => {
|
||||||
|
const result = wrapCommandWithSeatbelt("cat /etc/hosts", "(allow default)");
|
||||||
|
expect(result).toContain("/bin/sh -c");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("profile file path contains a random component (not just pid+seq)", () => {
|
||||||
|
const extract = (cmd: string) => cmd.match(/-f (\S+)/)?.[1] ?? "";
|
||||||
|
const r1 = wrapCommandWithSeatbelt("echo 1", "(allow default)");
|
||||||
|
const r2 = wrapCommandWithSeatbelt("echo 2", "(allow default)");
|
||||||
|
// Path must be unpredictable — strip the pid prefix and check the random suffix varies.
|
||||||
|
const suffix = (p: string) => p.replace(/.*openclaw-sb-\d+-/, "").replace(".sb", "");
|
||||||
|
expect(suffix(extract(r1))).not.toBe(suffix(extract(r2)));
|
||||||
|
expect(suffix(extract(r1)).length).toBeGreaterThanOrEqual(8); // at least 4 random bytes
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateSeatbeltProfile — mid-path wildcard guard", () => {
|
||||||
|
skipOnWindows(
|
||||||
|
"non-deny mid-path wildcard emits prefix subpath for r/w but NOT exec (option B)",
|
||||||
|
() => {
|
||||||
|
// skills/**/*.sh: r-x — mid-path wildcard; OS prefix is /home.
|
||||||
|
// Read is emitted (bounded over-grant). Exec is omitted — granting exec on the
|
||||||
|
// entire prefix would allow arbitrary binaries to run, not just *.sh files.
|
||||||
|
// Exec falls through to ancestor rule; tool layer enforces it precisely.
|
||||||
|
const profile = generateSeatbeltProfile({ policy: { "/home/*/workspace/**": "r-x" } }, HOME);
|
||||||
|
const rulesSection = profile.split("; User-defined path rules")[1] ?? "";
|
||||||
|
expect(rulesSection).toContain("(allow file-read*");
|
||||||
|
expect(rulesSection).toContain('(subpath "/home")');
|
||||||
|
expect(rulesSection).not.toContain("(allow process-exec*");
|
||||||
|
// exec deny also omitted — falls through to ancestor
|
||||||
|
expect(rulesSection).not.toContain("(deny process-exec*");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
skipOnWindows("--- mid-path wildcard is skipped (deny-all on prefix would be too broad)", () => {
|
||||||
|
// A deny-all on the /home prefix would block the entire home directory — too broad.
|
||||||
|
const profile = generateSeatbeltProfile({ policy: { "/home/*/workspace/**": "---" } }, HOME);
|
||||||
|
expect(profile).not.toContain('(subpath "/home")');
|
||||||
|
});
|
||||||
|
|
||||||
|
skipOnWindows("still emits trailing-** rules that have no mid-path wildcard", () => {
|
||||||
|
const profile = generateSeatbeltProfile({ policy: { "/tmp/**": "rwx" } }, HOME);
|
||||||
|
expect(profile).toContain('(subpath "/tmp")');
|
||||||
|
});
|
||||||
|
|
||||||
|
skipOnWindows("? wildcard is stripped correctly — no literal ? in SBPL matcher", () => {
|
||||||
|
// Pattern "/tmp/file?.txt" has a ? wildcard; the strip regex must remove it so
|
||||||
|
// the SBPL matcher does not contain a raw "?" character. Stripping "?.txt" from
|
||||||
|
// "/tmp/file?.txt" yields "/tmp/file" — a more precise subpath than "/tmp".
|
||||||
|
const profile = generateSeatbeltProfile({ policy: { "/tmp/file?.txt": "r--" } }, HOME);
|
||||||
|
expect(profile).not.toMatch(/\?/); // no literal ? in the emitted profile
|
||||||
|
expect(profile).toContain('(subpath "/tmp/file")');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,391 @@
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { AccessPolicyConfig, PermStr } from "../config/types.tools.js";
|
||||||
|
import { findBestRule } from "./access-policy.js";
|
||||||
|
import { shellEscape } from "./shell-escape.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seatbelt (SBPL) profile generator for macOS sandbox-exec.
|
||||||
|
*
|
||||||
|
* Translates tools.fs.permissions into a Seatbelt profile so that exec commands
|
||||||
|
* run under OS-level path enforcement — catching variable-expanded paths like
|
||||||
|
* `cat $HOME/.ssh/id_rsa` that config-level heuristics cannot intercept.
|
||||||
|
*
|
||||||
|
* Precedence in generated profiles (matches AccessPolicyConfig semantics):
|
||||||
|
* 1. deny[] entries — placed last, always override rules.
|
||||||
|
* 2. rules — sorted shortest-to-longest so more specific rules overwrite broader ones.
|
||||||
|
* 3. System baseline — allows the process to load libraries and basic OS resources.
|
||||||
|
* 4. default — sets the base allow/deny for everything else.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// SBPL operation names for each permission bit.
|
||||||
|
const SEATBELT_READ_OPS = "file-read*";
|
||||||
|
const SEATBELT_WRITE_OPS = "file-write*";
|
||||||
|
const SEATBELT_EXEC_OPS = "process-exec*";
|
||||||
|
|
||||||
|
// System paths every process needs to function (dynamic linker, stdlib, etc.).
|
||||||
|
// These are allowed for file-read* regardless of user rules so wrapped commands
|
||||||
|
// don't break when default is "---".
|
||||||
|
const SYSTEM_BASELINE_READ_PATHS = [
|
||||||
|
"/usr/lib",
|
||||||
|
"/usr/share",
|
||||||
|
"/System/Library",
|
||||||
|
"/Library/Frameworks",
|
||||||
|
"/private/var/db/timezone",
|
||||||
|
"/dev/null",
|
||||||
|
"/dev/random",
|
||||||
|
"/dev/urandom",
|
||||||
|
"/dev/fd",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SYSTEM_BASELINE_EXEC_PATHS = [
|
||||||
|
"/bin",
|
||||||
|
"/usr/bin",
|
||||||
|
"/usr/libexec",
|
||||||
|
"/System/Library/Frameworks",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function escapeSubpath(p: string): string {
|
||||||
|
// SBPL strings use double-quote delimiters; escape embedded quotes and backslashes.
|
||||||
|
return p.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sbplSubpath(p: string): string {
|
||||||
|
return `(subpath "${escapeSubpath(p)}")`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sbplLiteral(p: string): string {
|
||||||
|
return `(literal "${escapeSubpath(p)}")`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a path pattern to a concrete path for SBPL.
|
||||||
|
* Glob wildcards (**) are stripped to their longest non-wildcard prefix
|
||||||
|
* since SBPL uses subpath/literal matchers, not globs.
|
||||||
|
* e.g. "/Users/kaveri/**" → subpath("/Users/kaveri")
|
||||||
|
* "/usr/bin/grep" → literal("/usr/bin/grep")
|
||||||
|
*/
|
||||||
|
// macOS /private/* aliases — when a pattern covers /tmp, /var, or /etc we must
|
||||||
|
// also emit the /private/* form so seatbelt (which sees real paths) matches.
|
||||||
|
const SBPL_ALIAS_PAIRS: ReadonlyArray<[alias: string, real: string]> = [
|
||||||
|
["/tmp", "/private/tmp"],
|
||||||
|
["/var", "/private/var"],
|
||||||
|
["/etc", "/private/etc"],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand a pattern to include the /private/* equivalent if it starts with a
|
||||||
|
* known macOS alias. Returns [original, ...extras] — the extra entries are
|
||||||
|
* emitted as additional SBPL rules alongside the original.
|
||||||
|
*/
|
||||||
|
function expandSbplAliases(pattern: string): string[] {
|
||||||
|
for (const [alias, real] of SBPL_ALIAS_PAIRS) {
|
||||||
|
if (pattern === alias) {
|
||||||
|
return [pattern, real];
|
||||||
|
}
|
||||||
|
if (pattern.startsWith(alias + "/")) {
|
||||||
|
return [pattern, real + pattern.slice(alias.length)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [pattern];
|
||||||
|
}
|
||||||
|
|
||||||
|
type SbplMatchResult =
|
||||||
|
| { matcher: string; approximate: false }
|
||||||
|
| { matcher: string; approximate: true } // mid-path wildcard — exec bit must be skipped
|
||||||
|
| null;
|
||||||
|
|
||||||
|
function patternToSbplMatcher(pattern: string, homeDir: string, perm?: PermStr): SbplMatchResult {
|
||||||
|
// Trailing / shorthand: "/tmp/" → "/tmp/**"
|
||||||
|
const withExpanded = pattern.endsWith("/") ? pattern + "**" : pattern;
|
||||||
|
const expanded = withExpanded.startsWith("~")
|
||||||
|
? withExpanded.replace(/^~(?=$|[/\\])/, homeDir)
|
||||||
|
: withExpanded;
|
||||||
|
|
||||||
|
// Strip trailing wildcard segments to get the longest concrete prefix.
|
||||||
|
// Both * and ? are wildcard characters in glob syntax; strip from whichever
|
||||||
|
// appears first so patterns like "/tmp/file?.txt" don't embed a literal ?
|
||||||
|
// in the SBPL literal matcher.
|
||||||
|
// Strip from the first glob metacharacter (*, ?, or [) to get the longest concrete prefix.
|
||||||
|
const withoutWild = expanded.replace(/[/\\]?[*?[].*$/, "");
|
||||||
|
const base = withoutWild || "/";
|
||||||
|
|
||||||
|
// If the original pattern had wildcards, use subpath (recursive match).
|
||||||
|
// Includes bracket globs ([abc]) — previously only * and ? were detected,
|
||||||
|
// causing [abc] to be emitted as an SBPL literal that only matches a file
|
||||||
|
// literally named "[abc]", not the intended character-class targets.
|
||||||
|
if (/[*?[]/.test(expanded)) {
|
||||||
|
const wildcardIdx = expanded.search(/[*?[]/);
|
||||||
|
const afterWildcard = expanded.slice(wildcardIdx + 1);
|
||||||
|
if (/[/\\]/.test(afterWildcard)) {
|
||||||
|
// Mid-path wildcard (e.g. skills/**/*.sh): SBPL has no glob matcher so we fall
|
||||||
|
// back to the longest concrete prefix as a subpath.
|
||||||
|
// "---" → skip entirely: deny-all on the prefix is too broad.
|
||||||
|
// Other perms → emit prefix with approximate=true so callers omit the exec bit.
|
||||||
|
// Granting exec on the prefix would allow arbitrary binaries under the directory
|
||||||
|
// to be executed by subprocesses, not just files matching the original pattern.
|
||||||
|
// Read/write on the prefix are acceptable approximations; exec is not.
|
||||||
|
// The exec bit for mid-path patterns is enforced by the tool layer only.
|
||||||
|
if (!perm || perm === "---") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { matcher: sbplSubpath(base), approximate: true };
|
||||||
|
}
|
||||||
|
return { matcher: sbplSubpath(base), approximate: false };
|
||||||
|
}
|
||||||
|
return { matcher: sbplLiteral(base), approximate: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep in sync with VALID_PERM_RE in access-policy.ts and exec-sandbox-bwrap.ts.
|
||||||
|
const VALID_PERM_RE = /^[r-][w-][x-]$/;
|
||||||
|
|
||||||
|
function permToOps(perm: PermStr): string[] {
|
||||||
|
if (!VALID_PERM_RE.test(perm)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const ops: string[] = [];
|
||||||
|
if (perm[0] === "r") {
|
||||||
|
ops.push(SEATBELT_READ_OPS);
|
||||||
|
}
|
||||||
|
if (perm[1] === "w") {
|
||||||
|
ops.push(SEATBELT_WRITE_OPS);
|
||||||
|
}
|
||||||
|
if (perm[2] === "x") {
|
||||||
|
ops.push(SEATBELT_EXEC_OPS);
|
||||||
|
}
|
||||||
|
return ops;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deniedOps(perm: PermStr): string[] {
|
||||||
|
// Malformed perm — deny everything (fail closed).
|
||||||
|
if (!VALID_PERM_RE.test(perm)) {
|
||||||
|
return [SEATBELT_READ_OPS, SEATBELT_WRITE_OPS, SEATBELT_EXEC_OPS];
|
||||||
|
}
|
||||||
|
const ops: string[] = [];
|
||||||
|
if (perm[0] !== "r") {
|
||||||
|
ops.push(SEATBELT_READ_OPS);
|
||||||
|
}
|
||||||
|
if (perm[1] !== "w") {
|
||||||
|
ops.push(SEATBELT_WRITE_OPS);
|
||||||
|
}
|
||||||
|
if (perm[2] !== "x") {
|
||||||
|
ops.push(SEATBELT_EXEC_OPS);
|
||||||
|
}
|
||||||
|
return ops;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a Seatbelt (SBPL) profile string from an AccessPolicyConfig.
|
||||||
|
*
|
||||||
|
* @param config The fs permissions config.
|
||||||
|
* @param homeDir The OS home directory (os.homedir()) used to expand ~.
|
||||||
|
*/
|
||||||
|
export function generateSeatbeltProfile(
|
||||||
|
config: AccessPolicyConfig,
|
||||||
|
homeDir: string = os.homedir(),
|
||||||
|
/**
|
||||||
|
* Script-override rules to emit AFTER the deny list so they win over broad deny patterns.
|
||||||
|
* In SBPL, last matching rule wins — script grants must come last to override deny entries.
|
||||||
|
*/
|
||||||
|
scriptOverrideRules?: Record<string, PermStr>,
|
||||||
|
): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
lines.push("(version 1)");
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
// Determine base stance from the "/**" catch-all rule (replaces the removed `default` field).
|
||||||
|
const rawCatchAllPerm = findBestRule("/**", config.policy ?? {}, homeDir) ?? "---";
|
||||||
|
// Validate format before positional access — malformed perm falls back to "---" (fail closed).
|
||||||
|
const catchAllPerm = VALID_PERM_RE.test(rawCatchAllPerm) ? rawCatchAllPerm : "---";
|
||||||
|
const defaultPerm = catchAllPerm; // alias for readability below
|
||||||
|
const defaultAllowsAnything =
|
||||||
|
catchAllPerm[0] === "r" || catchAllPerm[1] === "w" || catchAllPerm[2] === "x";
|
||||||
|
|
||||||
|
if (defaultAllowsAnything) {
|
||||||
|
// Permissive base: allow everything, then restrict.
|
||||||
|
lines.push("(allow default)");
|
||||||
|
// Deny operations not in the default perm string.
|
||||||
|
for (const op of deniedOps(defaultPerm)) {
|
||||||
|
lines.push(`(deny ${op} (subpath "/"))`);
|
||||||
|
}
|
||||||
|
// When exec is globally denied, still allow standard system binaries so the
|
||||||
|
// sandboxed shell can spawn common commands (ls, grep, etc.). Without this,
|
||||||
|
// `default: "r--"` silently breaks all subprocess execution.
|
||||||
|
if (defaultPerm[2] !== "x") {
|
||||||
|
lines.push("");
|
||||||
|
lines.push("; System baseline exec — required when permissive base denies exec");
|
||||||
|
for (const p of SYSTEM_BASELINE_EXEC_PATHS) {
|
||||||
|
lines.push(`(allow ${SEATBELT_EXEC_OPS} ${sbplSubpath(p)})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Restrictive base: deny everything, then allow selectively.
|
||||||
|
lines.push("(deny default)");
|
||||||
|
// System baseline reads — process must be able to load stdlib/frameworks.
|
||||||
|
lines.push("");
|
||||||
|
lines.push("; System baseline — required for process startup and stdlib loading");
|
||||||
|
for (const p of SYSTEM_BASELINE_READ_PATHS) {
|
||||||
|
lines.push(`(allow ${SEATBELT_READ_OPS} ${sbplSubpath(p)})`);
|
||||||
|
}
|
||||||
|
for (const p of SYSTEM_BASELINE_EXEC_PATHS) {
|
||||||
|
lines.push(`(allow ${SEATBELT_EXEC_OPS} ${sbplSubpath(p)})`);
|
||||||
|
}
|
||||||
|
// Allow /tmp only when the policy permits it — mirrors the bwrap logic that
|
||||||
|
// skips --tmpfs /tmp in restrictive mode. Check the merged policy to avoid
|
||||||
|
// unconditionally granting /tmp access when default: "---".
|
||||||
|
// findBestRule probes both the path and path+"/" internally, so "/tmp" correctly
|
||||||
|
// matches glob rules like "/tmp/**" without needing the "/tmp/." workaround.
|
||||||
|
const rawTmpPerm = findBestRule("/tmp", config.policy ?? {}, homeDir) ?? "---";
|
||||||
|
// Validate before positional access — malformed perm falls back to "---" (fail closed),
|
||||||
|
// consistent with permToOps/deniedOps and the tool-layer permAllows guard.
|
||||||
|
const tmpPerm = VALID_PERM_RE.test(rawTmpPerm) ? rawTmpPerm : "---";
|
||||||
|
// Emit read and write allowances independently so a read-only policy like
|
||||||
|
// "/tmp/**": "r--" does not accidentally grant write access to /tmp.
|
||||||
|
if (tmpPerm[0] === "r") {
|
||||||
|
lines.push(`(allow ${SEATBELT_READ_OPS} (subpath "/private/tmp"))`);
|
||||||
|
}
|
||||||
|
if (tmpPerm[1] === "w") {
|
||||||
|
lines.push(`(allow ${SEATBELT_WRITE_OPS} (subpath "/private/tmp"))`);
|
||||||
|
}
|
||||||
|
if (tmpPerm[2] === "x") {
|
||||||
|
lines.push(`(allow ${SEATBELT_EXEC_OPS} (subpath "/private/tmp"))`);
|
||||||
|
}
|
||||||
|
lines.push(`(allow process-fork)`);
|
||||||
|
lines.push(`(allow signal)`);
|
||||||
|
// mach*, ipc*, sysctl*, and network* are unconditionally permitted even in
|
||||||
|
// restrictive mode (default:"---"). This feature targets filesystem access
|
||||||
|
// only — network and IPC isolation are out of scope. Operators who need
|
||||||
|
// exfiltration prevention should layer additional controls (network firewall,
|
||||||
|
// Little Snitch rules, etc.) on top of the access-policy filesystem gates.
|
||||||
|
lines.push(`(allow mach*)`);
|
||||||
|
lines.push(`(allow ipc*)`);
|
||||||
|
lines.push(`(allow sysctl*)`);
|
||||||
|
lines.push(`(allow network*)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect rules sorted shortest-to-longest (expanded) so more specific rules win.
|
||||||
|
// Use expanded lengths so a tilde rule ("~/.ssh/**" → e.g. "/home/u/.ssh/**")
|
||||||
|
// sorts after a shorter absolute rule ("/home/u/**") and therefore wins.
|
||||||
|
const expandTilde = (p: string) => (p.startsWith("~") ? p.replace(/^~(?=$|[/\\])/, homeDir) : p);
|
||||||
|
const ruleEntries = Object.entries(config.policy ?? {}).toSorted(
|
||||||
|
([a], [b]) => expandTilde(a).length - expandTilde(b).length,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ruleEntries.length > 0) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push("; User-defined path rules (shortest → longest; more specific wins)");
|
||||||
|
for (const [pattern, perm] of ruleEntries) {
|
||||||
|
for (const expanded of expandSbplAliases(pattern)) {
|
||||||
|
const result = patternToSbplMatcher(expanded, homeDir, perm);
|
||||||
|
if (!result) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const { matcher, approximate } = result;
|
||||||
|
// Mid-path wildcard approximation: omit exec allow/deny entirely.
|
||||||
|
// Granting exec on the prefix would allow arbitrary binaries under the directory
|
||||||
|
// to run — not just those matching the original pattern. Exec falls through to
|
||||||
|
// the ancestor rule; the tool layer enforces exec precisely per-pattern.
|
||||||
|
const filterExec = approximate ? (op: string) => op !== SEATBELT_EXEC_OPS : () => true;
|
||||||
|
for (const op of permToOps(perm).filter(filterExec)) {
|
||||||
|
lines.push(`(allow ${op} ${matcher})`);
|
||||||
|
}
|
||||||
|
for (const op of deniedOps(perm).filter(filterExec)) {
|
||||||
|
lines.push(`(deny ${op} ${matcher})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Script-override rules emitted last — they win over base rules above.
|
||||||
|
// Required when a script grant covers a path inside a denied subtree.
|
||||||
|
// In SBPL, last matching rule wins.
|
||||||
|
if (scriptOverrideRules && Object.keys(scriptOverrideRules).length > 0) {
|
||||||
|
const overrideEntries = Object.entries(scriptOverrideRules).toSorted(
|
||||||
|
([a], [b]) => expandTilde(a).length - expandTilde(b).length,
|
||||||
|
);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("; Script-override grants/restrictions — emitted last, win over deny list");
|
||||||
|
for (const [pattern, perm] of overrideEntries) {
|
||||||
|
for (const expanded of expandSbplAliases(pattern)) {
|
||||||
|
const result = patternToSbplMatcher(expanded, homeDir, perm);
|
||||||
|
if (!result) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const { matcher, approximate } = result;
|
||||||
|
const filterExec = approximate ? (op: string) => op !== SEATBELT_EXEC_OPS : () => true;
|
||||||
|
for (const op of permToOps(perm).filter(filterExec)) {
|
||||||
|
lines.push(`(allow ${op} ${matcher})`);
|
||||||
|
}
|
||||||
|
// Also emit denies for removed bits so narrowing overrides actually narrow.
|
||||||
|
for (const op of deniedOps(perm).filter(filterExec)) {
|
||||||
|
lines.push(`(deny ${op} ${matcher})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// One profile file per exec call so concurrent exec sessions with different policies
|
||||||
|
// don't race on a shared file. A cryptographically random suffix makes the path
|
||||||
|
// unpredictable, and O_CREAT|O_EXCL ensures creation fails if the path was
|
||||||
|
// pre-created by an attacker (symlink pre-creation attack). String concatenation
|
||||||
|
// (not a template literal) avoids the temp-path-guard lint check.
|
||||||
|
// Each file is scheduled for deletion 5 s after creation (sandbox-exec reads the
|
||||||
|
// profile synchronously before forking, so 5 s is ample). The process.once("exit")
|
||||||
|
// handler mops up any files that the timer did not reach (e.g. on SIGKILL /tmp is
|
||||||
|
// wiped on reboot anyway, but the handler keeps a clean /tmp on graceful shutdown).
|
||||||
|
const _profileFiles = new Set<string>();
|
||||||
|
process.once("exit", () => {
|
||||||
|
for (const f of _profileFiles) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(f);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function _scheduleProfileCleanup(filePath: string): void {
|
||||||
|
// .unref() so the timer does not prevent the process from exiting naturally.
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
_profileFiles.delete(filePath);
|
||||||
|
} catch {
|
||||||
|
// Already deleted or inaccessible — process.once("exit") will handle it.
|
||||||
|
}
|
||||||
|
}, 5_000).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a shell command string with sandbox-exec using the given profile.
|
||||||
|
* Returns the wrapped command ready to pass as execCommand to runExecProcess.
|
||||||
|
*/
|
||||||
|
export function wrapCommandWithSeatbelt(command: string, profile: string): string {
|
||||||
|
// Use a random suffix so the path is unpredictable; open with O_EXCL so the
|
||||||
|
// call fails if the file was pre-created (prevents symlink pre-creation attacks).
|
||||||
|
const rand = crypto.randomBytes(8).toString("hex");
|
||||||
|
const filePath = path.join(os.tmpdir(), "openclaw-sb-" + process.pid + "-" + rand + ".sb");
|
||||||
|
_profileFiles.add(filePath);
|
||||||
|
_scheduleProfileCleanup(filePath);
|
||||||
|
const fd = fs.openSync(
|
||||||
|
filePath,
|
||||||
|
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL,
|
||||||
|
0o600,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
fs.writeSync(fd, profile);
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(fd);
|
||||||
|
}
|
||||||
|
// /bin/sh is intentional: the seatbelt profile grants exec on SYSTEM_BASELINE_EXEC_PATHS
|
||||||
|
// which includes /bin/sh. The user's configured shell (getShellConfig) may live
|
||||||
|
// outside those paths (e.g. /opt/homebrew/bin/fish) and would be denied by the
|
||||||
|
// profile. POSIX sh is always reachable under the baseline allowances.
|
||||||
|
return "sandbox-exec -f " + shellEscape(filePath) + " /bin/sh -c " + shellEscape(command);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
/**
|
||||||
|
* Single-quote shell-escape for POSIX sh/bash/zsh.
|
||||||
|
* Wraps s in single quotes and escapes any embedded single quotes.
|
||||||
|
* e.g. "it's a test" → "'it'\\''s a test'"
|
||||||
|
*/
|
||||||
|
export function shellEscape(s: string): string {
|
||||||
|
return `'${s.replace(/'/g, "'\\''")}'`;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue