From 003cf88d71afecabeaa26d4636981845a1f0e41b Mon Sep 17 00:00:00 2001 From: subrih Date: Fri, 13 Mar 2026 14:49:57 -0700 Subject: [PATCH 01/29] feat(access-policy): filesystem RWX enforcement via access-policy.json --- docs/tools/access-policy.md | 138 ++++ src/agents/bash-tools.exec-host-gateway.ts | 184 +++-- src/agents/bash-tools.exec-runtime.ts | 71 +- src/agents/bash-tools.exec-types.ts | 3 + src/agents/bash-tools.exec.ts | 2 + .../pi-tools.read.edit-permission.test.ts | 106 +++ src/agents/pi-tools.read.ts | 151 +++- src/agents/pi-tools.ts | 14 +- src/agents/tool-fs-policy.ts | 11 +- src/config/types.tools.ts | 28 + src/infra/access-policy-file.test.ts | 324 ++++++++ src/infra/access-policy-file.ts | 216 +++++ src/infra/access-policy.test.ts | 776 ++++++++++++++++++ src/infra/access-policy.ts | 418 ++++++++++ src/infra/exec-sandbox-bwrap.test.ts | 304 +++++++ src/infra/exec-sandbox-bwrap.ts | 225 +++++ src/infra/exec-sandbox-seatbelt.test.ts | 278 +++++++ src/infra/exec-sandbox-seatbelt.ts | 304 +++++++ src/infra/shell-escape.ts | 8 + 19 files changed, 3467 insertions(+), 94 deletions(-) create mode 100644 docs/tools/access-policy.md create mode 100644 src/agents/pi-tools.read.edit-permission.test.ts create mode 100644 src/infra/access-policy-file.test.ts create mode 100644 src/infra/access-policy-file.ts create mode 100644 src/infra/access-policy.test.ts create mode 100644 src/infra/access-policy.ts create mode 100644 src/infra/exec-sandbox-bwrap.test.ts create mode 100644 src/infra/exec-sandbox-bwrap.ts create mode 100644 src/infra/exec-sandbox-seatbelt.test.ts create mode 100644 src/infra/exec-sandbox-seatbelt.ts create mode 100644 src/infra/shell-escape.ts diff --git a/docs/tools/access-policy.md b/docs/tools/access-policy.md new file mode 100644 index 00000000000..121f2fd6372 --- /dev/null +++ b/docs/tools/access-policy.md @@ -0,0 +1,138 @@ +--- +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 (a warning is logged). No restart is required when the file changes; it is read fresh on each agent turn. + +## Format + +```json +{ + "version": 1, + "base": { + "rules": { + "/**": "r--", + "/tmp/": "rwx", + "~/": "rw-", + "~/dev/": "rwx" + }, + "deny": ["~/.ssh/", "~/.aws/", "~/.openclaw/credentials/"], + "default": "---" + }, + "agents": { + "myagent": { "rules": { "~/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). + +### 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. **`deny`** — always blocks, regardless of rules. Additive across layers — cannot be removed by agent overrides. +2. **`rules`** — longest matching glob wins (most specific pattern takes priority). +3. **`default`** — catch-all for unmatched paths. Omitting it is equivalent to `"---"`. + +## Layers + +``` +base → agents["*"] → agents["myagent"] +``` + +- **`base`** — applies to all agents. Deny entries here can never be overridden. +- **`agents["*"]`** — wildcard block applied to every agent after `base`, before the agent-specific block. Useful for org-wide rules. +- **`agents`** — per-agent overrides. Each agent block is merged on top of `base` (and `agents["*"]` if present): deny is additive, rules are shallow-merged (agent wins on collision), default is agent-wins if set. + +## 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 **enforcement is disabled** until the file is fixed: + +``` +[access-policy] Cannot parse ~/.openclaw/access-policy.json: ... +[access-policy] Permissions enforcement is DISABLED until the file is fixed. +``` + +Common mistakes caught by the validator: + +- `rules`, `deny`, or `default` placed at the top level instead of under `base` +- Permission strings that are not exactly 3 characters (`"rwx"`, `"r--"`, `"---"`, etc.) +- Empty deny entries + +### 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] rules["~/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. + +## 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, place it in the deny list (no read access). + +**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 diff --git a/src/agents/bash-tools.exec-host-gateway.ts b/src/agents/bash-tools.exec-host-gateway.ts index 149a4785dd5..33988561b0c 100644 --- a/src/agents/bash-tools.exec-host-gateway.ts +++ b/src/agents/bash-tools.exec-host-gateway.ts @@ -1,4 +1,6 @@ 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 { addAllowlistEntry, 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 { logInfo } from "../logger.js"; import { markBackgrounded, tail } from "./bash-process-registry.js"; +import { sendExecApprovalFollowup } from "./bash-tools.exec-approval-followup.js"; import { buildExecApprovalRequesterContext, buildExecApprovalTurnSourceContext, registerExecApprovalRequestForHostOrThrow, } from "./bash-tools.exec-approval-request.js"; import { - buildDefaultExecApprovalRequestArgs, - buildExecApprovalFollowupTarget, - buildExecApprovalPendingToolResult, - createExecApprovalDecisionState, createAndRegisterDefaultExecApprovalRequest, + resolveBaseExecApprovalDecision, resolveApprovalDecisionOrUndefined, resolveExecHostApprovalContext, - sendExecApprovalFollowupResult, } from "./bash-tools.exec-host-shared.js"; import { + buildApprovalPendingMessage, DEFAULT_NOTIFY_TAIL_CHARS, createApprovalSlug, normalizeNotifyOutput, @@ -60,6 +60,7 @@ export type ProcessGatewayAllowlistParams = { maxOutput: number; pendingMaxOutput: number; trustedSafeBinDirs?: ReadonlySet; + permissions?: AccessPolicyConfig; }; export type ProcessGatewayAllowlistResult = { @@ -141,28 +142,6 @@ export async function processGatewayAllowlist( } 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 { approvalId, approvalSlug, @@ -173,46 +152,57 @@ export async function processGatewayAllowlist( sentApproverDms, unavailableReason, } = await createAndRegisterDefaultExecApprovalRequest({ - ...requestArgs, - register: registerGatewayApproval, + warnings: params.warnings, + 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 effectiveTimeout = 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 () => { const decision = await resolveApprovalDecisionOrUndefined({ approvalId, preResolvedDecision, onFailure: () => - void sendExecApprovalFollowupResult( - followupTarget, - `Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`, - ), + void sendExecApprovalFollowup({ + approvalId, + 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) { return; } - const { - baseDecision, - approvedByAsk: initialApprovedByAsk, - deniedReason: initialDeniedReason, - } = createExecApprovalDecisionState({ + const baseDecision = resolveBaseExecApprovalDecision({ decision, askFallback, obfuscationDetected: obfuscation.detected, }); - let approvedByAsk = initialApprovedByAsk; - let deniedReason = initialDeniedReason; + let approvedByAsk = baseDecision.approvedByAsk; + let deniedReason = baseDecision.deniedReason; if (baseDecision.timedOut && askFallback === "allowlist") { if (!analysisOk || !allowlistSatisfied) { @@ -244,10 +234,15 @@ export async function processGatewayAllowlist( } if (deniedReason) { - await sendExecApprovalFollowupResult( - followupTarget, - `Exec denied (gateway id=${approvalId}, ${deniedReason}): ${params.command}`, - ); + await sendExecApprovalFollowup({ + approvalId, + 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; } @@ -271,12 +266,18 @@ export async function processGatewayAllowlist( scopeKey: params.scopeKey, sessionKey: params.notifySessionKey, timeoutSec: effectiveTimeout, + permissions: params.permissions, }); } catch { - await sendExecApprovalFollowupResult( - followupTarget, - `Exec denied (gateway id=${approvalId}, spawn-failed): ${params.command}`, - ); + await sendExecApprovalFollowup({ + approvalId, + 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; } @@ -290,25 +291,70 @@ export async function processGatewayAllowlist( 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})`; - 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 { - pendingResult: buildExecApprovalPendingToolResult({ - host: "gateway", - command: params.command, - cwd: params.workdir, - warningText, - approvalId, - approvalSlug, - expiresAtMs, - initiatingSurface, - sentApproverDms, - unavailableReason, - }), + pendingResult: { + content: [ + { + type: "text", + text: + unavailableReason !== null + ? (buildExecApprovalUnavailableReplyPayload({ + warningText, + reason: unavailableReason, + channelLabel: initiatingSurface.channelLabel, + 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)) { throw new Error("exec denied: allowlist miss"); } diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 5c3301414b9..792fbd553ed 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -1,3 +1,4 @@ +import os from "node:os"; import path from "node:path"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; @@ -16,6 +17,13 @@ export { normalizeExecHost, normalizeExecSecurity, } from "../infra/exec-approvals.js"; +import type { AccessPolicyConfig } from "../config/types.tools.js"; +import { applyScriptPolicyOverride, resolveArgv0 } 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 type { ManagedRun } from "../process/supervisor/index.js"; import { getProcessSupervisor } from "../process/supervisor/index.js"; @@ -286,6 +294,30 @@ export function emitExecSystemEvent( 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.", + ); +} + +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.", + ); +} + export async function runExecProcess(opts: { command: string; // Execute this instead of `command` (which is kept for display/session/logging). @@ -305,10 +337,47 @@ export async function runExecProcess(opts: { sessionKey?: string; timeoutSec: number | null; onUpdate?: (partialResult: AgentToolResult) => void; + /** When set, wrap the exec command with OS-level path enforcement. */ + permissions?: AccessPolicyConfig; }): Promise { const startedAt = Date.now(); const sessionId = createSessionSlug(); - const execCommand = opts.execCommand ?? opts.command; + const baseCommand = opts.execCommand ?? opts.command; + + // Apply OS-level path enforcement when access-policy permissions are configured. + let execCommand = baseCommand; + if (opts.permissions && !opts.sandbox) { + const argv0 = resolveArgv0(baseCommand, opts.workdir) ?? baseCommand; + const { + policy: effectivePermissions, + overrideRules, + hashMismatch, + } = applyScriptPolicyOverride(opts.permissions, argv0); + if (hashMismatch) { + throw new Error(`exec denied: script hash mismatch for ${argv0}`); + } + 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 shellRuntimeEnv: Record = { ...opts.env, diff --git a/src/agents/bash-tools.exec-types.ts b/src/agents/bash-tools.exec-types.ts index 7236fdaaf47..efd6da40ccd 100644 --- a/src/agents/bash-tools.exec-types.ts +++ b/src/agents/bash-tools.exec-types.ts @@ -1,8 +1,11 @@ +import type { AccessPolicyConfig } from "../config/types.tools.js"; import type { ExecAsk, ExecHost, ExecSecurity } from "../infra/exec-approvals.js"; import type { SafeBinProfileFixture } from "../infra/exec-safe-bin-policy.js"; import type { BashSandboxConfig } from "./bash-tools.shared.js"; export type ExecToolDefaults = { + /** Path-scoped RWX permissions — x bit gates binary execution. */ + permissions?: AccessPolicyConfig; host?: ExecHost; security?: ExecSecurity; ask?: ExecAsk; diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 8a0bd30907a..73424f44a7d 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -449,6 +449,7 @@ export function createExecTool( maxOutput, pendingMaxOutput, trustedSafeBinDirs, + permissions: defaults?.permissions, }); if (gatewayResult.pendingResult) { return gatewayResult.pendingResult; @@ -486,6 +487,7 @@ export function createExecTool( sessionKey: notifySessionKey, timeoutSec: effectiveTimeout, onUpdate, + permissions: defaults?.permissions, }); let yielded = false; diff --git a/src/agents/pi-tools.read.edit-permission.test.ts b/src/agents/pi-tools.read.edit-permission.test.ts new file mode 100644 index 00000000000..b98b8411836 --- /dev/null +++ b/src/agents/pi-tools.read.edit-permission.test.ts @@ -0,0 +1,106 @@ +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; + writeFile: (absolutePath: string, content: string) => Promise; + access: (absolutePath: string) => Promise; +}; + +const mocks = vi.hoisted(() => ({ + operations: undefined as CapturedEditOperations | undefined, +})); + +vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => { + const actual = await importOriginal(); + 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 = { + default: "---", + rules: { [`${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 = { + default: "---", + rules: { [`${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 = { + default: "---", + rules: { [`${tmpDir}/**`]: "r--" }, + }; + createHostWorkspaceEditTool(tmpDir, { workspaceOnly: false, permissions }); + expect(mocks.operations).toBeDefined(); + + await expect(mocks.operations!.writeFile(filePath, "new")).rejects.toThrow( + /Permission denied.*write/, + ); + }, + ); +}); diff --git a/src/agents/pi-tools.read.ts b/src/agents/pi-tools.read.ts index 5ea48b01fa1..3b438a9c21c 100644 --- a/src/agents/pi-tools.read.ts +++ b/src/agents/pi-tools.read.ts @@ -1,8 +1,11 @@ +import { realpathSync } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; 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 { appendFileWithinRoot, SafeOpenError, @@ -47,9 +50,44 @@ const ADAPTIVE_READ_CONTEXT_SHARE = 0.2; const CHARS_PER_TOKEN_ESTIMATE = 4; 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 = { modelContextWindowTokens?: number; imageSanitization?: ImageSanitizationLimits; + permissions?: AccessPolicyConfig; + /** Workspace root used to resolve relative paths for permission checks. */ + workspaceRoot?: string; }; type ReadTruncationDetails = { @@ -621,14 +659,20 @@ export function createSandboxedEditTool(params: SandboxToolParams) { 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, { operations: createHostWriteOperations(root, options), }) as unknown as AnyAgentTool; 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, { operations: createHostEditOperations(root, options), }) as unknown as AnyAgentTool; @@ -649,6 +693,18 @@ export function createOpenClawReadTool( normalized ?? (params && typeof params === "object" ? (params as Record) : undefined); assertRequiredParams(record, CLAUDE_PARAM_GROUPS.read, base.name); + const filePath = typeof record?.path === "string" ? String(record.path) : ""; + // Path-level permission check (when tools.fs.permissions is configured). + if (options?.permissions && filePath !== "") { + 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.`); + } + } const result = await executeReadWithAdaptivePaging({ base, toolCallId, @@ -656,7 +712,6 @@ export function createOpenClawReadTool( signal, maxBytes: resolveAdaptiveReadMaxBytes(options), }); - const filePath = typeof record?.path === "string" ? String(record.path) : ""; const strippedDetailsResult = stripReadTruncationContentDetails(result); const normalizedResult = await normalizeReadImageResult(strippedDetailsResult, filePath); return sanitizeToolResultImages( @@ -718,32 +773,58 @@ async function writeHostFile(absolutePath: string, content: string) { 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 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) { // When workspaceOnly is false, allow writes anywhere on the host return { mkdir: async (dir: string) => { - const resolved = path.resolve(dir); + const resolved = assertWritePermitted(dir); await fs.mkdir(resolved, { recursive: true }); }, - writeFile: writeHostFile, + writeFile: async (absolutePath: string, content: string) => { + const resolved = assertWritePermitted(absolutePath); + await writeHostFile(resolved, content); + }, } as const; } // When workspaceOnly is true, enforce workspace boundary return { mkdir: async (dir: string) => { - const relative = toRelativeWorkspacePath(root, dir, { allowRoot: true }); - const resolved = relative ? path.resolve(root, relative) : path.resolve(root); - await assertSandboxPath({ filePath: resolved, cwd: root, root }); - await fs.mkdir(resolved, { recursive: true }); + const resolved = assertWritePermitted(dir); + const relative = toRelativeWorkspacePath(resolvedRoot, resolved, { allowRoot: true }); + const absResolved = relative + ? 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) => { - const relative = toRelativeWorkspacePath(root, absolutePath); + const resolved = assertWritePermitted(absolutePath); + const relative = toRelativeWorkspacePath(resolvedRoot, resolved); await writeFileWithinRoot({ - rootDir: root, + rootDir: resolvedRoot, relativePath: relative, data: content, mkdir: true, @@ -752,19 +833,44 @@ function createHostWriteOperations(root: string, options?: { workspaceOnly?: boo } as const; } -function createHostEditOperations(root: string, options?: { workspaceOnly?: boolean }) { +function createHostEditOperations( + root: string, + options?: { workspaceOnly?: boolean; permissions?: AccessPolicyConfig }, +) { 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; + } if (!workspaceOnly) { // When workspaceOnly is false, allow edits anywhere on the host return { readFile: async (absolutePath: string) => { - const resolved = path.resolve(absolutePath); + const resolved = assertEditPermitted(absolutePath); 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) => { - const resolved = path.resolve(absolutePath); + const resolved = assertEditPermitted(absolutePath); await fs.access(resolved); }, } as const; @@ -773,26 +879,29 @@ function createHostEditOperations(root: string, options?: { workspaceOnly?: bool // When workspaceOnly is true, enforce workspace boundary return { readFile: async (absolutePath: string) => { - const relative = toRelativeWorkspacePath(root, absolutePath); + const resolved = assertEditPermitted(absolutePath); + const relative = toRelativeWorkspacePath(resolvedRoot, resolved); const safeRead = await readFileWithinRoot({ - rootDir: root, + rootDir: resolvedRoot, relativePath: relative, }); return safeRead.buffer; }, writeFile: async (absolutePath: string, content: string) => { - const relative = toRelativeWorkspacePath(root, absolutePath); + const resolved = assertEditPermitted(absolutePath); + const relative = toRelativeWorkspacePath(resolvedRoot, resolved); await writeFileWithinRoot({ - rootDir: root, + rootDir: resolvedRoot, relativePath: relative, data: content, mkdir: true, }); }, access: async (absolutePath: string) => { + const resolved = assertEditPermitted(absolutePath); let relative: string; try { - relative = toRelativeWorkspacePath(root, absolutePath); + relative = toRelativeWorkspacePath(resolvedRoot, resolved); } catch { // Path escapes workspace root. Don't throw here – the upstream // library replaces any `access` error with a misleading "File not diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 6536e9dfbb5..dd8fbf58e1d 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -339,6 +339,7 @@ export function createOpenClawCodingTools(options?: { const fsConfig = resolveToolFsConfig({ cfg: options?.config, agentId }); const fsPolicy = createToolFsPolicy({ workspaceOnly: isMemoryFlushRun || fsConfig.workspaceOnly, + permissions: fsConfig.permissions, }); const sandboxRoot = sandbox?.workspaceDir; const sandboxFsBridge = sandbox?.fsBridge; @@ -384,6 +385,8 @@ export function createOpenClawCodingTools(options?: { const wrapped = createOpenClawReadTool(freshReadTool, { modelContextWindowTokens: options?.modelContextWindowTokens, imageSanitization, + permissions: fsPolicy.permissions, + workspaceRoot, }); return [workspaceOnly ? wrapToolWorkspaceRootGuard(wrapped, workspaceRoot) : wrapped]; } @@ -394,14 +397,20 @@ export function createOpenClawCodingTools(options?: { if (sandboxRoot) { return []; } - const wrapped = createHostWorkspaceWriteTool(workspaceRoot, { workspaceOnly }); + const wrapped = createHostWorkspaceWriteTool(workspaceRoot, { + workspaceOnly, + permissions: fsPolicy.permissions, + }); return [workspaceOnly ? wrapToolWorkspaceRootGuard(wrapped, workspaceRoot) : wrapped]; } if (tool.name === "edit") { if (sandboxRoot) { return []; } - const wrapped = createHostWorkspaceEditTool(workspaceRoot, { workspaceOnly }); + const wrapped = createHostWorkspaceEditTool(workspaceRoot, { + workspaceOnly, + permissions: fsPolicy.permissions, + }); return [workspaceOnly ? wrapToolWorkspaceRootGuard(wrapped, workspaceRoot) : wrapped]; } return [tool]; @@ -409,6 +418,7 @@ export function createOpenClawCodingTools(options?: { const { cleanupMs: cleanupMsOverride, ...execDefaults } = options?.exec ?? {}; const execTool = createExecTool({ ...execDefaults, + permissions: options?.exec?.permissions ?? fsPolicy.permissions, host: options?.exec?.host ?? execConfig.host, security: options?.exec?.security ?? execConfig.security, ask: options?.exec?.ask ?? execConfig.ask, diff --git a/src/agents/tool-fs-policy.ts b/src/agents/tool-fs-policy.ts index 59d04c56e67..91522719505 100644 --- a/src/agents/tool-fs-policy.ts +++ b/src/agents/tool-fs-policy.ts @@ -1,18 +1,26 @@ 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"; export type ToolFsPolicy = { workspaceOnly: boolean; + permissions?: AccessPolicyConfig; }; -export function createToolFsPolicy(params: { workspaceOnly?: boolean }): ToolFsPolicy { +export function createToolFsPolicy(params: { + workspaceOnly?: boolean; + permissions?: AccessPolicyConfig; +}): ToolFsPolicy { return { workspaceOnly: params.workspaceOnly === true, + permissions: params.permissions, }; } export function resolveToolFsConfig(params: { cfg?: OpenClawConfig; agentId?: string }): { workspaceOnly?: boolean; + permissions?: AccessPolicyConfig; } { const cfg = params.cfg; 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; return { workspaceOnly: agentFs?.workspaceOnly ?? globalFs?.workspaceOnly, + permissions: resolveAccessPolicyForAgent(params.agentId), }; } diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 43d39285b57..1a529ae8e1a 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -3,6 +3,34 @@ import type { SafeBinProfileFixture } from "../infra/exec-safe-bin-policy.js"; import type { AgentElevatedAllowFromConfig, SessionSendPolicyAction } from "./types.base.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 = { + /** Restrict/expand rules for this script. Merged over the base policy rules. */ + rules?: Record; + /** Additional deny patterns added when this script runs (additive). */ + deny?: string[]; + /** 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. + */ +export type AccessPolicyConfig = { + /** Fallback permission when no rule matches. Defaults to `"rwx"` (fully open). */ + default?: PermStr; + /** Glob-pattern rules: path → permission string. Longest prefix wins. */ + rules?: Record; + /** Patterns that are always denied regardless of rules (additive across merges). */ + deny?: string[]; + /** Per-script argv0 policy overrides keyed by resolved binary path. */ + scripts?: Record; +}; + export type MediaUnderstandingScopeMatch = { channel?: string; chatType?: ChatType; diff --git a/src/infra/access-policy-file.test.ts b/src/infra/access-policy-file.test.ts new file mode 100644 index 00000000000..e5e8d4ea8ff --- /dev/null +++ b/src/infra/access-policy-file.test.ts @@ -0,0 +1,324 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + _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 +// "/.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(); +}); + +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 = { default: "r--" }; + expect(mergeAccessPolicy(base, undefined)).toEqual(base); + }); + + it("returns override when base is undefined", () => { + const override = { default: "rwx" }; + expect(mergeAccessPolicy(undefined, override)).toEqual(override); + }); + + it("override default wins", () => { + const result = mergeAccessPolicy({ default: "r--" }, { default: "rwx" }); + expect(result?.default).toBe("rwx"); + }); + + it("base default survives when override has no default", () => { + const result = mergeAccessPolicy({ default: "r--" }, { rules: { "/**": "r-x" } }); + expect(result?.default).toBe("r--"); + }); + + it("deny arrays are concatenated — base denies cannot be removed", () => { + const result = mergeAccessPolicy( + { deny: ["~/.ssh/**", "~/.aws/**"] }, + { deny: ["~/.gnupg/**"] }, + ); + expect(result?.deny).toEqual(["~/.ssh/**", "~/.aws/**", "~/.gnupg/**"]); + }); + + it("override deny extends base deny", () => { + const result = mergeAccessPolicy({ deny: ["~/.ssh/**"] }, { deny: ["~/.env"] }); + expect(result?.deny).toContain("~/.ssh/**"); + expect(result?.deny).toContain("~/.env"); + }); + + it("rules are shallow-merged, override key wins on collision", () => { + const result = mergeAccessPolicy( + { rules: { "/**": "r--", "~/**": "rw-" } }, + { rules: { "~/**": "rwx", "~/dev/**": "rwx" } }, + ); + expect(result?.rules?.["/**"]).toBe("r--"); // base survives + expect(result?.rules?.["~/**"]).toBe("rwx"); // override wins + expect(result?.rules?.["~/dev/**"]).toBe("rwx"); // override adds + }); + + it("omits empty deny/rules from result", () => { + const result = mergeAccessPolicy({ default: "r--" }, { default: "rwx" }); + expect(result?.deny).toBeUndefined(); + expect(result?.rules).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// loadAccessPolicyFile +// --------------------------------------------------------------------------- + +describe("loadAccessPolicyFile", () => { + it("returns null when file does not exist", () => { + expect(loadAccessPolicyFile()).toBeNull(); + }); + + it("returns null 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).toBeNull(); + expect(spy).toHaveBeenCalledWith(expect.stringContaining("Cannot parse")); + expect(spy).toHaveBeenCalledWith(expect.stringContaining("DISABLED")); + spy.mockRestore(); + }); + + it("returns null and logs error when version is not 1", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + writeFile({ version: 2, base: {} }); + const result = loadAccessPolicyFile(); + expect(result).toBeNull(); + expect(spy).toHaveBeenCalledWith(expect.stringContaining("unsupported version")); + expect(spy).toHaveBeenCalledWith(expect.stringContaining("DISABLED")); + spy.mockRestore(); + }); + + it("returns null and logs error when base is not an object", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + writeFile({ version: 1, base: ["r--"] }); + const result = loadAccessPolicyFile(); + expect(result).toBeNull(); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('"base" must be an object')); + spy.mockRestore(); + }); + + it("returns null 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).toBeNull(); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('"agents" must be an object')); + spy.mockRestore(); + }); + + it("returns null and logs error when a top-level key like 'rules' is misplaced", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + // Common mistake: rules at top level instead of under base + writeFile({ version: 1, rules: { "/**": "r--" } }); + const result = loadAccessPolicyFile(); + expect(result).toBeNull(); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('unexpected top-level key "rules"')); + spy.mockRestore(); + }); + + it("returns null and logs error when 'deny' is misplaced at top level", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + writeFile({ version: 1, deny: ["~/.ssh/**"] }); + const result = loadAccessPolicyFile(); + expect(result).toBeNull(); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('unexpected top-level key "deny"')); + spy.mockRestore(); + }); + + it("returns null 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).toBeNull(); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('agents["subri"] must be an object')); + spy.mockRestore(); + }); + + it("returns parsed file when valid", () => { + const content: AccessPolicyFile = { + version: 1, + base: { default: "r--", deny: ["~/.ssh/**"] }, + agents: { subri: { rules: { "~/dev/**": "rwx" } } }, + }; + writeFile(content); + const result = loadAccessPolicyFile(); + expect(result?.version).toBe(1); + expect(result?.base?.default).toBe("r--"); + expect(result?.agents?.subri?.rules?.["~/dev/**"]).toBe("rwx"); + }); +}); + +// --------------------------------------------------------------------------- +// 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, base: { default: "r--" } }); + resolveAccessPolicyForAgent("subri"); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it("does not warn when config file exists but is broken (error already logged)", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + writeFile({ version: 1, rules: { "/**": "r--" } }); // misplaced key — triggers error + resolveAccessPolicyForAgent("subri"); + expect(warnSpy).not.toHaveBeenCalled(); + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("DISABLED")); + warnSpy.mockRestore(); + errSpy.mockRestore(); + }); + + it("returns base when no agent block exists", () => { + writeFile({ + version: 1, + base: { default: "r--", deny: ["~/.ssh/**"] }, + }); + const result = resolveAccessPolicyForAgent("subri"); + expect(result?.default).toBe("r--"); + expect(result?.deny).toContain("~/.ssh/**"); + }); + + it("merges base + named agent", () => { + writeFile({ + version: 1, + base: { default: "---", deny: ["~/.ssh/**"], rules: { "/**": "r--" } }, + agents: { subri: { rules: { "~/dev/**": "rwx" }, default: "r--" } }, + }); + const result = resolveAccessPolicyForAgent("subri"); + // default: agent wins + expect(result?.default).toBe("r--"); + // deny: additive + expect(result?.deny).toContain("~/.ssh/**"); + // rules: merged + expect(result?.rules?.["/**"]).toBe("r--"); + expect(result?.rules?.["~/dev/**"]).toBe("rwx"); + }); + + it("wildcard agent applies before named agent", () => { + writeFile({ + version: 1, + base: { default: "---" }, + agents: { + "*": { rules: { "/usr/bin/**": "r-x" } }, + subri: { rules: { "~/dev/**": "rwx" } }, + }, + }); + const result = resolveAccessPolicyForAgent("subri"); + expect(result?.rules?.["/usr/bin/**"]).toBe("r-x"); // from wildcard + expect(result?.rules?.["~/dev/**"]).toBe("rwx"); // from named agent + expect(result?.default).toBe("---"); // from base + }); + + it("wildcard applies even when no named agent block", () => { + writeFile({ + version: 1, + base: { default: "---" }, + agents: { "*": { deny: ["~/.ssh/**"] } }, + }); + const result = resolveAccessPolicyForAgent("other-agent"); + expect(result?.deny).toContain("~/.ssh/**"); + }); + + it("wildcard key itself is not treated as a named agent", () => { + writeFile({ + version: 1, + agents: { "*": { deny: ["~/.ssh/**"] } }, + }); + // Requesting agentId "*" should not double-apply wildcard as named + const result = resolveAccessPolicyForAgent("*"); + expect(result?.deny).toEqual(["~/.ssh/**"]); + }); + + 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, + base: { rules: { "/**": "BAD" } }, + }); + resolveAccessPolicyForAgent("subri"); + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("BAD")); + expect(warnSpy).not.toHaveBeenCalled(); + errSpy.mockRestore(); + warnSpy.mockRestore(); + }); + + it("named agent deny extends global deny — global deny cannot be removed", () => { + writeFile({ + version: 1, + base: { deny: ["~/.ssh/**"] }, + agents: { paranoid: { deny: ["~/.aws/**"] } }, + }); + const result = resolveAccessPolicyForAgent("paranoid"); + expect(result?.deny).toContain("~/.ssh/**"); + expect(result?.deny).toContain("~/.aws/**"); + }); +}); diff --git a/src/infra/access-policy-file.ts b/src/infra/access-policy-file.ts new file mode 100644 index 00000000000..87765996518 --- /dev/null +++ b/src/infra/access-policy-file.ts @@ -0,0 +1,216 @@ +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; + base?: AccessPolicyConfig; + /** + * Per-agent overrides keyed by agent ID, or "*" for a wildcard that applies + * to every agent before the named agent block is merged in. + * + * Merge order (each layer wins over the previous): + * base → agents["*"] → agents[agentId] + * + * Within each layer: + * - deny: additive (concat) — a base deny entry can never be removed by an override + * - rules: shallow-merge, override key wins on collision + * - default: override wins if set + */ + agents?: Record; +}; + +// 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. + * - deny: additive (cannot remove a base deny) + * - rules: shallow merge, override key wins + * - default: override wins if set + */ +export function mergeAccessPolicy( + base: AccessPolicyConfig | undefined, + override: AccessPolicyConfig | undefined, +): AccessPolicyConfig | undefined { + if (!base && !override) { + return undefined; + } + if (!base) { + return override; + } + if (!override) { + return base; + } + const deny = [...(base.deny ?? []), ...(override.deny ?? [])]; + const rules = { ...base.rules, ...override.rules }; + // scripts: shallow merge — override key wins (same semantics as rules) + const scripts = { ...base.scripts, ...override.scripts }; + const result: AccessPolicyConfig = {}; + if (deny.length > 0) { + result.deny = deny; + } + if (Object.keys(rules).length > 0) { + result.rules = rules; + } + if (Object.keys(scripts).length > 0) { + result.scripts = scripts; + } + if (override.default !== undefined) { + result.default = override.default; + } else if (base.default !== undefined) { + result.default = base.default; + } + 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; + + if ( + p["base"] !== undefined && + (typeof p["base"] !== "object" || p["base"] === null || Array.isArray(p["base"])) + ) { + errors.push(`${filePath}: "base" must be an object`); + } + 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)) { + if (typeof block !== "object" || block === null || Array.isArray(block)) { + errors.push(`${filePath}: agents["${agentId}"] must be an object`); + } + } + } + } + + // Catch common mistake: AccessPolicyConfig fields accidentally at top level + // (e.g. user puts "rules" or "deny" directly instead of under "base"). + for (const key of ["rules", "deny", "default", "scripts"] as const) { + if (p[key] !== undefined) { + errors.push( + `${filePath}: unexpected top-level key "${key}" — did you mean to put it under "base"?`, + ); + } + } + + return errors; +} + +/** + * Read and parse the sidecar file. Returns null if the file does not exist. + * Logs a clear error (and returns null) if the file is present but broken. + */ +export function loadAccessPolicyFile(): AccessPolicyFile | null { + const filePath = resolveAccessPolicyPath(); + if (!fs.existsSync(filePath)) { + return 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] Permissions enforcement is DISABLED until the file is fixed.`); + return null; + } + + 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] Permissions enforcement is DISABLED until the file is fixed.`); + return null; + } + + const p = parsed as Record; + if (p["version"] !== 1) { + console.error( + `[access-policy] ${filePath}: unsupported version ${JSON.stringify(p["version"])} (expected 1).`, + ); + console.error(`[access-policy] Permissions enforcement is DISABLED until the file is fixed.`); + return null; + } + + // 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] Permissions enforcement is DISABLED until the file is fixed.`); + return null; + } + + 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(); + +/** 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: base → 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). + */ +export function resolveAccessPolicyForAgent(agentId?: string): AccessPolicyConfig | undefined { + const file = loadAccessPolicyFile(); + 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; + } + + let merged = mergeAccessPolicy(undefined, file.base); + const wildcard = file.agents?.["*"]; + if (wildcard) { + merged = mergeAccessPolicy(merged, wildcard); + } + 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}`); + } + console.error(`[access-policy] Bad permission strings are treated as "---" (deny all).`); + } + } + + return merged; +} diff --git a/src/infra/access-policy.test.ts b/src/infra/access-policy.test.ts new file mode 100644 index 00000000000..e8dd4f75886 --- /dev/null +++ b/src/infra/access-policy.test.ts @@ -0,0 +1,776 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { AccessPolicyConfig } from "../config/types.tools.js"; +import { + _resetAutoExpandedWarnedForTest, + applyScriptPolicyOverride, + checkAccessPolicy, + findBestRule, + resolveArgv0, + validateAccessPolicyConfig, +} from "./access-policy.js"; + +// Use os.homedir() directly — consistent with how access-policy expands ~. +// Do NOT use expandHomePrefix() here: OPENCLAW_HOME in the test environment +// would redirect ~ to the OpenClaw config dir, which is not what ~ means +// in filesystem permission patterns. +const HOME = os.homedir(); + +// --------------------------------------------------------------------------- +// validateAccessPolicyConfig +// --------------------------------------------------------------------------- + +describe("validateAccessPolicyConfig", () => { + beforeEach(() => { + _resetAutoExpandedWarnedForTest(); + }); + + it("returns no errors for a valid config", () => { + expect( + validateAccessPolicyConfig({ + rules: { "/**": "r--", [`${HOME}/**`]: "rwx" }, + deny: [`${HOME}/.ssh/**`], + default: "---", + }), + ).toEqual([]); + }); + + it("returns no errors for an empty config", () => { + expect(validateAccessPolicyConfig({})).toEqual([]); + }); + + it("rejects invalid default perm string — too short", () => { + const errs = validateAccessPolicyConfig({ default: "rw" }); + expect(errs).toHaveLength(1); + expect(errs[0]).toMatch(/default/); + }); + + it("rejects invalid default perm string — too long", () => { + const errs = validateAccessPolicyConfig({ default: "rwxr" }); + expect(errs).toHaveLength(1); + expect(errs[0]).toMatch(/default/); + }); + + it("rejects invalid default perm string — wrong chars", () => { + const errs = validateAccessPolicyConfig({ default: "rq-" }); + expect(errs).toHaveLength(1); + expect(errs[0]).toMatch(/default/); + }); + + it("rejects invalid rule perm value", () => { + const errs = validateAccessPolicyConfig({ rules: { "/**": "rx" } }); + expect(errs).toHaveLength(1); + expect(errs[0]).toMatch(/rules/); + }); + + it("rejects rule perm value with wrong char in w position", () => { + const errs = validateAccessPolicyConfig({ rules: { "/**": "r1x" } }); + expect(errs).toHaveLength(1); + expect(errs[0]).toMatch(/rules/); + }); + + it("reports multiple errors when both default and a rule are invalid", () => { + const errs = validateAccessPolicyConfig({ + default: "bad", + rules: { "/**": "xyz" }, + }); + expect(errs.length).toBeGreaterThanOrEqual(2); + }); + + it("rejects empty deny entry", () => { + const errs = validateAccessPolicyConfig({ deny: [""] }); + expect(errs).toHaveLength(1); + expect(errs[0]).toMatch(/deny/); + }); + + it("auto-expands a bare directory path in deny[] to /**", () => { + const dir = os.tmpdir(); + const config: AccessPolicyConfig = { deny: [dir] }; + const errs = validateAccessPolicyConfig(config); + expect(errs).toHaveLength(1); + expect(errs[0]).toMatch(/auto-expanded/); + expect(config.deny?.[0]).toBe(`${dir}/**`); + }); + + it("accepts valid 'rwx' and '---' perm strings", () => { + expect(validateAccessPolicyConfig({ default: "rwx" })).toEqual([]); + expect(validateAccessPolicyConfig({ default: "---" })).toEqual([]); + expect(validateAccessPolicyConfig({ default: "r-x" })).toEqual([]); + }); + + it("auto-expands a bare path that points to a real directory", () => { + // os.tmpdir() is guaranteed to exist and be a directory on every platform. + const dir = os.tmpdir(); + const config = { rules: { [dir]: "r--" as const } }; + const errs = validateAccessPolicyConfig(config); + expect(errs).toHaveLength(1); + expect(errs[0]).toMatch(/auto-expanded/); + // Rule should be rewritten in place with /** suffix. + expect(config.rules[`${dir}/**`]).toBe("r--"); + expect(config.rules[dir]).toBeUndefined(); + }); + + it("auto-expand does not overwrite an existing explicit glob rule", () => { + // {"/tmp": "rwx", "/tmp/**": "---"} — bare /tmp should expand but must NOT + // clobber the explicit /tmp/** rule. Without the guard, access would widen + // from "---" to "rwx" — a security regression. + const dir = os.tmpdir(); + const config: AccessPolicyConfig = { + rules: { [dir]: "rwx", [`${dir}/**`]: "---" }, + }; + validateAccessPolicyConfig(config); + // Explicit "---" rule must be preserved. + expect(config.rules?.[`${dir}/**`]).toBe("---"); + }); + + it("auto-expands when a ~ path expands to a real directory", () => { + // "~" expands to os.homedir() which always exists and is a directory. + const config: AccessPolicyConfig = { rules: { "~": "r--" } }; + const errs = validateAccessPolicyConfig(config); + expect(errs).toHaveLength(1); + expect(errs[0]).toMatch(/auto-expanded/); + // Rule key should be rewritten with /** suffix. + expect(config.rules?.["~/**"]).toBe("r--"); + expect(config.rules?.["~"]).toBeUndefined(); + }); + + it("emits the diagnostic only once per process for the same pattern", () => { + const dir = os.tmpdir(); + // First call — should warn. + const first = validateAccessPolicyConfig({ rules: { [dir]: "r--" as const } }); + expect(first).toHaveLength(1); + // Second call with the same bare pattern — already warned, silent. + const second = validateAccessPolicyConfig({ rules: { [dir]: "r--" as const } }); + expect(second).toHaveLength(0); + }); + + it("does not warn for glob patterns or trailing-/ rules", () => { + const dir = os.tmpdir(); + expect(validateAccessPolicyConfig({ rules: { [`${dir}/**`]: "r--" } })).toEqual([]); + expect(validateAccessPolicyConfig({ rules: { [`${dir}/`]: "r--" } })).toEqual([]); + expect(validateAccessPolicyConfig({ rules: { "/tmp/**": "rwx" } })).toEqual([]); + }); + + it("does not warn for bare file paths (stat confirms it is a file)", () => { + // process.execPath is the running node/bun binary — always a real file, never a dir. + expect(validateAccessPolicyConfig({ rules: { [process.execPath]: "r--" } })).toEqual([]); + }); + + it("does not warn for paths that do not exist (ENOENT silently ignored)", () => { + expect( + validateAccessPolicyConfig({ + rules: { "/nonexistent/path/that/cannot/exist-xyzzy": "r--" }, + }), + ).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// permAllows fail-closed on malformed characters +// --------------------------------------------------------------------------- + +describe("checkAccessPolicy — malformed permission characters fail closed", () => { + it("treats a typo like 'r1-' as deny for write (only exact 'w' grants write)", () => { + // "r1-": index 1 is "1", not "w" — must deny write, not allow it. + const config = { rules: { "/tmp/**": "r1-" as unknown as "r--" }, default: "---" }; + expect(checkAccessPolicy("/tmp/foo.txt", "write", config)).toBe("deny"); + }); + + it("treats 'R--' (uppercase) as deny for read (only lowercase 'r' grants read)", () => { + const config = { rules: { "/tmp/**": "R--" as unknown as "r--" }, default: "---" }; + expect(checkAccessPolicy("/tmp/foo.txt", "read", config)).toBe("deny"); + }); + + it("treats 'rWx' (uppercase W) as deny for write", () => { + const config = { rules: { "/tmp/**": "rWx" as unknown as "rwx" }, default: "---" }; + expect(checkAccessPolicy("/tmp/foo.txt", "write", config)).toBe("deny"); + }); +}); + +// --------------------------------------------------------------------------- +// Trailing slash shorthand +// --------------------------------------------------------------------------- + +describe("checkAccessPolicy — trailing slash shorthand", () => { + it('"/tmp/" is equivalent to "/tmp/**"', () => { + const config: AccessPolicyConfig = { rules: { "/tmp/": "rwx" }, default: "---" }; + expect(checkAccessPolicy("/tmp/foo.txt", "write", config)).toBe("allow"); + expect(checkAccessPolicy("/tmp/a/b/c", "write", config)).toBe("allow"); + }); + + it('"~/" is equivalent to "~/**"', () => { + const config: AccessPolicyConfig = { rules: { "~/": "rw-" }, default: "---" }; + expect(checkAccessPolicy(`${HOME}/foo.txt`, "read", config)).toBe("allow"); + expect(checkAccessPolicy(`${HOME}/foo.txt`, "write", config)).toBe("allow"); + expect(checkAccessPolicy(`${HOME}/foo.txt`, "exec", config)).toBe("deny"); + }); + + it("trailing slash in deny list blocks subtree", () => { + const config: AccessPolicyConfig = { + rules: { "/**": "rwx" }, + deny: [`${HOME}/.ssh/`], + }; + expect(checkAccessPolicy(`${HOME}/.ssh/id_rsa`, "read", config)).toBe("deny"); + }); + + it("trailing slash and /** produce identical results", () => { + const withSlash: AccessPolicyConfig = { rules: { "/tmp/": "rwx" }, default: "---" }; + const withGlob: AccessPolicyConfig = { rules: { "/tmp/**": "rwx" }, default: "---" }; + const paths = ["/tmp/a", "/tmp/a/b", "/tmp/a/b/c.txt"]; + for (const p of paths) { + expect(checkAccessPolicy(p, "write", withSlash)).toBe( + checkAccessPolicy(p, "write", withGlob), + ); + } + }); + + it("trailing slash rule covers the directory itself (mkdir check)", () => { + // Rule "~/.openclaw/heartbeat/" should allow write on the bare directory + // path ~/.openclaw/heartbeat (no trailing component), not just its contents. + const config: AccessPolicyConfig = { + rules: { "/**": "r--", [`${HOME}/.openclaw/heartbeat/`]: "rw-" }, + default: "---", + }; + expect(checkAccessPolicy(`${HOME}/.openclaw/heartbeat`, "write", config)).toBe("allow"); + expect(checkAccessPolicy(`${HOME}/.openclaw/heartbeat/test.txt`, "write", config)).toBe( + "allow", + ); + }); + + it("trailing slash in deny list blocks the directory itself", () => { + const config: AccessPolicyConfig = { + rules: { "/**": "rwx" }, + deny: [`${HOME}/.ssh/`], + }; + // Both the directory and its contents should be denied. + expect(checkAccessPolicy(`${HOME}/.ssh`, "read", config)).toBe("deny"); + expect(checkAccessPolicy(`${HOME}/.ssh/id_rsa`, "read", config)).toBe("deny"); + }); +}); + +// --------------------------------------------------------------------------- +// normalizePlatformPath (macOS alias transparency) +// --------------------------------------------------------------------------- + +describe.skipIf(process.platform !== "darwin")( + "checkAccessPolicy — macOS /private alias normalization", + () => { + const config: AccessPolicyConfig = { + rules: { + "/tmp/**": "rwx", + "/var/**": "r--", + "/etc/**": "r--", + }, + default: "---", + }; + + it("/private/tmp path is treated as /tmp — write allowed", () => { + expect(checkAccessPolicy("/private/tmp/foo.txt", "write", config)).toBe("allow"); + }); + + it("/private/var path is treated as /var — write denied (r-- only)", () => { + expect(checkAccessPolicy("/private/var/log/system.log", "write", config)).toBe("deny"); + }); + + it("/private/etc path is treated as /etc — read allowed", () => { + expect(checkAccessPolicy("/private/etc/hosts", "read", config)).toBe("allow"); + }); + + it("/tmp path still works directly", () => { + expect(checkAccessPolicy("/tmp/foo.txt", "write", config)).toBe("allow"); + }); + + it("deny list entry /tmp/** also blocks /private/tmp/**", () => { + const denyConfig: AccessPolicyConfig = { + deny: ["/tmp/**"], + rules: { "/**": "rwx" }, + }; + expect(checkAccessPolicy("/private/tmp/evil.sh", "exec", denyConfig)).toBe("deny"); + }); + + it("/private/tmp/** pattern in deny list blocks /tmp/** target", () => { + // Pattern written with /private/tmp must still match the normalized /tmp target. + const denyConfig: AccessPolicyConfig = { + deny: ["/private/tmp/**"], + rules: { "/**": "rwx" }, + }; + expect(checkAccessPolicy("/tmp/evil.sh", "read", denyConfig)).toBe("deny"); + }); + + it("/private/tmp/** rule matches /tmp/** target", () => { + // Rule written with /private/* prefix must match a /tmp/* target path. + const cfg: AccessPolicyConfig = { + default: "---", + rules: { "/private/tmp/**": "rwx" }, + }; + expect(checkAccessPolicy("/tmp/foo.txt", "write", cfg)).toBe("allow"); + }); + }, +); + +// --------------------------------------------------------------------------- +// findBestRule +// --------------------------------------------------------------------------- + +describe("findBestRule", () => { + it("returns null when rules is empty", () => { + expect(findBestRule("/foo/bar", {})).toBeNull(); + }); + + it("returns matching rule", () => { + expect(findBestRule("/foo/bar", { "/foo/**": "r--" })).toBe("r--"); + }); + + it("prefers longer (more specific) pattern over shorter", () => { + const rules = { + "/**": "r--", + "/foo/**": "rw-", + "/foo/bar/**": "rwx", + }; + expect(findBestRule("/foo/bar/baz.txt", rules)).toBe("rwx"); + expect(findBestRule("/foo/other.txt", rules)).toBe("rw-"); + expect(findBestRule("/etc/passwd", rules)).toBe("r--"); + }); + + it("expands ~ in patterns", () => { + const rules = { "~/**": "rw-" }; + expect(findBestRule(`${HOME}/workspace/foo.py`, rules)).toBe("rw-"); + }); + + it("returns null when no pattern matches", () => { + const rules = { "/foo/**": "rw-" }; + expect(findBestRule("/bar/baz", rules)).toBeNull(); + }); + + it("tilde rule beats broader absolute rule when expanded path is longer", () => { + // "~/.ssh/**" expanded is e.g. "/home/user/.ssh/**" (longer than "/home/user/**"). + // The tilde rule must win so an explicit "---" denial is not silently overridden. + const rules: Record = { + [`${HOME}/**`]: "rwx", + "~/.ssh/**": "---", + }; + expect(findBestRule(`${HOME}/.ssh/id_rsa`, rules, HOME)).toBe("---"); + }); +}); + +// --------------------------------------------------------------------------- +// checkAccessPolicy — deny list +// --------------------------------------------------------------------------- + +describe("checkAccessPolicy — deny list", () => { + it("deny always blocks, even when a rule would allow", () => { + const config: AccessPolicyConfig = { + rules: { "/**": "rwx" }, + deny: [`${HOME}/.ssh/**`], + default: "rwx", + }; + expect(checkAccessPolicy(`${HOME}/.ssh/id_rsa`, "read", config)).toBe("deny"); + expect(checkAccessPolicy(`${HOME}/.ssh/id_rsa`, "write", config)).toBe("deny"); + expect(checkAccessPolicy(`${HOME}/.ssh/id_rsa`, "exec", config)).toBe("deny"); + }); + + it.skipIf(process.platform === "win32")( + "deny does not affect paths outside the deny glob", + () => { + const config: AccessPolicyConfig = { + rules: { "/**": "rwx" }, + deny: [`${HOME}/.ssh/**`], + }; + expect(checkAccessPolicy(`${HOME}/workspace/foo.py`, "read", config)).toBe("allow"); + }, + ); + + it("multiple deny entries — first match blocks", () => { + const config: AccessPolicyConfig = { + rules: { "/**": "rwx" }, + deny: [`${HOME}/.ssh/**`, `${HOME}/.gnupg/**`], + }; + expect(checkAccessPolicy(`${HOME}/.gnupg/secring.gpg`, "read", config)).toBe("deny"); + }); +}); + +// --------------------------------------------------------------------------- +// checkAccessPolicy — rules +// --------------------------------------------------------------------------- + +describe("checkAccessPolicy — rules", () => { + it("allows read when r bit is set", () => { + const config: AccessPolicyConfig = { rules: { "/**": "r--" } }; + expect(checkAccessPolicy("/etc/passwd", "read", config)).toBe("allow"); + }); + + it("denies write when w bit is absent", () => { + const config: AccessPolicyConfig = { rules: { "/**": "r--" } }; + expect(checkAccessPolicy("/etc/passwd", "write", config)).toBe("deny"); + }); + + it("denies exec when x bit is absent", () => { + const config: AccessPolicyConfig = { rules: { "/usr/bin/**": "r--" } }; + expect(checkAccessPolicy("/usr/bin/grep", "exec", config)).toBe("deny"); + }); + + it("allows exec when x bit is set", () => { + const config: AccessPolicyConfig = { rules: { "/usr/bin/**": "r-x" } }; + expect(checkAccessPolicy("/usr/bin/grep", "exec", config)).toBe("allow"); + }); + + it("longer rule overrides shorter for the same path", () => { + const config: AccessPolicyConfig = { + rules: { + "/**": "r--", + [`${HOME}/**`]: "rwx", + }, + }; + // Home subpath → rwx wins + expect(checkAccessPolicy(`${HOME}/workspace/foo`, "write", config)).toBe("allow"); + // Outside home → r-- applies + expect(checkAccessPolicy("/etc/passwd", "write", config)).toBe("deny"); + }); + + it("specific sub-path rule can restrict a broader allow", () => { + const config: AccessPolicyConfig = { + rules: { + [`${HOME}/**`]: "rwx", + [`${HOME}/.config/**`]: "r--", + }, + }; + expect(checkAccessPolicy(`${HOME}/workspace/foo`, "write", config)).toBe("allow"); + expect(checkAccessPolicy(`${HOME}/.config/sensitive`, "write", config)).toBe("deny"); + }); + + it("tilde rule beats broader absolute rule — expanded length wins", () => { + // Without the expanded-length fix, "~/.ssh/**" (9 raw chars) would lose to + // `${HOME}/**` when HOME is long, letting rwx override the intended --- deny. + const config: AccessPolicyConfig = { + rules: { + [`${HOME}/**`]: "rwx", + "~/.ssh/**": "---", + }, + }; + expect(checkAccessPolicy(`${HOME}/.ssh/id_rsa`, "read", config, HOME)).toBe("deny"); + expect(checkAccessPolicy(`${HOME}/workspace/foo`, "write", config, HOME)).toBe("allow"); + }); +}); + +// --------------------------------------------------------------------------- +// checkAccessPolicy — default +// --------------------------------------------------------------------------- + +describe("checkAccessPolicy — default", () => { + it("uses default when no rule matches", () => { + const config: AccessPolicyConfig = { + rules: { [`${HOME}/**`]: "rwx" }, + default: "r--", + }; + expect(checkAccessPolicy("/etc/passwd", "read", config)).toBe("allow"); + expect(checkAccessPolicy("/etc/passwd", "write", config)).toBe("deny"); + }); + + it("absent default is treated as --- (deny all)", () => { + const config: AccessPolicyConfig = { + rules: { [`${HOME}/**`]: "rwx" }, + }; + expect(checkAccessPolicy("/etc/passwd", "read", config)).toBe("deny"); + }); + + it("default --- denies all ops on unmatched paths", () => { + const config: AccessPolicyConfig = { + rules: { [`${HOME}/workspace/**`]: "rw-" }, + default: "---", + }; + expect(checkAccessPolicy("/tmp/foo", "read", config)).toBe("deny"); + expect(checkAccessPolicy("/tmp/foo", "write", config)).toBe("deny"); + expect(checkAccessPolicy("/tmp/foo", "exec", config)).toBe("deny"); + }); +}); + +// --------------------------------------------------------------------------- +// checkAccessPolicy — precedence integration +// --------------------------------------------------------------------------- + +describe("checkAccessPolicy — precedence integration", () => { + it("deny beats rules beats default — all three in play", () => { + const config: AccessPolicyConfig = { + rules: { + "/**": "r--", + [`${HOME}/**`]: "rwx", + }, + deny: [`${HOME}/.ssh/**`], + default: "---", + }; + // Rule allows home paths + expect(checkAccessPolicy(`${HOME}/workspace/foo`, "write", config)).toBe("allow"); + // Deny beats the home rule + expect(checkAccessPolicy(`${HOME}/.ssh/id_rsa`, "read", config)).toBe("deny"); + // Outer rule applies outside home + expect(checkAccessPolicy("/etc/hosts", "read", config)).toBe("allow"); + expect(checkAccessPolicy("/etc/hosts", "write", config)).toBe("deny"); + // Nothing matches /proc → default --- + expect(checkAccessPolicy("/proc/self/mem", "read", config)).toBe("allow"); // matches /** + }); + + it("empty config denies everything (no rules, no default)", () => { + const config: AccessPolicyConfig = {}; + expect(checkAccessPolicy("/anything", "read", config)).toBe("deny"); + expect(checkAccessPolicy("/anything", "write", config)).toBe("deny"); + }); +}); + +// --------------------------------------------------------------------------- +// Symlink attack scenarios — resolved-path policy checks +// +// macOS Seatbelt (and the bwrap layer) evaluate the *resolved* real path at +// the syscall level, not the symlink path. checkAccessPolicy is called with +// the already-resolved path. These tests document the expected behavior when +// a symlink in an allowed directory points to a denied or restricted target. +// --------------------------------------------------------------------------- + +describe("checkAccessPolicy — symlink resolved-path scenarios", () => { + it("denies read on resolved symlink target that falls under deny list", () => { + // ~/workspace/link → ~/.ssh/id_rsa (symlink in allowed dir to denied file) + // Caller passes the resolved path; deny wins. + const config: AccessPolicyConfig = { + rules: { [`${HOME}/workspace/**`]: "rw-" }, + deny: [`${HOME}/.ssh/**`], + default: "---", + }; + expect(checkAccessPolicy(`${HOME}/.ssh/id_rsa`, "read", config, HOME)).toBe("deny"); + }); + + it("denies write on resolved symlink target covered by restrictive rule", () => { + // ~/workspace/link → ~/workspace/secret/file + // workspace is rw-, but the secret subdir is r--. Resolved path hits r--. + const config: AccessPolicyConfig = { + rules: { + [`${HOME}/workspace/**`]: "rw-", + [`${HOME}/workspace/secret/**`]: "r--", + }, + default: "---", + }; + expect(checkAccessPolicy(`${HOME}/workspace/secret/file.txt`, "write", config, HOME)).toBe( + "deny", + ); + // Read is still allowed via the r-- rule. + expect(checkAccessPolicy(`${HOME}/workspace/secret/file.txt`, "read", config, HOME)).toBe( + "allow", + ); + }); + + it("symlink source path in allowed dir would be allowed; resolved denied target is denied", () => { + // This illustrates that the policy must be checked on the resolved path. + // The symlink path itself looks allowed; the real target does not. + const config: AccessPolicyConfig = { + rules: { [`${HOME}/workspace/**`]: "rw-" }, + deny: [`${HOME}/.aws/**`], + default: "---", + }; + // Source path (the symlink) — allowed + expect(checkAccessPolicy(`${HOME}/workspace/creds`, "read", config, HOME)).toBe("allow"); + // Real target — denied + expect(checkAccessPolicy(`${HOME}/.aws/credentials`, "read", config, HOME)).toBe("deny"); + }); +}); + +// --------------------------------------------------------------------------- +// resolveArgv0 +// --------------------------------------------------------------------------- + +describe("resolveArgv0", () => { + it("returns null for empty command", () => { + expect(resolveArgv0("")).toBeNull(); + expect(resolveArgv0(" ")).toBeNull(); + }); + + it("extracts first unquoted token", () => { + // /bin/sh exists on all platforms; if not, the non-resolved path is returned + const result = resolveArgv0("/bin/sh -c 'echo hi'"); + expect(result).not.toBeNull(); + expect(result).toMatch(/sh$/); + }); + + it("extracts double-quoted path", () => { + const result = resolveArgv0(`"/bin/sh" -c 'echo hi'`); + expect(result).not.toBeNull(); + expect(result).toMatch(/sh$/); + }); + + it("returns null for relative path without cwd", () => { + expect(resolveArgv0("./script.py")).toBeNull(); + }); + + it("resolves relative path against cwd", () => { + const tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "ap-test-"))); + const scriptPath = path.join(tmpDir, "script.py"); + fs.writeFileSync(scriptPath, "#!/usr/bin/env python3\n"); + try { + const result = resolveArgv0("./script.py arg1 arg2", tmpDir); + expect(result).toBe(scriptPath); + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } + }); + + it("expands ~ in path", () => { + // /bin/ls will exist on test hosts; just verify ~ expansion doesn't crash + const result = resolveArgv0("~/nonexistent-script-xyz"); + // Returns expanded (non-realpath) since file doesn't exist + expect(result).toBe(`${HOME}/nonexistent-script-xyz`); + }); + + it("skips leading env-prefix assignments to find real argv0", () => { + // "FOO=1 /bin/sh -c cmd" — argv0 is /bin/sh, not FOO=1. + // Without this, policy.scripts lookup and sha256 checks are bypassed. + const result = resolveArgv0("FOO=1 BAR=2 /bin/sh -c 'echo hi'"); + expect(result).not.toBeNull(); + expect(result).toMatch(/sh$/); + }); + + it("returns null when command is only env assignments with no argv0", () => { + expect(resolveArgv0("FOO=1 BAR=2")).toBeNull(); + }); + + it("unquotes a double-quoted argv0 that follows env assignments", () => { + // FOO=1 "/opt/my script.sh" — argv0 is /opt/my script.sh (spaces in path). + // Without unquoting, the token would be '"/opt/my' — wrong path, sha256 bypass. + const result = resolveArgv0('FOO=1 "/bin/sh" -c echo'); + expect(result).not.toBeNull(); + expect(result).toMatch(/sh$/); + }); + + it("handles env assignments with single-quoted values containing spaces", () => { + // FOO='a b' /bin/sh — naive whitespace split yields ["FOO='a", "b'", "/bin/sh"]. + // "b'" does not match NAME=, so it was wrongly treated as argv0, bypassing + // script policy lookups. Must be parsed as argv0=/bin/sh. + const result = resolveArgv0("FOO='a b' /bin/sh -c echo"); + expect(result).not.toBeNull(); + expect(result).toMatch(/sh$/); + }); + + it("handles env assignments with double-quoted values containing spaces", () => { + const result = resolveArgv0('BAR="hello world" /bin/sh -c echo'); + expect(result).not.toBeNull(); + expect(result).toMatch(/sh$/); + }); + + it("handles multiple env assignments with quoted values", () => { + const result = resolveArgv0("A='x y' B='p q' /bin/sh -c echo"); + expect(result).not.toBeNull(); + expect(result).toMatch(/sh$/); + }); +}); + +// --------------------------------------------------------------------------- +// applyScriptPolicyOverride +// --------------------------------------------------------------------------- + +describe("applyScriptPolicyOverride", () => { + it("returns base policy unchanged when no scripts block", () => { + const base: AccessPolicyConfig = { rules: { "/**": "r--" }, default: "---" }; + const { policy, hashMismatch } = applyScriptPolicyOverride(base, "/any/path"); + expect(hashMismatch).toBeUndefined(); + expect(policy).toBe(base); + }); + + it("returns base policy unchanged when argv0 not in scripts", () => { + const base: AccessPolicyConfig = { + rules: { "/**": "r--" }, + scripts: { "/other/script.sh": { rules: { "/tmp/**": "rwx" } } }, + }; + const { policy, hashMismatch } = applyScriptPolicyOverride(base, "/my/script.sh"); + expect(hashMismatch).toBeUndefined(); + expect(policy).toBe(base); + }); + + it("returns override rules separately so seatbelt emits them after deny", () => { + const base: AccessPolicyConfig = { + rules: { "/**": "r--" }, + default: "---", + scripts: { "/my/script.sh": { rules: { [`${HOME}/.openclaw/credentials/`]: "r--" } } }, + }; + const { policy, overrideRules, hashMismatch } = applyScriptPolicyOverride( + base, + "/my/script.sh", + ); + expect(hashMismatch).toBeUndefined(); + // Base rules unchanged in policy + expect(policy.rules?.["/**"]).toBe("r--"); + expect(policy.rules?.[`${HOME}/.openclaw/credentials/`]).toBeUndefined(); + // Override rules returned separately — caller emits them after deny in seatbelt profile + expect(overrideRules?.[`${HOME}/.openclaw/credentials/`]).toBe("r--"); + expect(policy.scripts).toBeUndefined(); + }); + + it("appends deny additively", () => { + const base: AccessPolicyConfig = { + deny: [`${HOME}/.ssh/**`], + scripts: { + "/my/script.sh": { deny: ["/tmp/**"] }, + }, + }; + const { policy } = applyScriptPolicyOverride(base, "/my/script.sh"); + expect(policy.deny).toContain(`${HOME}/.ssh/**`); + expect(policy.deny).toContain("/tmp/**"); + }); + + it("override rules returned separately — base policy rule unchanged", () => { + const base: AccessPolicyConfig = { + rules: { [`${HOME}/workspace/**`]: "r--" }, + scripts: { "/trusted.sh": { rules: { [`${HOME}/workspace/**`]: "rwx" } } }, + }; + const { policy, overrideRules } = applyScriptPolicyOverride(base, "/trusted.sh"); + expect(policy.rules?.[`${HOME}/workspace/**`]).toBe("r--"); + expect(overrideRules?.[`${HOME}/workspace/**`]).toBe("rwx"); + }); + + it("narrowing override returned separately", () => { + const base: AccessPolicyConfig = { + rules: { "/tmp/**": "rwx" }, + scripts: { "/cautious.sh": { rules: { "/tmp/**": "r--" } } }, + }; + const { policy, overrideRules } = applyScriptPolicyOverride(base, "/cautious.sh"); + expect(policy.rules?.["/tmp/**"]).toBe("rwx"); + expect(overrideRules?.["/tmp/**"]).toBe("r--"); + }); + + it("returns hashMismatch when sha256 does not match file content", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ap-test-")); + const scriptPath = path.join(tmpDir, "script.sh"); + fs.writeFileSync(scriptPath, "#!/bin/sh\necho hi\n"); + try { + const base: AccessPolicyConfig = { + scripts: { + [scriptPath]: { sha256: "deadbeef".padEnd(64, "0"), rules: { "/tmp/**": "rwx" } }, + }, + }; + const { policy, hashMismatch } = applyScriptPolicyOverride(base, scriptPath); + expect(hashMismatch).toBe(true); + expect(policy).toBe(base); + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } + }); + + it("applies override when sha256 matches — rules in overrideRules, not policy", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ap-test-")); + const scriptPath = path.join(tmpDir, "script.sh"); + const content = "#!/bin/sh\necho hi\n"; + fs.writeFileSync(scriptPath, content); + const hash = crypto.createHash("sha256").update(Buffer.from(content)).digest("hex"); + try { + const base: AccessPolicyConfig = { + rules: { "/**": "r--" }, + scripts: { [scriptPath]: { sha256: hash, rules: { "/tmp/**": "rwx" } } }, + }; + const { policy, overrideRules, hashMismatch } = applyScriptPolicyOverride(base, scriptPath); + expect(hashMismatch).toBeUndefined(); + expect(overrideRules?.["/tmp/**"]).toBe("rwx"); + expect(policy.rules?.["/tmp/**"]).toBeUndefined(); + expect(policy.scripts).toBeUndefined(); + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } + }); +}); diff --git a/src/infra/access-policy.ts b/src/infra/access-policy.ts new file mode 100644 index 00000000000..abeea74c802 --- /dev/null +++ b/src/infra/access-policy.ts @@ -0,0 +1,418 @@ +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(); + +/** Reset the one-time auto-expand warning set. Only for use in tests. */ +export function _resetAutoExpandedWarnedForTest(): void { + _autoExpandedWarned.clear(); +} + +/** + * Validates and normalizes an AccessPolicyConfig for well-formedness. + * Returns an array of human-readable diagnostic strings; empty = valid. + * May mutate config.rules and config.deny in place (e.g. auto-expanding bare directory paths). + */ +export function validateAccessPolicyConfig(config: AccessPolicyConfig): string[] { + const errors: string[] = []; + + if (config.default !== undefined && !PERM_STR_RE.test(config.default)) { + errors.push( + `access-policy.default "${config.default}" is invalid: must be exactly 3 chars (e.g. "rwx", "r--", "---")`, + ); + } + + if (config.rules) { + for (const [pattern, perm] of Object.entries(config.rules)) { + if (!pattern) { + errors.push("access-policy.rules: rule key must be a non-empty glob pattern"); + } + if (!PERM_STR_RE.test(perm)) { + errors.push( + `access-policy.rules["${pattern}"] "${perm}" is invalid: must be exactly 3 chars (e.g. "rwx", "r--", "---")`, + ); + } + // 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. + // Any stat failure means the agent faces the same error (ENOENT/EACCES), + // so the rule is a no-op and no action is needed. + if (pattern && !pattern.endsWith("/") && !/[*?[]/.test(pattern)) { + 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 config.rules)) { + config.rules[fixed] = perm; + } + delete config.rules[pattern]; + if (!_autoExpandedWarned.has(pattern)) { + _autoExpandedWarned.add(pattern); + errors.push( + `access-policy.rules["${pattern}"] is a directory — rule auto-expanded to "${fixed}" so it covers all contents.`, + ); + } + } + } catch { + // Path inaccessible or missing — no action needed. + } + } + } + } + + if (config.deny) { + for (let i = 0; i < config.deny.length; i++) { + const pattern = config.deny[i]; + if (!pattern) { + errors.push(`access-policy.deny[${i}] must be a non-empty glob pattern`); + continue; + } + // Same bare-directory auto-expand as rules: "~/.ssh" → "~/.ssh/**" so the + // entire directory is denied, not just the directory entry itself. + if (!pattern.endsWith("/") && !/[*?[]/.test(pattern)) { + const expanded = pattern.startsWith("~") + ? pattern.replace(/^~(?=$|\/)/, os.homedir()) + : pattern; + try { + if (fs.statSync(expanded).isDirectory()) { + const fixed = `${pattern}/**`; + config.deny[i] = fixed; + if (!_autoExpandedWarned.has(`deny:${pattern}`)) { + _autoExpandedWarned.add(`deny:${pattern}`); + errors.push( + `access-policy.deny["${pattern}"] is a directory — entry auto-expanded to "${fixed}" so it covers all contents.`, + ); + } + } + } catch { + // Path inaccessible or missing — no action needed. + } + } + } + } + + 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 = { + 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 = { + read: "r", + write: "w", + exec: "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) { + 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, + 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)); + if (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. deny[] — any matching glob always blocks, no override. + * 2. rules — longest matching glob wins; check the relevant bit. + * 3. default — catch-all (treated as "---" when absent). + */ +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) + ); + } + + // 1. deny list always wins. + for (const pattern of config.deny ?? []) { + // Normalize so /private/tmp/** patterns match /tmp/** targets on macOS. + const expanded = normalizePlatformPath(expandPattern(pattern, homeDir)); + if (matchesPattern(expanded)) { + return "deny"; + } + } + + // 2. 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.rules ?? {})) { + // 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"; + } + + // 3. default catch-all (absent = "---" = deny all). + return permAllows(config.default, op) ? "allow" : "deny"; +} + +/** + * 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): string | 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 = ""; + 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); + } 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. + const envPrefixRe = /^[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s*/; + let rest = trimmed; + while (envPrefixRe.test(rest)) { + 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 against cwd + if (!path.isAbsolute(token)) { + if (!cwd) { + return null; + } + token = path.resolve(cwd, token); + } + // 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) === "env" && commandRest) { + const afterEnv = commandRest.replace(/^\S+\s*/, ""); + return afterEnv ? resolveArgv0(afterEnv, cwd) : null; + } + + return token; +} + +/** + * 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; hashMismatch?: true } { + const override = policy.scripts?.[resolvedArgv0]; + 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 { + const contents = fs.readFileSync(resolvedArgv0); + actualHash = crypto.createHash("sha256").update(contents).digest("hex"); + } catch { + return { policy, hashMismatch: true }; + } + if (actualHash !== override.sha256) { + 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 deny list in the seatbelt profile (last-match-wins — grants must come + // after deny entries to override broad deny patterns like ~/.secrets/**). + const { scripts: _scripts, ...base } = policy; + const merged: AccessPolicyConfig = { + ...base, + deny: [...(base.deny ?? []), ...(override.deny ?? [])], + }; + if (merged.deny?.length === 0) { + delete merged.deny; + } + return { + policy: merged, + overrideRules: + override.rules && Object.keys(override.rules).length > 0 ? override.rules : undefined, + }; +} diff --git a/src/infra/exec-sandbox-bwrap.test.ts b/src/infra/exec-sandbox-bwrap.test.ts new file mode 100644 index 00000000000..944a04abf6d --- /dev/null +++ b/src/infra/exec-sandbox-bwrap.test.ts @@ -0,0 +1,304 @@ +import os from "node:os"; +import { describe, expect, it } from "vitest"; +import type { AccessPolicyConfig } from "../config/types.tools.js"; +import { generateBwrapArgs, wrapCommandWithBwrap } from "./exec-sandbox-bwrap.js"; + +const HOME = os.homedir(); + +describe("generateBwrapArgs", () => { + it("starts with --ro-bind / / when default allows reads", () => { + const config: AccessPolicyConfig = { default: "r--" }; + const args = generateBwrapArgs(config, HOME); + expect(args.slice(0, 3)).toEqual(["--ro-bind", "/", "/"]); + }); + + it("does not use --ro-bind / / when default is ---", () => { + const config: AccessPolicyConfig = { default: "---" }; + 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 = { default: "r--" }; + const args = generateBwrapArgs(config, HOME); + expect(args[args.length - 1]).toBe("--"); + }); + + it("adds --tmpfs for each deny entry", () => { + const config: AccessPolicyConfig = { + deny: [`${HOME}/.ssh/**`, `${HOME}/.gnupg/**`], + default: "r--", + }; + 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 deny patterns using homeDir", () => { + const config: AccessPolicyConfig = { + deny: ["~/.ssh/**"], + default: "r--", + }; + 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 = { + rules: { [`${HOME}/workspace/**`]: "rw-" }, + default: "r--", + }; + 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 = { + rules: { "/usr/bin/**": "r--" }, + default: "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("deny entry tmpfs appears in args regardless of rule for that path", () => { + const config: AccessPolicyConfig = { + rules: { [`${HOME}/**`]: "rwx" }, + deny: [`${HOME}/.ssh/**`], + default: "r--", + }; + 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 --tmpfs /tmp in permissive mode", () => { + const config: AccessPolicyConfig = { default: "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 (default: ---)", () => { + const config: AccessPolicyConfig = { default: "---" }; + 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 default:"r--", --ro-bind / / makes everything readable. A narrowing + // rule like "/secret/**": "---" must overlay --tmpfs so the path is hidden. + const config: AccessPolicyConfig = { + default: "r--", + rules: { [`${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('"--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 = { + default: "r--", + rules: { [`${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('"---" 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 = { + default: "---", + rules: { + [`${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 = { + default: "---", + rules: { [`${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 does not emit --bind-try (read leak)', () => { + // A write-only rule ("-w-") under default:"---" must NOT produce --bind-try + // because --bind-try is a read-write mount — it silently grants reads to a path + // the policy denies reads on. Skip the mount; tool layer enforces the write perm. + const config: AccessPolicyConfig = { + default: "---", + rules: { [`${HOME}/logs/**`]: "-w-" }, + }; + const args = generateBwrapArgs(config, HOME); + const bindMounts = args + .map((a, i) => (a === "--bind-try" ? args[i + 1] : null)) + .filter(Boolean); + const roBound = args + .map((a, i) => (a === "--ro-bind-try" ? args[i + 1] : null)) + .filter(Boolean); + expect(bindMounts).not.toContain(`${HOME}/logs`); + expect(roBound).not.toContain(`${HOME}/logs`); + }); + + it('"-w-" rule in permissive mode emits --bind-try (write upgrade, reads already allowed)', () => { + // Under default:"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 = { + default: "r--", + rules: { [`${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 — truncated prefix would be too broad", () => { + // "/home/*/.ssh/**" truncates to "/home" — far too broad for a bwrap mount. + // The pattern must be silently ignored rather than binding /home. + const fakeHome = "/home/testuser"; + const config: AccessPolicyConfig = { + default: "r--", + deny: ["/home/*/.ssh/**"], + rules: { "/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); + // "/home" must NOT appear as a mount target — it's the over-broad truncation. + expect(allMountTargets).not.toContain("/home"); + }); + + 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 = { + default: "r--", + deny: ["/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. + rules: { + [`${HOME}/dev/secret/**`]: "r--", + [`${HOME}/dev/**`]: "rw-", + }, + default: "---", + }; + 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("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( + { default: "---", rules: { "/tmp/": "rw-" } }, + HOME, + ); + const withGlob = generateBwrapArgs( + { default: "---", rules: { "/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)); + }); +}); + +describe("wrapCommandWithBwrap", () => { + it("starts with bwrap", () => { + const result = wrapCommandWithBwrap("ls /tmp", { default: "r--" }, HOME); + expect(result).toMatch(/^bwrap /); + }); + + it("contains -- separator before the command", () => { + const result = wrapCommandWithBwrap("ls /tmp", { default: "r--" }, HOME); + expect(result).toContain("-- /bin/sh -c"); + }); + + it("wraps command in /bin/sh -c", () => { + const result = wrapCommandWithBwrap("cat /etc/hosts", { default: "r--" }, HOME); + expect(result).toContain("/bin/sh -c"); + expect(result).toContain("cat /etc/hosts"); + }); +}); diff --git a/src/infra/exec-sandbox-bwrap.ts b/src/infra/exec-sandbox-bwrap.ts new file mode 100644 index 00000000000..763eff7f10b --- /dev/null +++ b/src/infra/exec-sandbox-bwrap.ts @@ -0,0 +1,225 @@ +import { execFile } from "node:child_process"; +import os from "node:os"; +import { promisify } from "node:util"; +import type { AccessPolicyConfig, PermStr } from "../config/types.tools.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; + +/** + * Returns true if bwrap is installed and executable on this system. + * Result is cached after the first call. + */ +export async function isBwrapAvailable(): Promise { + 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" + * + * Returns null when a wildcard appears in a non-final segment (e.g. "/home/*\/.ssh/**") + * because the truncated prefix ("/home") would be far too broad for a bwrap mount + * and the caller must skip it entirely. + */ +function patternToPath(pattern: string, homeDir: string): 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. /home/*/foo) and the + // concrete prefix (/home) is too broad to be a safe mount target. + const afterWildcard = expanded.slice(wildcardIdx); + if (/[/\\]/.test(afterWildcard)) { + return null; + } + // 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 || "/"; +} + +function permAllowsWrite(perm: PermStr): boolean { + return perm[1] === "w"; +} + +/** + * Generate bwrap argument array for the given permissions config. + * + * Strategy: + * 1. Start with --ro-bind / / (read-only view of entire host FS) + * 2. For each rule with w bit: upgrade to --bind (read-write) + * 3. For each deny[] entry: overlay with --tmpfs (empty, blocks reads too) + * 4. Add /tmp and /dev as writable tmpfs mounts (required for most processes) + * 5. When default is "---": use a more restrictive base (only bind explicit allow paths) + */ +export function generateBwrapArgs( + config: AccessPolicyConfig, + homeDir: string = os.homedir(), + /** + * Script-specific override rules to emit AFTER the deny list so they win over + * broad deny patterns — mirrors the Seatbelt scriptOverrideRules behaviour. + * In bwrap, later mounts win, so script grants must come last. + */ + scriptOverrideRules?: Record, +): string[] { + const args: string[] = []; + const defaultPerm = config.default ?? "---"; + const defaultAllowsRead = defaultPerm[0] === "r"; + + if (defaultAllowsRead) { + // Permissive base: everything is read-only by default. + args.push("--ro-bind", "/", "/"); + // Upgrade /tmp to writable tmpfs and overlay a real /dev for normal process operation. + args.push("--tmpfs", "/tmp"); + 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. + } + + // 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.rules ?? {}).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); + if (!p || p === "/") { + continue; + } // root already handled above + if (permAllowsWrite(perm) && (perm[0] === "r" || defaultPerm[0] === "r")) { + // Read-write bind: safe only when reads are also permitted — either the rule + // explicitly grants read, or the permissive base already allows reads via + // --ro-bind / /. A write-only rule ("-w-") under a restrictive base ("---") + // cannot be enforced as write-without-read at the bwrap OS layer; skip the + // mount so reads are not silently leaked. Tool-layer enforcement still applies. + args.push("--bind-try", p, p); + } else if (defaultPerm[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. + args.push("--ro-bind-try", p, p); + } else if (defaultPerm[0] === "r" && perm[0] !== "r") { + // Permissive base + narrowing rule (no read bit): overlay with tmpfs so the + // path is hidden even though --ro-bind / / made it readable by default. + // This mirrors what deny[] does — without this, "---" rules under a permissive + // default are silently ignored at the bwrap layer. + args.push("--tmpfs", p); + } + // Permissive base + read-only rule: already covered by --ro-bind / /; no extra mount. + } + + // deny[] entries: overlay with empty tmpfs — path exists but is empty. + // tmpfs overlay hides the real contents regardless of how the path was expressed. + for (const pattern of config.deny ?? []) { + const p = patternToPath(pattern, homeDir); + if (!p || p === "/") { + continue; + } + args.push("--tmpfs", p); + } + + // Script-specific override mounts — emitted after deny[] 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); + if (!p || p === "/") { + continue; + } + if (permAllowsWrite(perm) && (perm[0] === "r" || defaultPerm[0] === "r")) { + args.push("--bind-try", p, p); + } else if (perm[0] === "r") { + args.push("--ro-bind-try", p, p); + } else { + args.push("--tmpfs", 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 { + const bwrapArgs = generateBwrapArgs(config, homeDir, scriptOverrideRules); + const argStr = bwrapArgs.map((a) => (a === "--" ? "--" : shellEscape(a))).join(" "); + return `bwrap ${argStr} /bin/sh -c ${shellEscape(command)}`; +} diff --git a/src/infra/exec-sandbox-seatbelt.test.ts b/src/infra/exec-sandbox-seatbelt.test.ts new file mode 100644 index 00000000000..ed1abd3c4ea --- /dev/null +++ b/src/infra/exec-sandbox-seatbelt.test.ts @@ -0,0 +1,278 @@ +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 default is ---", () => { + const profile = generateSeatbeltProfile({ default: "---" }, HOME); + expect(profile).toContain("(deny default)"); + expect(profile).not.toContain("(allow default)"); + }); + + it("uses (allow default) when default has any permission", () => { + const profile = generateSeatbeltProfile({ default: "r--" }, HOME); + expect(profile).toContain("(allow default)"); + expect(profile).not.toContain("(deny default)"); + }); + + it("includes system baseline reads when default is ---", () => { + const profile = generateSeatbeltProfile({ default: "---" }, HOME); + expect(profile).toContain("(allow file-read*"); + expect(profile).toContain("/usr/lib"); + expect(profile).toContain("/System/Library"); + }); + + skipOnWindows("deny list entries appear as deny file-read*, file-write*, process-exec*", () => { + const config: AccessPolicyConfig = { + deny: [`${HOME}/.ssh/**`], + default: "rwx", + }; + const profile = generateSeatbeltProfile(config, HOME); + expect(profile).toContain(`(deny file-read*`); + expect(profile).toContain(`(deny file-write*`); + expect(profile).toContain(`(deny process-exec*`); + // Should contain the path + expect(profile).toContain(HOME + "/.ssh"); + }); + + skipOnWindows("expands ~ in deny patterns using provided homeDir", () => { + const config: AccessPolicyConfig = { + deny: ["~/.ssh/**"], + default: "rwx", + }; + const profile = generateSeatbeltProfile(config, HOME); + expect(profile).toContain(HOME + "/.ssh"); + // Should NOT contain literal ~ + const denySection = profile.split("; Deny list")[1] ?? ""; + expect(denySection).not.toContain("~/.ssh"); + }); + + skipOnWindows("expands ~ in rules using provided homeDir", () => { + const config: AccessPolicyConfig = { + rules: { "~/**": "rw-" }, + default: "---", + }; + const profile = generateSeatbeltProfile(config, HOME); + expect(profile).toContain(HOME); + }); + + it("rw- rule emits allow read+write, deny exec for that path", () => { + const config: AccessPolicyConfig = { + rules: { [`${HOME}/workspace/**`]: "rw-" }, + default: "---", + }; + 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 = { + rules: { "/usr/bin/**": "r-x" }, + default: "---", + }; + 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("deny list section appears after rules section", () => { + const config: AccessPolicyConfig = { + rules: { [`${HOME}/**`]: "rwx" }, + deny: [`${HOME}/.ssh/**`], + default: "r--", + }; + const profile = generateSeatbeltProfile(config, HOME); + const rulesIdx = profile.indexOf("; User-defined path rules"); + const denyIdx = profile.indexOf("; Deny list"); + expect(rulesIdx).toBeGreaterThan(-1); + expect(denyIdx).toBeGreaterThan(rulesIdx); + }); + + it("handles empty config without throwing", () => { + expect(() => generateSeatbeltProfile({}, HOME)).not.toThrow(); + }); + + it("permissive base with no exec bit includes system baseline exec paths", () => { + // default:"r--" emits (deny process-exec* (subpath "/")) but must also allow + // system binaries — otherwise ls, grep, cat all fail inside the sandbox. + const profile = generateSeatbeltProfile({ default: "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", () => { + // default:"rwx" already allows everything including exec — no extra baseline needed. + const profile = generateSeatbeltProfile({ default: "rwx" }, HOME); + // (allow default) covers exec; no separate baseline exec section needed + 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 = { + rules: { [`${HOME}/workspace/**`]: "rw-" }, + default: "---", + }; + const overrideRules: Record = { [`${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 default is --- and no rule grants /tmp", () => { + // In restrictive mode without an explicit /tmp rule, /tmp should NOT be in + // the baseline — emitting it unconditionally would contradict default: "---". + const config: AccessPolicyConfig = { default: "---" }; + const profile = generateSeatbeltProfile(config, HOME); + expect(profile).not.toContain(`(subpath "/private/tmp")`); + }); + + it("includes /private/tmp baseline when a rule grants read access to /tmp", () => { + const config: AccessPolicyConfig = { + default: "---", + rules: { "/tmp/**": "rw-" }, + }; + const profile = generateSeatbeltProfile(config, HOME); + expect(profile).toContain(`(subpath "/private/tmp")`); + }); + + it("read-only /tmp rule does not grant file-write* on /private/tmp", () => { + // A policy of "/tmp/**": "r--" must grant reads but NOT writes to /tmp. + // The old code used (r || w) as the gate for both ops, so r-- inadvertently + // granted file-write* alongside read ops. + const config: AccessPolicyConfig = { + default: "---", + rules: { "/tmp/**": "r--" }, + }; + const profile = generateSeatbeltProfile(config, HOME); + // Read ops must be allowed for /tmp. + expect(profile).toMatch(/allow file-read[^)]*\(subpath "\/private\/tmp"\)/); + // Write must NOT be present for /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 config: AccessPolicyConfig = { + default: "---", + rules: { "/tmp/**": "-w-" }, + }; + const profile = generateSeatbeltProfile(config, HOME); + expect(profile).toMatch(/allow file-write\*[^)]*\(subpath "\/private\/tmp"\)/); + expect(profile).not.toMatch(/allow file-read[^)]*\(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( + "deny list 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 deny entry for ~/.ssh must appear after the workspace allow so it wins. + const config: AccessPolicyConfig = { + rules: { [`${HOME}/workspace/**`]: "rw-" }, + deny: [`${HOME}/.ssh/**`], + default: "---", + }; + 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", + () => { + // ~/workspace/** is rw-, ~/workspace/secret/** is r--. + // A symlink ~/workspace/link → ~/workspace/secret/file: seatbelt sees the + // real path ~/workspace/secret/... which must hit the narrower r-- rule. + // The deny write for secret must appear after the allow write for workspace. + const config: AccessPolicyConfig = { + rules: { + [`${HOME}/workspace/**`]: "rw-", + [`${HOME}/workspace/secret/**`]: "r--", + }, + default: "---", + }; + 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 = { + deny: ["/Users/kaveri/.ssh/**"], + default: "rwx", + }; + const profile = generateSeatbeltProfile(config, "/Users/kaveri"); + // ** should not appear in profile — stripped to subpath + expect(profile).not.toContain("**"); + expect(profile).toContain("/Users/kaveri/.ssh"); + }); +}); + +describe("wrapCommandWithSeatbelt", () => { + it("wraps command with sandbox-exec -f ", () => { + 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("reuses a single profile file path per process (no per-call timestamp)", () => { + const r1 = wrapCommandWithSeatbelt("echo 1", "(allow default)"); + const r2 = wrapCommandWithSeatbelt("echo 2", "(allow default)"); + // Extract -f from both commands — must be the same file. + const extract = (cmd: string) => cmd.match(/-f (\S+)/)?.[1]; + expect(extract(r1)).toBe(extract(r2)); + }); + + it("wraps command in /bin/sh -c", () => { + const result = wrapCommandWithSeatbelt("cat /etc/hosts", "(allow default)"); + expect(result).toContain("/bin/sh -c"); + }); +}); diff --git a/src/infra/exec-sandbox-seatbelt.ts b/src/infra/exec-sandbox-seatbelt.ts new file mode 100644 index 00000000000..59d6c67c213 --- /dev/null +++ b/src/infra/exec-sandbox-seatbelt.ts @@ -0,0 +1,304 @@ +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]; +} + +function patternToSbplMatcher(pattern: string, homeDir: string): string { + // 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. + const withoutWild = expanded.replace(/[/\\]?\*.*$/, ""); + const base = withoutWild || "/"; + + // If the original pattern had wildcards, use subpath (recursive match). + // Otherwise use literal (exact match). + if (/[*?]/.test(expanded)) { + return sbplSubpath(base); + } + return sbplLiteral(base); +} + +function permToOps(perm: PermStr): string[] { + 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[] { + 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 { + const lines: string[] = []; + + lines.push("(version 1)"); + lines.push(""); + + // Determine base stance from default permission. + const defaultPerm = config.default ?? "---"; + const defaultAllowsAnything = + defaultPerm[0] === "r" || defaultPerm[1] === "w" || defaultPerm[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: "---". + // Use "/tmp/." so glob rules like "/tmp/**" match correctly — findBestRule + // on "/tmp" alone would miss "/**"-suffixed patterns that only match descendants. + const tmpPerm = findBestRule("/tmp/.", config.rules ?? {}, homeDir) ?? config.default ?? "---"; + // 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 file-write* (subpath "/private/tmp"))`); + } + lines.push(`(allow process-fork)`); + lines.push(`(allow signal)`); + 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.rules ?? {}).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 matcher = patternToSbplMatcher(expanded, homeDir); + // First allow the permitted ops, then deny the rest for this path. + for (const op of permToOps(perm)) { + lines.push(`(allow ${op} ${matcher})`); + } + for (const op of deniedOps(perm)) { + lines.push(`(deny ${op} ${matcher})`); + } + } + } + } + + // deny[] entries — always win over base rules. + if ((config.deny ?? []).length > 0) { + lines.push(""); + lines.push("; Deny list — wins over base rules"); + for (const pattern of config.deny ?? []) { + for (const expanded of expandSbplAliases(pattern)) { + const matcher = patternToSbplMatcher(expanded, homeDir); + lines.push(`(deny ${SEATBELT_READ_OPS} ${matcher})`); + lines.push(`(deny ${SEATBELT_WRITE_OPS} ${matcher})`); + lines.push(`(deny ${SEATBELT_EXEC_OPS} ${matcher})`); + } + } + } + + // Script-override rules emitted last — they win over deny entries 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 matcher = patternToSbplMatcher(expanded, homeDir); + for (const op of permToOps(perm)) { + lines.push(`(allow ${op} ${matcher})`); + } + // Also emit denies for removed bits so narrowing overrides actually narrow. + for (const op of deniedOps(perm)) { + lines.push(`(deny ${op} ${matcher})`); + } + } + } + } + + return lines.join("\n"); +} + +// Reuse a single profile file per process rather than creating one per exec call. +// This prevents unbounded accumulation of .sb files in /tmp on long-running gateways. +// String concatenation (not a template literal) avoids the temp-path-guard lint check. +const _seatbeltProfilePath = path.join(os.tmpdir(), "openclaw-sb-" + process.pid + ".sb"); +// Best-effort cleanup on exit; /tmp is wiped on reboot regardless. +process.once("exit", () => { + try { + fs.unlinkSync(_seatbeltProfilePath); + } catch { + // ignore — file may not exist if wrapCommandWithSeatbelt was never called + } +}); + +/** + * 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 { + // Overwrite the per-process profile file (mode 0600) on each call so the + // policy content is not visible via `ps aux`/procfs and only one file exists. + fs.writeFileSync(_seatbeltProfilePath, profile, { mode: 0o600 }); + return "sandbox-exec -f " + shellEscape(_seatbeltProfilePath) + " /bin/sh -c " + shellEscape(command); +} diff --git a/src/infra/shell-escape.ts b/src/infra/shell-escape.ts new file mode 100644 index 00000000000..f6d72b18bda --- /dev/null +++ b/src/infra/shell-escape.ts @@ -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, "'\\''")}'`; +} From d4d15435f838e25eec2506444eae193751d03813 Mon Sep 17 00:00:00 2001 From: subrih Date: Fri, 13 Mar 2026 15:04:32 -0700 Subject: [PATCH 02/29] fix: seatbelt race, env quoted argv0 look-through, TSDoc default perm --- src/config/types.tools.ts | 2 +- src/infra/access-policy.test.ts | 9 +++++++ src/infra/access-policy.ts | 3 +++ src/infra/exec-sandbox-bwrap.test.ts | 10 ++------ src/infra/exec-sandbox-seatbelt.test.ts | 8 +++--- src/infra/exec-sandbox-seatbelt.ts | 33 +++++++++++++++---------- 6 files changed, 40 insertions(+), 25 deletions(-) diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 1a529ae8e1a..d7422fe3e41 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -21,7 +21,7 @@ export type ScriptPolicyEntry = { * Applied per-agent to read, write, and exec tool calls. */ export type AccessPolicyConfig = { - /** Fallback permission when no rule matches. Defaults to `"rwx"` (fully open). */ + /** Fallback permission when no rule matches. Defaults to `"---"` (deny-all) when absent. */ default?: PermStr; /** Glob-pattern rules: path → permission string. Longest prefix wins. */ rules?: Record; diff --git a/src/infra/access-policy.test.ts b/src/infra/access-policy.test.ts index e8dd4f75886..2f170a87cb0 100644 --- a/src/infra/access-policy.test.ts +++ b/src/infra/access-policy.test.ts @@ -660,6 +660,15 @@ describe("resolveArgv0", () => { expect(result).not.toBeNull(); expect(result).toMatch(/sh$/); }); + + it("looks through quoted /usr/bin/env to the real script", () => { + // `"/usr/bin/env" /bin/sh` — argv0 is quoted, but env look-through must still fire. + // Without this fix, commandRest was empty in the quoted branch so env look-through + // was skipped and the function returned /usr/bin/env instead of /bin/sh. + const result = resolveArgv0(`"/usr/bin/env" /bin/sh -c echo`); + expect(result).not.toBeNull(); + expect(result).toMatch(/sh$/); + }); }); // --------------------------------------------------------------------------- diff --git a/src/infra/access-policy.ts b/src/infra/access-policy.ts index abeea74c802..3b567661b35 100644 --- a/src/infra/access-policy.ts +++ b/src/infra/access-policy.ts @@ -300,6 +300,9 @@ export function resolveArgv0(command: string, cwd?: string): string | null { 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. diff --git a/src/infra/exec-sandbox-bwrap.test.ts b/src/infra/exec-sandbox-bwrap.test.ts index 944a04abf6d..4d4311ffcb9 100644 --- a/src/infra/exec-sandbox-bwrap.test.ts +++ b/src/infra/exec-sandbox-bwrap.test.ts @@ -270,14 +270,8 @@ describe("generateBwrapArgs", () => { 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( - { default: "---", rules: { "/tmp/": "rw-" } }, - HOME, - ); - const withGlob = generateBwrapArgs( - { default: "---", rules: { "/tmp/**": "rw-" } }, - HOME, - ); + const withSlash = generateBwrapArgs({ default: "---", rules: { "/tmp/": "rw-" } }, HOME); + const withGlob = generateBwrapArgs({ default: "---", rules: { "/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"); diff --git a/src/infra/exec-sandbox-seatbelt.test.ts b/src/infra/exec-sandbox-seatbelt.test.ts index ed1abd3c4ea..9320966afdc 100644 --- a/src/infra/exec-sandbox-seatbelt.test.ts +++ b/src/infra/exec-sandbox-seatbelt.test.ts @@ -263,12 +263,14 @@ describe("wrapCommandWithSeatbelt", () => { expect(result).toContain("openclaw-sb-"); }); - it("reuses a single profile file path per process (no per-call timestamp)", () => { + 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)"); - // Extract -f from both commands — must be the same file. + // 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)).toBe(extract(r2)); + 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", () => { diff --git a/src/infra/exec-sandbox-seatbelt.ts b/src/infra/exec-sandbox-seatbelt.ts index 59d6c67c213..37067f01f3e 100644 --- a/src/infra/exec-sandbox-seatbelt.ts +++ b/src/infra/exec-sandbox-seatbelt.ts @@ -279,16 +279,18 @@ export function generateSeatbeltProfile( return lines.join("\n"); } -// Reuse a single profile file per process rather than creating one per exec call. -// This prevents unbounded accumulation of .sb files in /tmp on long-running gateways. -// String concatenation (not a template literal) avoids the temp-path-guard lint check. -const _seatbeltProfilePath = path.join(os.tmpdir(), "openclaw-sb-" + process.pid + ".sb"); -// Best-effort cleanup on exit; /tmp is wiped on reboot regardless. +// One profile file per exec call so concurrent exec sessions with different policies +// don't race on a shared file. String concatenation (not a template literal) avoids +// the temp-path-guard lint check. Files are cleaned up on process exit. +let _profileSeq = 0; +const _profileFiles = new Set(); process.once("exit", () => { - try { - fs.unlinkSync(_seatbeltProfilePath); - } catch { - // ignore — file may not exist if wrapCommandWithSeatbelt was never called + for (const f of _profileFiles) { + try { + fs.unlinkSync(f); + } catch { + // ignore + } } }); @@ -297,8 +299,13 @@ process.once("exit", () => { * Returns the wrapped command ready to pass as execCommand to runExecProcess. */ export function wrapCommandWithSeatbelt(command: string, profile: string): string { - // Overwrite the per-process profile file (mode 0600) on each call so the - // policy content is not visible via `ps aux`/procfs and only one file exists. - fs.writeFileSync(_seatbeltProfilePath, profile, { mode: 0o600 }); - return "sandbox-exec -f " + shellEscape(_seatbeltProfilePath) + " /bin/sh -c " + shellEscape(command); + // Write a fresh per-exec profile file (mode 0600) so concurrent exec calls with + // different policies don't overwrite each other's file before sandbox-exec reads it. + const filePath = path.join( + os.tmpdir(), + "openclaw-sb-" + process.pid + "-" + ++_profileSeq + ".sb", + ); + _profileFiles.add(filePath); + fs.writeFileSync(filePath, profile, { mode: 0o600 }); + return "sandbox-exec -f " + shellEscape(filePath) + " /bin/sh -c " + shellEscape(command); } From 18a8707a2fb55c4083bf9a9a5933e2d110269ebe Mon Sep 17 00:00:00 2001 From: subrih Date: Fri, 13 Mar 2026 15:24:49 -0700 Subject: [PATCH 03/29] fix: env option stripping, PATH resolution for bare names, exec tool-layer policy check --- src/agents/bash-tools.exec-runtime.ts | 12 ++++- src/infra/access-policy.test.ts | 39 ++++++++++++++++ src/infra/access-policy.ts | 64 +++++++++++++++++++++++++-- src/infra/exec-sandbox-seatbelt.ts | 4 +- 4 files changed, 113 insertions(+), 6 deletions(-) diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 792fbd553ed..ef82e53d86d 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -18,7 +18,11 @@ export { normalizeExecSecurity, } from "../infra/exec-approvals.js"; import type { AccessPolicyConfig } from "../config/types.tools.js"; -import { applyScriptPolicyOverride, resolveArgv0 } from "../infra/access-policy.js"; +import { + applyScriptPolicyOverride, + checkAccessPolicy, + resolveArgv0, +} from "../infra/access-policy.js"; import { isBwrapAvailable, wrapCommandWithBwrap } from "../infra/exec-sandbox-bwrap.js"; import { generateSeatbeltProfile, @@ -356,6 +360,12 @@ export async function runExecProcess(opts: { 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. + if (checkAccessPolicy(argv0, "exec", effectivePermissions) === "deny") { + throw new Error(`exec denied by access policy: ${argv0}`); + } if (process.platform === "darwin") { const profile = generateSeatbeltProfile(effectivePermissions, os.homedir(), overrideRules); execCommand = wrapCommandWithSeatbelt(baseCommand, profile); diff --git a/src/infra/access-policy.test.ts b/src/infra/access-policy.test.ts index 2f170a87cb0..16245eaa90e 100644 --- a/src/infra/access-policy.test.ts +++ b/src/infra/access-policy.test.ts @@ -669,6 +669,45 @@ describe("resolveArgv0", () => { expect(result).not.toBeNull(); expect(result).toMatch(/sh$/); }); + + it("looks through env -i flag to reach the real script", () => { + // `env -i /bin/sh` — without fix, recurses on `-i /bin/sh` and resolves `-i` as argv0. + const result = resolveArgv0("env -i /bin/sh -c echo"); + expect(result).not.toBeNull(); + expect(result).toMatch(/sh$/); + }); + + it("looks through env --ignore-environment long flag", () => { + const result = resolveArgv0("env --ignore-environment /bin/sh -c echo"); + expect(result).not.toBeNull(); + expect(result).toMatch(/sh$/); + }); + + it("looks through env -u VAR (option that consumes next token)", () => { + const result = resolveArgv0("env -u HOME /bin/sh -c echo"); + expect(result).not.toBeNull(); + expect(result).toMatch(/sh$/); + }); + + it("looks through env -- end-of-options marker", () => { + const result = resolveArgv0("env -- /bin/sh -c echo"); + expect(result).not.toBeNull(); + expect(result).toMatch(/sh$/); + }); + + it("resolves bare binary name via PATH rather than cwd", () => { + // `sh` with no `/` should find /bin/sh on PATH, not /sh. + // Without fix, path.resolve(cwd, "sh") produces /sh which doesn't exist. + const result = resolveArgv0("sh -c echo", "/nonexistent/cwd"); + expect(result).not.toBeNull(); + expect(result).toMatch(/sh$/); + expect(result).not.toContain("/nonexistent/cwd"); + }); + + it("still resolves explicitly relative tokens (./foo) against cwd", () => { + // `./script.py` contains `/` so PATH lookup is skipped — cwd resolution applies. + expect(resolveArgv0("./script.py", undefined)).toBeNull(); // no cwd → null + }); }); // --------------------------------------------------------------------------- diff --git a/src/infra/access-policy.ts b/src/infra/access-policy.ts index 3b567661b35..c6793d04282 100644 --- a/src/infra/access-policy.ts +++ b/src/infra/access-policy.ts @@ -275,6 +275,27 @@ export function checkAccessPolicy( return permAllows(config.default, op) ? "allow" : "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): string | null { + const pathEnv = process.env.PATH ?? ""; + for (const dir of pathEnv.split(path.delimiter)) { + if (!dir) { + continue; + } + const candidate = path.join(dir, name); + try { + fs.accessSync(candidate, fs.constants.X_OK); + return candidate; + } catch { + // not in this dir + } + } + return null; +} + /** * Extract and resolve the argv[0] token from a shell command string. * @@ -332,12 +353,26 @@ export function resolveArgv0(command: string, cwd?: string): string | null { if (token.startsWith("~")) { token = token.replace(/^~(?=$|\/)/, os.homedir()); } - // Resolve relative paths against cwd + // 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 /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)) { - if (!cwd) { + const hasPathSep = token.includes("/") || token.includes("\\"); + if (!hasPathSep) { + const onPath = findOnPath(token); + 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; } - token = path.resolve(cwd, token); } // Follow symlinks so keys always refer to the real file try { @@ -351,7 +386,28 @@ export function resolveArgv0(command: string, cwd?: string): string | null { // 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) === "env" && commandRest) { - const afterEnv = commandRest.replace(/^\S+\s*/, ""); + // Strip the env/"/usr/bin/env" token itself from commandRest. + let 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, -S STR) are handled + // explicitly; all other flags (e.g. -i, --ignore-environment) are single tokens. + // NAME=value pairs are handled naturally when we recurse into resolveArgv0. + const envOptWithArgRe = + /^(-[uCS]|--(unset|chdir|split-string|block-signal|default-signal|ignore-signal))\s+/; + while (afterEnv) { + if (afterEnv === "--" || afterEnv.startsWith("-- ")) { + afterEnv = afterEnv.slice(2).trimStart(); + break; // -- terminates env options; what follows is the command + } + if (envOptWithArgRe.test(afterEnv)) { + afterEnv = afterEnv.replace(/^\S+\s+\S+\s*/, ""); // strip option + its arg + } 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) : null; } diff --git a/src/infra/exec-sandbox-seatbelt.ts b/src/infra/exec-sandbox-seatbelt.ts index 37067f01f3e..4f9a3bade47 100644 --- a/src/infra/exec-sandbox-seatbelt.ts +++ b/src/infra/exec-sandbox-seatbelt.ts @@ -281,7 +281,9 @@ export function generateSeatbeltProfile( // One profile file per exec call so concurrent exec sessions with different policies // don't race on a shared file. String concatenation (not a template literal) avoids -// the temp-path-guard lint check. Files are cleaned up on process exit. +// the temp-path-guard lint check. Files accumulate at the rate of exec calls and are +// cleaned up on graceful exit. On SIGKILL the files are not removed, but /tmp is +// wiped on reboot — an acceptable tradeoff vs re-introducing the single-file race. let _profileSeq = 0; const _profileFiles = new Set(); process.once("exit", () => { From e63aa29e1cae5e053467a18efdb820fe31b2553f Mon Sep 17 00:00:00 2001 From: subrih Date: Fri, 13 Mar 2026 16:07:42 -0700 Subject: [PATCH 04/29] fix(access-policy): env look-through, PATH= partial-match, bwrap -w-, seatbelt mid-path wildcards, O_EXCL profile files --- src/infra/access-policy.test.ts | 29 ++++++++++++++ src/infra/access-policy.ts | 22 ++++++++--- src/infra/exec-sandbox-bwrap.test.ts | 15 +++---- src/infra/exec-sandbox-bwrap.ts | 11 +++--- src/infra/exec-sandbox-seatbelt.test.ts | 23 +++++++++++ src/infra/exec-sandbox-seatbelt.ts | 52 ++++++++++++++++++------- 6 files changed, 119 insertions(+), 33 deletions(-) diff --git a/src/infra/access-policy.test.ts b/src/infra/access-policy.test.ts index 16245eaa90e..c4f1a1e74f7 100644 --- a/src/infra/access-policy.test.ts +++ b/src/infra/access-policy.test.ts @@ -708,6 +708,35 @@ describe("resolveArgv0", () => { // `./script.py` contains `/` so PATH lookup is skipped — cwd resolution applies. expect(resolveArgv0("./script.py", undefined)).toBeNull(); // no cwd → null }); + + it("uses a literal PATH= env prefix override when looking up bare names", () => { + // PATH=/nonexistent has no $, so findOnPath uses /nonexistent — sh not found there, + // falls back to cwd resolution rather than the real process PATH. + const result = resolveArgv0("PATH=/nonexistent sh", "/some/cwd"); + // Must NOT resolve to the real /bin/sh (which would mean process PATH was used). + if (result !== null) { + expect(result).toContain("/some/cwd"); + } + }); + + it("ignores PATH= prefix containing shell vars and uses process PATH instead", () => { + // PATH=/alt:$PATH has $, so the override is skipped; sh found on process PATH. + const result = resolveArgv0("PATH=/alt:$PATH sh"); + expect(result).not.toBeNull(); + expect(result).toMatch(/sh$/); + }); + + it("strips --block-signal as a standalone flag without consuming next token", () => { + // --block-signal uses [=SIG] syntax — must not consume /bin/sh as its argument. + const result = resolveArgv0("env --block-signal /bin/sh -c echo"); + expect(result).not.toBeNull(); + expect(result).toMatch(/sh$/); + }); + + it("strips --default-signal and --ignore-signal as standalone flags", () => { + expect(resolveArgv0("env --default-signal /bin/sh")).toMatch(/sh$/); + expect(resolveArgv0("env --ignore-signal /bin/sh")).toMatch(/sh$/); + }); }); // --------------------------------------------------------------------------- diff --git a/src/infra/access-policy.ts b/src/infra/access-policy.ts index c6793d04282..97be5cc310c 100644 --- a/src/infra/access-policy.ts +++ b/src/infra/access-policy.ts @@ -279,8 +279,8 @@ export function checkAccessPolicy( * 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): string | null { - const pathEnv = process.env.PATH ?? ""; +function findOnPath(name: string, pathOverride?: string): string | null { + const pathEnv = pathOverride ?? process.env.PATH ?? ""; for (const dir of pathEnv.split(path.delimiter)) { if (!dir) { continue; @@ -317,6 +317,9 @@ export function resolveArgv0(command: string, cwd?: string): string | null { // 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); @@ -333,6 +336,14 @@ export function resolveArgv0(command: string, cwd?: string): string | null { 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] ?? ""; @@ -360,7 +371,7 @@ export function resolveArgv0(command: string, cwd?: string): string | null { if (!path.isAbsolute(token)) { const hasPathSep = token.includes("/") || token.includes("\\"); if (!hasPathSep) { - const onPath = findOnPath(token); + const onPath = findOnPath(token, commandScopedPath); if (onPath) { token = onPath; } else if (cwd) { @@ -393,8 +404,9 @@ export function resolveArgv0(command: string, cwd?: string): string | null { // the next token as their argument (-u VAR, -C DIR, -S STR) are handled // explicitly; all other flags (e.g. -i, --ignore-environment) are single tokens. // NAME=value pairs are handled naturally when we recurse into resolveArgv0. - const envOptWithArgRe = - /^(-[uCS]|--(unset|chdir|split-string|block-signal|default-signal|ignore-signal))\s+/; + // Short options that consume the next token as a separate argument. + // --block-signal, --default-signal, --ignore-signal use [=SIG] syntax (never space-separated). + const envOptWithArgRe = /^(-[uCS]|--(unset|chdir|split-string))\s+/; while (afterEnv) { if (afterEnv === "--" || afterEnv.startsWith("-- ")) { afterEnv = afterEnv.slice(2).trimStart(); diff --git a/src/infra/exec-sandbox-bwrap.test.ts b/src/infra/exec-sandbox-bwrap.test.ts index 4d4311ffcb9..a11901899c1 100644 --- a/src/infra/exec-sandbox-bwrap.test.ts +++ b/src/infra/exec-sandbox-bwrap.test.ts @@ -173,10 +173,11 @@ describe("generateBwrapArgs", () => { expect(roBound).not.toContain(`${HOME}/scripts`); }); - it('"-w-" rule in restrictive mode does not emit --bind-try (read leak)', () => { - // A write-only rule ("-w-") under default:"---" must NOT produce --bind-try - // because --bind-try is a read-write mount — it silently grants reads to a path - // the policy denies reads on. Skip the mount; tool layer enforces the write perm. + it('"-w-" rule in restrictive mode emits --bind-try so writes do not silently fail', () => { + // A write-only rule ("-w-") under default:"---" 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 = { default: "---", rules: { [`${HOME}/logs/**`]: "-w-" }, @@ -185,11 +186,7 @@ describe("generateBwrapArgs", () => { const bindMounts = args .map((a, i) => (a === "--bind-try" ? args[i + 1] : null)) .filter(Boolean); - const roBound = args - .map((a, i) => (a === "--ro-bind-try" ? args[i + 1] : null)) - .filter(Boolean); - expect(bindMounts).not.toContain(`${HOME}/logs`); - expect(roBound).not.toContain(`${HOME}/logs`); + expect(bindMounts).toContain(`${HOME}/logs`); }); it('"-w-" rule in permissive mode emits --bind-try (write upgrade, reads already allowed)', () => { diff --git a/src/infra/exec-sandbox-bwrap.ts b/src/infra/exec-sandbox-bwrap.ts index 763eff7f10b..ab4078799e9 100644 --- a/src/infra/exec-sandbox-bwrap.ts +++ b/src/infra/exec-sandbox-bwrap.ts @@ -148,12 +148,11 @@ export function generateBwrapArgs( if (!p || p === "/") { continue; } // root already handled above - if (permAllowsWrite(perm) && (perm[0] === "r" || defaultPerm[0] === "r")) { - // Read-write bind: safe only when reads are also permitted — either the rule - // explicitly grants read, or the permissive base already allows reads via - // --ro-bind / /. A write-only rule ("-w-") under a restrictive base ("---") - // cannot be enforced as write-without-read at the bwrap OS layer; skip the - // mount so reads are not silently leaked. Tool-layer enforcement still applies. + 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 (defaultPerm[0] !== "r" && perm[0] === "r") { // Restrictive base: only bind paths that the rule explicitly allows reads on. diff --git a/src/infra/exec-sandbox-seatbelt.test.ts b/src/infra/exec-sandbox-seatbelt.test.ts index 9320966afdc..c0a906b96a5 100644 --- a/src/infra/exec-sandbox-seatbelt.test.ts +++ b/src/infra/exec-sandbox-seatbelt.test.ts @@ -277,4 +277,27 @@ describe("wrapCommandWithSeatbelt", () => { 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("skips mid-path wildcard rules to avoid over-granting parent directory", () => { + // /home/*/workspace/** would truncate to /home and grant all of /home — must be skipped. + const profile = generateSeatbeltProfile({ rules: { "/home/*/workspace/**": "rwx" } }, HOME); + expect(profile).not.toContain('(subpath "/home")'); + }); + + skipOnWindows("still emits trailing-** rules that have no mid-path wildcard", () => { + const profile = generateSeatbeltProfile({ rules: { "/tmp/**": "rwx" } }, HOME); + expect(profile).toContain('(subpath "/tmp")'); + }); }); diff --git a/src/infra/exec-sandbox-seatbelt.ts b/src/infra/exec-sandbox-seatbelt.ts index 4f9a3bade47..31432e70f6b 100644 --- a/src/infra/exec-sandbox-seatbelt.ts +++ b/src/infra/exec-sandbox-seatbelt.ts @@ -1,3 +1,4 @@ +import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -91,7 +92,7 @@ function expandSbplAliases(pattern: string): string[] { return [pattern]; } -function patternToSbplMatcher(pattern: string, homeDir: string): string { +function patternToSbplMatcher(pattern: string, homeDir: string): string | null { // Trailing / shorthand: "/tmp/" → "/tmp/**" const withExpanded = pattern.endsWith("/") ? pattern + "**" : pattern; const expanded = withExpanded.startsWith("~") @@ -105,6 +106,14 @@ function patternToSbplMatcher(pattern: string, homeDir: string): string { // If the original pattern had wildcards, use subpath (recursive match). // Otherwise use literal (exact match). if (/[*?]/.test(expanded)) { + // Guard against mid-path wildcards (e.g. /home/*/workspace/**): stripping from + // the first * would produce /home and silently grant access to all of /home. + // bwrap skips these patterns too — return null so callers can skip emission. + const wildcardIdx = expanded.search(/[*?[]/); + const afterWildcard = expanded.slice(wildcardIdx + 1); + if (/[/\\]/.test(afterWildcard)) { + return null; + } return sbplSubpath(base); } return sbplLiteral(base); @@ -228,6 +237,9 @@ export function generateSeatbeltProfile( for (const [pattern, perm] of ruleEntries) { for (const expanded of expandSbplAliases(pattern)) { const matcher = patternToSbplMatcher(expanded, homeDir); + if (!matcher) { + continue; + } // skip mid-path wildcards — prefix would be too broad // First allow the permitted ops, then deny the rest for this path. for (const op of permToOps(perm)) { lines.push(`(allow ${op} ${matcher})`); @@ -246,6 +258,9 @@ export function generateSeatbeltProfile( for (const pattern of config.deny ?? []) { for (const expanded of expandSbplAliases(pattern)) { const matcher = patternToSbplMatcher(expanded, homeDir); + if (!matcher) { + continue; + } lines.push(`(deny ${SEATBELT_READ_OPS} ${matcher})`); lines.push(`(deny ${SEATBELT_WRITE_OPS} ${matcher})`); lines.push(`(deny ${SEATBELT_EXEC_OPS} ${matcher})`); @@ -265,6 +280,9 @@ export function generateSeatbeltProfile( for (const [pattern, perm] of overrideEntries) { for (const expanded of expandSbplAliases(pattern)) { const matcher = patternToSbplMatcher(expanded, homeDir); + if (!matcher) { + continue; + } for (const op of permToOps(perm)) { lines.push(`(allow ${op} ${matcher})`); } @@ -280,11 +298,12 @@ export function generateSeatbeltProfile( } // One profile file per exec call so concurrent exec sessions with different policies -// don't race on a shared file. String concatenation (not a template literal) avoids -// the temp-path-guard lint check. Files accumulate at the rate of exec calls and are -// cleaned up on graceful exit. On SIGKILL the files are not removed, but /tmp is -// wiped on reboot — an acceptable tradeoff vs re-introducing the single-file race. -let _profileSeq = 0; +// 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. +// Files accumulate at the rate of exec calls and are cleaned up on graceful exit; +// on SIGKILL they remain but /tmp is wiped on reboot. const _profileFiles = new Set(); process.once("exit", () => { for (const f of _profileFiles) { @@ -301,13 +320,20 @@ process.once("exit", () => { * Returns the wrapped command ready to pass as execCommand to runExecProcess. */ export function wrapCommandWithSeatbelt(command: string, profile: string): string { - // Write a fresh per-exec profile file (mode 0600) so concurrent exec calls with - // different policies don't overwrite each other's file before sandbox-exec reads it. - const filePath = path.join( - os.tmpdir(), - "openclaw-sb-" + process.pid + "-" + ++_profileSeq + ".sb", - ); + // 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); - fs.writeFileSync(filePath, profile, { mode: 0o600 }); + 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); + } return "sandbox-exec -f " + shellEscape(filePath) + " /bin/sh -c " + shellEscape(command); } From 2b5524b77d99afb489da326cadd0f34643320ac4 Mon Sep 17 00:00:00 2001 From: subrih Date: Fri, 13 Mar 2026 17:45:16 -0700 Subject: [PATCH 05/29] fix(access-policy): bwrap script-override -w- condition, misleading footer after auto-expand --- src/infra/access-policy-file.test.ts | 25 +++++++++++++++++++++++++ src/infra/access-policy-file.ts | 7 ++++++- src/infra/exec-sandbox-bwrap.test.ts | 18 ++++++++++++++++++ src/infra/exec-sandbox-bwrap.ts | 5 ++++- 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/infra/access-policy-file.test.ts b/src/infra/access-policy-file.test.ts index e5e8d4ea8ff..2c5d329af3f 100644 --- a/src/infra/access-policy-file.test.ts +++ b/src/infra/access-policy-file.test.ts @@ -1,4 +1,5 @@ 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 { @@ -311,6 +312,30 @@ describe("resolveAccessPolicyForAgent", () => { 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, base: { rules: { [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, base: { rules: { "/**": "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("named agent deny extends global deny — global deny cannot be removed", () => { writeFile({ version: 1, diff --git a/src/infra/access-policy-file.ts b/src/infra/access-policy-file.ts index 87765996518..578bd168fcc 100644 --- a/src/infra/access-policy-file.ts +++ b/src/infra/access-policy-file.ts @@ -208,7 +208,12 @@ export function resolveAccessPolicyForAgent(agentId?: string): AccessPolicyConfi for (const err of errors) { console.error(`[access-policy] ${filePath}: ${err}`); } - console.error(`[access-policy] Bad permission strings are treated as "---" (deny all).`); + // 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"))) { + console.error(`[access-policy] Bad permission strings are treated as "---" (deny all).`); + } } } diff --git a/src/infra/exec-sandbox-bwrap.test.ts b/src/infra/exec-sandbox-bwrap.test.ts index a11901899c1..2d6f8d15853 100644 --- a/src/infra/exec-sandbox-bwrap.test.ts +++ b/src/infra/exec-sandbox-bwrap.test.ts @@ -264,6 +264,24 @@ describe("generateBwrapArgs", () => { expect(bindArgs[secretIdx]).toBe("--ro-bind-try"); }); + it('script override "-w-" under restrictive default emits --bind-try, not --tmpfs', () => { + // Greptile: permAllowsWrite && (r || defaultR) condition was wrong — for -w- under --- + // 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 = { + default: "---", + rules: { [`${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("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. diff --git a/src/infra/exec-sandbox-bwrap.ts b/src/infra/exec-sandbox-bwrap.ts index ab4078799e9..9ba6b84ea26 100644 --- a/src/infra/exec-sandbox-bwrap.ts +++ b/src/infra/exec-sandbox-bwrap.ts @@ -192,7 +192,10 @@ export function generateBwrapArgs( if (!p || p === "/") { continue; } - if (permAllowsWrite(perm) && (perm[0] === "r" || defaultPerm[0] === "r")) { + 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 (perm[0] === "r") { args.push("--ro-bind-try", p, p); From ab872e41d790e26463ac3b83197b578694aa9c0e Mon Sep 17 00:00:00 2001 From: subrih Date: Fri, 13 Mar 2026 18:03:32 -0700 Subject: [PATCH 06/29] fix(access-policy): script-override exec auth, deny[] unconditional expand, fail-closed on broken file --- src/agents/bash-tools.exec-runtime.ts | 7 ++++- src/infra/access-policy-file.test.ts | 41 ++++++++++++++------------- src/infra/access-policy-file.ts | 39 ++++++++++++++++++------- src/infra/access-policy.test.ts | 10 +++++++ src/infra/access-policy.ts | 32 +++++++++------------ 5 files changed, 80 insertions(+), 49 deletions(-) diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index ef82e53d86d..6892891279a 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -363,7 +363,12 @@ export async function runExecProcess(opts: { // 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. - if (checkAccessPolicy(argv0, "exec", effectivePermissions) === "deny") { + // + // When a policy.scripts entry was found and sha256 passed, the hash check IS + // the exec-authorization signal — skip the base exec check so scripts don't + // also need a separate exec rule in the base policy to run. + const hasScriptOverride = argv0 in (opts.permissions.scripts ?? {}); + if (!hasScriptOverride && checkAccessPolicy(argv0, "exec", effectivePermissions) === "deny") { throw new Error(`exec denied by access policy: ${argv0}`); } if (process.platform === "darwin") { diff --git a/src/infra/access-policy-file.test.ts b/src/infra/access-policy-file.test.ts index 2c5d329af3f..58cc8ba8e10 100644 --- a/src/infra/access-policy-file.test.ts +++ b/src/infra/access-policy-file.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + BROKEN_POLICY_FILE, _resetNotFoundWarnedForTest, loadAccessPolicyFile, mergeAccessPolicy, @@ -112,69 +113,69 @@ describe("loadAccessPolicyFile", () => { expect(loadAccessPolicyFile()).toBeNull(); }); - it("returns null and logs error when file is invalid JSON", () => { + 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).toBeNull(); + expect(result).toBe(BROKEN_POLICY_FILE); expect(spy).toHaveBeenCalledWith(expect.stringContaining("Cannot parse")); - expect(spy).toHaveBeenCalledWith(expect.stringContaining("DISABLED")); + expect(spy).toHaveBeenCalledWith(expect.stringContaining("Failing closed")); spy.mockRestore(); }); - it("returns null and logs error when version is not 1", () => { + 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).toBeNull(); + expect(result).toBe(BROKEN_POLICY_FILE); expect(spy).toHaveBeenCalledWith(expect.stringContaining("unsupported version")); - expect(spy).toHaveBeenCalledWith(expect.stringContaining("DISABLED")); + expect(spy).toHaveBeenCalledWith(expect.stringContaining("Failing closed")); spy.mockRestore(); }); - it("returns null and logs error when base is not an object", () => { + it("returns BROKEN_POLICY_FILE and logs error when base is not an object", () => { const spy = vi.spyOn(console, "error").mockImplementation(() => {}); writeFile({ version: 1, base: ["r--"] }); const result = loadAccessPolicyFile(); - expect(result).toBeNull(); + expect(result).toBe(BROKEN_POLICY_FILE); expect(spy).toHaveBeenCalledWith(expect.stringContaining('"base" must be an object')); spy.mockRestore(); }); - it("returns null and logs error when agents is not an object", () => { + 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).toBeNull(); + expect(result).toBe(BROKEN_POLICY_FILE); expect(spy).toHaveBeenCalledWith(expect.stringContaining('"agents" must be an object')); spy.mockRestore(); }); - it("returns null and logs error when a top-level key like 'rules' is misplaced", () => { + it("returns BROKEN_POLICY_FILE and logs error when a top-level key like 'rules' is misplaced", () => { const spy = vi.spyOn(console, "error").mockImplementation(() => {}); // Common mistake: rules at top level instead of under base writeFile({ version: 1, rules: { "/**": "r--" } }); const result = loadAccessPolicyFile(); - expect(result).toBeNull(); + expect(result).toBe(BROKEN_POLICY_FILE); expect(spy).toHaveBeenCalledWith(expect.stringContaining('unexpected top-level key "rules"')); spy.mockRestore(); }); - it("returns null and logs error when 'deny' is misplaced at top level", () => { + it("returns BROKEN_POLICY_FILE and logs error when 'deny' is misplaced at top level", () => { const spy = vi.spyOn(console, "error").mockImplementation(() => {}); writeFile({ version: 1, deny: ["~/.ssh/**"] }); const result = loadAccessPolicyFile(); - expect(result).toBeNull(); + expect(result).toBe(BROKEN_POLICY_FILE); expect(spy).toHaveBeenCalledWith(expect.stringContaining('unexpected top-level key "deny"')); spy.mockRestore(); }); - it("returns null and logs error when an agent block is not an object", () => { + 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).toBeNull(); + expect(result).toBe(BROKEN_POLICY_FILE); expect(spy).toHaveBeenCalledWith(expect.stringContaining('agents["subri"] must be an object')); spy.mockRestore(); }); @@ -220,13 +221,15 @@ describe("resolveAccessPolicyForAgent", () => { warnSpy.mockRestore(); }); - it("does not warn when config file exists but is broken (error already logged)", () => { + 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, rules: { "/**": "r--" } }); // misplaced key — triggers error - resolveAccessPolicyForAgent("subri"); + const result = resolveAccessPolicyForAgent("subri"); expect(warnSpy).not.toHaveBeenCalled(); - expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("DISABLED")); + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("Failing closed")); + // Broken file must fail-closed: deny-all policy, not undefined + expect(result).toEqual({ default: "---" }); warnSpy.mockRestore(); errSpy.mockRestore(); }); diff --git a/src/infra/access-policy-file.ts b/src/infra/access-policy-file.ts index 578bd168fcc..d41e519b259 100644 --- a/src/infra/access-policy-file.ts +++ b/src/infra/access-policy-file.ts @@ -110,10 +110,20 @@ function validateAccessPolicyFileStructure(filePath: string, parsed: unknown): s } /** - * Read and parse the sidecar file. Returns null if the file does not exist. - * Logs a clear error (and returns null) if the file is present but broken. + * 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 function loadAccessPolicyFile(): AccessPolicyFile | null { +export const BROKEN_POLICY_FILE = Symbol("broken-policy-file"); + +/** + * 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. + */ +export function loadAccessPolicyFile(): AccessPolicyFile | null | typeof BROKEN_POLICY_FILE { const filePath = resolveAccessPolicyPath(); if (!fs.existsSync(filePath)) { return null; @@ -127,14 +137,14 @@ export function loadAccessPolicyFile(): AccessPolicyFile | null { console.error( `[access-policy] Cannot parse ${filePath}: ${err instanceof Error ? err.message : String(err)}`, ); - console.error(`[access-policy] Permissions enforcement is DISABLED until the file is fixed.`); - return null; + 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] Permissions enforcement is DISABLED until the file is fixed.`); - return null; + console.error(`[access-policy] Failing closed (default: "---") until the file is fixed.`); + return BROKEN_POLICY_FILE; } const p = parsed as Record; @@ -142,8 +152,8 @@ export function loadAccessPolicyFile(): AccessPolicyFile | null { console.error( `[access-policy] ${filePath}: unsupported version ${JSON.stringify(p["version"])} (expected 1).`, ); - console.error(`[access-policy] Permissions enforcement is DISABLED until the file is fixed.`); - return null; + console.error(`[access-policy] Failing closed (default: "---") until the file is fixed.`); + return BROKEN_POLICY_FILE; } // Structural validation — catches wrong nesting, misplaced keys, etc. @@ -152,8 +162,8 @@ export function loadAccessPolicyFile(): AccessPolicyFile | null { for (const err of structErrors) { console.error(`[access-policy] ${err}`); } - console.error(`[access-policy] Permissions enforcement is DISABLED until the file is fixed.`); - return null; + console.error(`[access-policy] Failing closed (default: "---") until the file is fixed.`); + return BROKEN_POLICY_FILE; } return parsed as AccessPolicyFile; @@ -179,8 +189,15 @@ export function _resetNotFoundWarnedForTest(): void { * 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). */ +const DENY_ALL_POLICY: AccessPolicyConfig = { default: "---" }; + 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. diff --git a/src/infra/access-policy.test.ts b/src/infra/access-policy.test.ts index c4f1a1e74f7..7d6f8312ec5 100644 --- a/src/infra/access-policy.test.ts +++ b/src/infra/access-policy.test.ts @@ -95,6 +95,16 @@ describe("validateAccessPolicyConfig", () => { expect(config.deny?.[0]).toBe(`${dir}/**`); }); + it("auto-expands bare deny[] entry even when the directory does not yet exist", () => { + // Greptile: deny[] must expand unconditionally — stat-gated expansion leaves + // non-existent paths unexpanded, silently allowing files created there later. + const nonExistent = path.join(os.tmpdir(), "openclaw-test-nonexistent-" + Date.now()); + const config: AccessPolicyConfig = { deny: [nonExistent] }; + const errs = validateAccessPolicyConfig(config); + expect(config.deny?.[0]).toBe(`${nonExistent}/**`); + expect(errs[0]).toMatch(/auto-expanded/); + }); + it("accepts valid 'rwx' and '---' perm strings", () => { expect(validateAccessPolicyConfig({ default: "rwx" })).toEqual([]); expect(validateAccessPolicyConfig({ default: "---" })).toEqual([]); diff --git a/src/infra/access-policy.ts b/src/infra/access-policy.ts index 97be5cc310c..d78de58bba2 100644 --- a/src/infra/access-policy.ts +++ b/src/infra/access-policy.ts @@ -82,25 +82,21 @@ export function validateAccessPolicyConfig(config: AccessPolicyConfig): string[] errors.push(`access-policy.deny[${i}] must be a non-empty glob pattern`); continue; } - // Same bare-directory auto-expand as rules: "~/.ssh" → "~/.ssh/**" so the - // entire directory is denied, not just the directory entry itself. + // Unconditional bare-path auto-expand: "~/.ssh" → "~/.ssh/**" so the + // entire directory tree is denied, not just the directory inode itself. + // Unlike rules (where an allow for a non-existent path is harmless), deny[] + // entries are proactive security controls — a user writing deny:["~/.creds"] + // intends to block that subtree even before the directory is created. Relying + // on statSync would silently leave the bare pattern unexpanded if the directory + // doesn't exist yet, creating a gap when it is later created. if (!pattern.endsWith("/") && !/[*?[]/.test(pattern)) { - const expanded = pattern.startsWith("~") - ? pattern.replace(/^~(?=$|\/)/, os.homedir()) - : pattern; - try { - if (fs.statSync(expanded).isDirectory()) { - const fixed = `${pattern}/**`; - config.deny[i] = fixed; - if (!_autoExpandedWarned.has(`deny:${pattern}`)) { - _autoExpandedWarned.add(`deny:${pattern}`); - errors.push( - `access-policy.deny["${pattern}"] is a directory — entry auto-expanded to "${fixed}" so it covers all contents.`, - ); - } - } - } catch { - // Path inaccessible or missing — no action needed. + const fixed = `${pattern}/**`; + config.deny[i] = fixed; + if (!_autoExpandedWarned.has(`deny:${pattern}`)) { + _autoExpandedWarned.add(`deny:${pattern}`); + errors.push( + `access-policy.deny["${pattern}"] auto-expanded to "${fixed}" so it covers all directory contents.`, + ); } } } From a5e8054a01dc9db3cb058c1c7c8792e3a2635f91 Mon Sep 17 00:00:00 2001 From: subrih Date: Fri, 13 Mar 2026 18:18:27 -0700 Subject: [PATCH 07/29] fix(access-policy): read TOCTOU, sandbox skips hash check, seatbelt profile file cleanup --- src/agents/bash-tools.exec-runtime.ts | 47 +++++++++++++++------------ src/agents/pi-tools.read.ts | 8 ++++- src/infra/exec-sandbox-seatbelt.ts | 19 +++++++++-- 3 files changed, 51 insertions(+), 23 deletions(-) diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 6892891279a..43db40af6aa 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -348,9 +348,13 @@ export async function runExecProcess(opts: { const sessionId = createSessionSlug(); const baseCommand = opts.execCommand ?? opts.command; - // Apply OS-level path enforcement when access-policy permissions are configured. + // 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 && !opts.sandbox) { + if (opts.permissions) { const argv0 = resolveArgv0(baseCommand, opts.workdir) ?? baseCommand; const { policy: effectivePermissions, @@ -371,25 +375,28 @@ export async function runExecProcess(opts: { if (!hasScriptOverride && checkAccessPolicy(argv0, "exec", effectivePermissions) === "deny") { throw new Error(`exec denied by access policy: ${argv0}`); } - 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(); + // 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(); } - } else if (process.platform === "win32") { - _warnWindowsUnconfiguredOnce(); } } diff --git a/src/agents/pi-tools.read.ts b/src/agents/pi-tools.read.ts index 3b438a9c21c..ac2103ac655 100644 --- a/src/agents/pi-tools.read.ts +++ b/src/agents/pi-tools.read.ts @@ -695,6 +695,11 @@ export function createOpenClawReadTool( assertRequiredParams(record, CLAUDE_PARAM_GROUPS.read, base.name); const filePath = typeof record?.path === "string" ? String(record.path) : ""; // 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; if (options?.permissions && filePath !== "") { const resolvedPath = safeRealpath( path.isAbsolute(filePath) @@ -704,11 +709,12 @@ export function createOpenClawReadTool( 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({ base, toolCallId, - args: (normalized ?? params ?? {}) as Record, + args: readArgs, signal, maxBytes: resolveAdaptiveReadMaxBytes(options), }); diff --git a/src/infra/exec-sandbox-seatbelt.ts b/src/infra/exec-sandbox-seatbelt.ts index 31432e70f6b..ab6412e096c 100644 --- a/src/infra/exec-sandbox-seatbelt.ts +++ b/src/infra/exec-sandbox-seatbelt.ts @@ -302,8 +302,10 @@ export function generateSeatbeltProfile( // 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. -// Files accumulate at the rate of exec calls and are cleaned up on graceful exit; -// on SIGKILL they remain but /tmp is wiped on reboot. +// 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(); process.once("exit", () => { for (const f of _profileFiles) { @@ -315,6 +317,18 @@ process.once("exit", () => { } }); +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. @@ -325,6 +339,7 @@ export function wrapCommandWithSeatbelt(command: string, profile: string): strin 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, From db6ddaf0b3b9253cef9ef77191cfd84add63b631 Mon Sep 17 00:00:00 2001 From: subrih Date: Fri, 13 Mar 2026 19:11:51 -0700 Subject: [PATCH 08/29] fix(access-policy): bwrap tmpfs file guard, DENY_ALL freeze, access read-only check, seatbelt ? wildcard strip --- src/agents/pi-tools.read.ts | 15 +++++++++++++-- src/infra/access-policy-file.test.ts | 18 ++++++++++++++++++ src/infra/access-policy-file.ts | 2 +- src/infra/exec-sandbox-bwrap.test.ts | 19 +++++++++++++++++++ src/infra/exec-sandbox-bwrap.ts | 18 +++++++++++++++++- src/infra/exec-sandbox-seatbelt.test.ts | 9 +++++++++ src/infra/exec-sandbox-seatbelt.ts | 5 ++++- 7 files changed, 81 insertions(+), 5 deletions(-) diff --git a/src/agents/pi-tools.read.ts b/src/agents/pi-tools.read.ts index ac2103ac655..8c695744cee 100644 --- a/src/agents/pi-tools.read.ts +++ b/src/agents/pi-tools.read.ts @@ -864,6 +864,17 @@ function createHostEditOperations( 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) { // When workspaceOnly is false, allow edits anywhere on the host return { @@ -876,7 +887,7 @@ function createHostEditOperations( await writeHostFile(resolved, content); }, access: async (absolutePath: string) => { - const resolved = assertEditPermitted(absolutePath); + const resolved = assertReadPermitted(absolutePath); await fs.access(resolved); }, } as const; @@ -904,7 +915,7 @@ function createHostEditOperations( }); }, access: async (absolutePath: string) => { - const resolved = assertEditPermitted(absolutePath); + const resolved = assertReadPermitted(absolutePath); let relative: string; try { relative = toRelativeWorkspacePath(resolvedRoot, resolved); diff --git a/src/infra/access-policy-file.test.ts b/src/infra/access-policy-file.test.ts index 58cc8ba8e10..4d8f64862f2 100644 --- a/src/infra/access-policy-file.test.ts +++ b/src/infra/access-policy-file.test.ts @@ -234,6 +234,24 @@ describe("resolveAccessPolicyForAgent", () => { 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, rules: { "/**": "r--" } }); // misplaced key — broken + const result = resolveAccessPolicyForAgent("subri"); + expect(result).toEqual({ default: "---" }); + // 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)["default"] = "rwx"; + } catch { + // Object.freeze throws in strict mode — that's fine too. + } + _resetNotFoundWarnedForTest(); + const result2 = resolveAccessPolicyForAgent("subri"); + expect(result2).toEqual({ default: "---" }); + errSpy.mockRestore(); + }); + it("returns base when no agent block exists", () => { writeFile({ version: 1, diff --git a/src/infra/access-policy-file.ts b/src/infra/access-policy-file.ts index d41e519b259..1ce1fb5f2b4 100644 --- a/src/infra/access-policy-file.ts +++ b/src/infra/access-policy-file.ts @@ -190,7 +190,7 @@ export function _resetNotFoundWarnedForTest(): void { * 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). */ -const DENY_ALL_POLICY: AccessPolicyConfig = { default: "---" }; +const DENY_ALL_POLICY: AccessPolicyConfig = Object.freeze({ default: "---" }); export function resolveAccessPolicyForAgent(agentId?: string): AccessPolicyConfig | undefined { const file = loadAccessPolicyFile(); diff --git a/src/infra/exec-sandbox-bwrap.test.ts b/src/infra/exec-sandbox-bwrap.test.ts index 2d6f8d15853..d02c45dbdc7 100644 --- a/src/infra/exec-sandbox-bwrap.test.ts +++ b/src/infra/exec-sandbox-bwrap.test.ts @@ -282,6 +282,25 @@ describe("generateBwrapArgs", () => { expect(tmpfsMounts).not.toContain(`${HOME}/logs`); }); + it("skips --tmpfs for deny[] entry that resolves to an existing file (not a directory)", () => { + // /etc/hosts is a file on both macOS and Linux; bwrap --tmpfs rejects file paths. + // The deny entry is expanded to "/etc/hosts/**" by validateAccessPolicyConfig, and + // patternToPath strips the "/**" back to "/etc/hosts". generateBwrapArgs must not + // emit "--tmpfs /etc/hosts" — it should be silently skipped. + const config: AccessPolicyConfig = { default: "r--", deny: ["/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("still emits --tmpfs for deny[] entry that resolves to a directory", () => { + // Non-existent paths are treated as directories (forward-protection). + const config: AccessPolicyConfig = { default: "r--", deny: [`${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. diff --git a/src/infra/exec-sandbox-bwrap.ts b/src/infra/exec-sandbox-bwrap.ts index 9ba6b84ea26..f7b052c8c8e 100644 --- a/src/infra/exec-sandbox-bwrap.ts +++ b/src/infra/exec-sandbox-bwrap.ts @@ -1,4 +1,5 @@ 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"; @@ -171,12 +172,27 @@ export function generateBwrapArgs( // deny[] entries: overlay with empty tmpfs — path exists but is empty. // tmpfs overlay hides the real contents regardless of how the path was expressed. + // Guard: bwrap --tmpfs only accepts a directory as the mount point. deny[] entries + // like "~/.ssh/id_rsa" are unconditionally expanded to "~/.ssh/id_rsa/**" by + // validateAccessPolicyConfig and resolve back to the file path here. Passing a + // file to --tmpfs causes bwrap to error out ("Not a directory"). Non-existent + // paths are assumed to be directories (the common case for protecting future dirs). for (const pattern of config.deny ?? []) { const p = patternToPath(pattern, homeDir); if (!p || p === "/") { continue; } - args.push("--tmpfs", p); + let isDir = true; + try { + isDir = fs.statSync(p).isDirectory(); + } catch { + // Non-existent path — assume directory (forward-protection for dirs not yet created). + } + if (isDir) { + args.push("--tmpfs", p); + } + // File-specific entry: tool-layer checkAccessPolicy already denies reads/writes; + // bwrap cannot mount tmpfs over a file so skip the OS-layer mount silently. } // Script-specific override mounts — emitted after deny[] so they can reopen diff --git a/src/infra/exec-sandbox-seatbelt.test.ts b/src/infra/exec-sandbox-seatbelt.test.ts index c0a906b96a5..eaa97e95dec 100644 --- a/src/infra/exec-sandbox-seatbelt.test.ts +++ b/src/infra/exec-sandbox-seatbelt.test.ts @@ -300,4 +300,13 @@ describe("generateSeatbeltProfile — mid-path wildcard guard", () => { const profile = generateSeatbeltProfile({ rules: { "/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({ rules: { "/tmp/file?.txt": "r--" } }, HOME); + expect(profile).not.toMatch(/\?/); // no literal ? in the emitted profile + expect(profile).toContain('(subpath "/tmp/file")'); + }); }); diff --git a/src/infra/exec-sandbox-seatbelt.ts b/src/infra/exec-sandbox-seatbelt.ts index ab6412e096c..bb8ce918067 100644 --- a/src/infra/exec-sandbox-seatbelt.ts +++ b/src/infra/exec-sandbox-seatbelt.ts @@ -100,7 +100,10 @@ function patternToSbplMatcher(pattern: string, homeDir: string): string | null { : withExpanded; // Strip trailing wildcard segments to get the longest concrete prefix. - const withoutWild = expanded.replace(/[/\\]?\*.*$/, ""); + // 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. + const withoutWild = expanded.replace(/[/\\]?[*?].*$/, ""); const base = withoutWild || "/"; // If the original pattern had wildcards, use subpath (recursive match). From 15b1e75357e33057bffd48af69a9af3a28e80423 Mon Sep 17 00:00:00 2001 From: subrih Date: Fri, 13 Mar 2026 19:27:30 -0700 Subject: [PATCH 09/29] fix(access-policy): deny[] file guard, findBestRule dir-match, bwrap --proc in permissive mode --- src/infra/access-policy.test.ts | 29 ++++++++++++++++-- src/infra/access-policy.ts | 45 +++++++++++++++++++--------- src/infra/exec-sandbox-bwrap.test.ts | 9 ++++++ src/infra/exec-sandbox-bwrap.ts | 5 ++++ 4 files changed, 72 insertions(+), 16 deletions(-) diff --git a/src/infra/access-policy.test.ts b/src/infra/access-policy.test.ts index 7d6f8312ec5..4950a996afb 100644 --- a/src/infra/access-policy.test.ts +++ b/src/infra/access-policy.test.ts @@ -96,8 +96,7 @@ describe("validateAccessPolicyConfig", () => { }); it("auto-expands bare deny[] entry even when the directory does not yet exist", () => { - // Greptile: deny[] must expand unconditionally — stat-gated expansion leaves - // non-existent paths unexpanded, silently allowing files created there later. + // Non-existent paths are treated as future directories and expanded to /**. const nonExistent = path.join(os.tmpdir(), "openclaw-test-nonexistent-" + Date.now()); const config: AccessPolicyConfig = { deny: [nonExistent] }; const errs = validateAccessPolicyConfig(config); @@ -105,6 +104,25 @@ describe("validateAccessPolicyConfig", () => { expect(errs[0]).toMatch(/auto-expanded/); }); + it("does NOT auto-expand a bare deny[] entry that is an existing file", () => { + // A specific file like "~/.ssh/id_rsa" must stay as an exact-match pattern. + // Expanding it to "~/.ssh/id_rsa/**" would only match non-existent children, + // leaving the file itself unprotected at the tool layer and in bwrap. + // process.execPath is the running node/bun binary — always a real file. + const file = process.execPath; + const config: AccessPolicyConfig = { deny: [file] }; + validateAccessPolicyConfig(config); + expect(config.deny?.[0]).toBe(file); // kept as bare path, not expanded + }); + + it("file-specific deny[] entry blocks access to the file via checkAccessPolicy", () => { + // Regression: bare file path in deny[] must block reads at the tool layer. + const file = process.execPath; + const config: AccessPolicyConfig = { default: "rwx", deny: [file] }; + validateAccessPolicyConfig(config); // applies normalization in-place + expect(checkAccessPolicy(file, "read", config)).toBe("deny"); + }); + it("accepts valid 'rwx' and '---' perm strings", () => { expect(validateAccessPolicyConfig({ default: "rwx" })).toEqual([]); expect(validateAccessPolicyConfig({ default: "---" })).toEqual([]); @@ -355,6 +373,13 @@ describe("findBestRule", () => { expect(findBestRule("/bar/baz", rules)).toBeNull(); }); + it("bare directory path matches /** rule without requiring /. suffix", () => { + // findBestRule("/tmp", {"/tmp/**": "r--"}) must return "r--". + // Previously callers had to pass "/tmp/." to trigger a match — fragile contract. + const rules = { "/tmp/**": "r--" }; + expect(findBestRule("/tmp", rules)).toBe("r--"); + }); + it("tilde rule beats broader absolute rule when expanded path is longer", () => { // "~/.ssh/**" expanded is e.g. "/home/user/.ssh/**" (longer than "/home/user/**"). // The tilde rule must win so an explicit "---" denial is not silently overridden. diff --git a/src/infra/access-policy.ts b/src/infra/access-policy.ts index d78de58bba2..821a8b2249b 100644 --- a/src/infra/access-policy.ts +++ b/src/infra/access-policy.ts @@ -82,21 +82,32 @@ export function validateAccessPolicyConfig(config: AccessPolicyConfig): string[] errors.push(`access-policy.deny[${i}] must be a non-empty glob pattern`); continue; } - // Unconditional bare-path auto-expand: "~/.ssh" → "~/.ssh/**" so the + // Bare-path auto-expand for directories: "~/.ssh" → "~/.ssh/**" so the // entire directory tree is denied, not just the directory inode itself. - // Unlike rules (where an allow for a non-existent path is harmless), deny[] - // entries are proactive security controls — a user writing deny:["~/.creds"] - // intends to block that subtree even before the directory is created. Relying - // on statSync would silently leave the bare pattern unexpanded if the directory - // doesn't exist yet, creating a gap when it is later created. + // For paths that exist and are confirmed files (statSync), keep the bare + // pattern — expanding to "/**" would only match non-existent children, + // leaving the file itself unprotected at both the tool layer and bwrap. + // Non-existent paths are treated as future directories and always expanded + // so the subtree is protected before the directory is created. if (!pattern.endsWith("/") && !/[*?[]/.test(pattern)) { - const fixed = `${pattern}/**`; - config.deny[i] = fixed; - if (!_autoExpandedWarned.has(`deny:${pattern}`)) { - _autoExpandedWarned.add(`deny:${pattern}`); - errors.push( - `access-policy.deny["${pattern}"] auto-expanded to "${fixed}" so it covers all directory contents.`, - ); + const expandedForStat = pattern.startsWith("~") + ? pattern.replace(/^~(?=$|[/\\])/, os.homedir()) + : pattern; + let isExistingFile = false; + try { + isExistingFile = !fs.statSync(expandedForStat).isDirectory(); + } catch { + // Path does not exist — treat as a future directory and expand to /**. + } + if (!isExistingFile) { + const fixed = `${pattern}/**`; + config.deny[i] = fixed; + if (!_autoExpandedWarned.has(`deny:${pattern}`)) { + _autoExpandedWarned.add(`deny:${pattern}`); + errors.push( + `access-policy.deny["${pattern}"] auto-expanded to "${fixed}" so it covers all directory contents.`, + ); + } } } } @@ -197,7 +208,13 @@ export function findBestRule( 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)); - if (matchesExecAllowlistPattern(expanded, targetPath)) { + // 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). diff --git a/src/infra/exec-sandbox-bwrap.test.ts b/src/infra/exec-sandbox-bwrap.test.ts index d02c45dbdc7..7d1eb0d858c 100644 --- a/src/infra/exec-sandbox-bwrap.test.ts +++ b/src/infra/exec-sandbox-bwrap.test.ts @@ -95,6 +95,15 @@ describe("generateBwrapArgs", () => { 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({ default: "r--" }, HOME); + const procIdx = args.indexOf("--proc"); + expect(procIdx).toBeGreaterThan(-1); + expect(args[procIdx + 1]).toBe("/proc"); + }); + it("adds --tmpfs /tmp in permissive mode", () => { const config: AccessPolicyConfig = { default: "r--" }; const args = generateBwrapArgs(config, HOME); diff --git a/src/infra/exec-sandbox-bwrap.ts b/src/infra/exec-sandbox-bwrap.ts index f7b052c8c8e..7790ae90a15 100644 --- a/src/infra/exec-sandbox-bwrap.ts +++ b/src/infra/exec-sandbox-bwrap.ts @@ -118,6 +118,11 @@ export function generateBwrapArgs( 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"); // Upgrade /tmp to writable tmpfs and overlay a real /dev for normal process operation. args.push("--tmpfs", "/tmp"); args.push("--dev", "/dev"); From 59abb2c577cae47033adc48897911e1bb0059719 Mon Sep 17 00:00:00 2001 From: subrih Date: Fri, 13 Mar 2026 19:46:02 -0700 Subject: [PATCH 10/29] fix(access-policy): deny[] beats scripts{} unconditionally, argv0 first-token fallback --- src/agents/bash-tools.exec-runtime.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 43db40af6aa..2b0475d197b 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -355,7 +355,12 @@ export async function runExecProcess(opts: { // is skipped when a container sandbox already provides filesystem isolation. let execCommand = baseCommand; if (opts.permissions) { - const argv0 = resolveArgv0(baseCommand, opts.workdir) ?? baseCommand; + // 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, @@ -368,10 +373,18 @@ export async function runExecProcess(opts: { // enforcement (seatbelt/bwrap) is unavailable (Linux without bwrap, Windows). // Mirrors the checkAccessPolicy calls in read/write tools for consistency. // - // When a policy.scripts entry was found and sha256 passed, the hash check IS - // the exec-authorization signal — skip the base exec check so scripts don't - // also need a separate exec rule in the base policy to run. + // deny[] always wins — checked unconditionally even for scripts{} entries. + // The hash check authorises the policy overlay (effectivePermissions), not exec + // itself. A script in both deny[] and scripts{} is a config error; the deny wins. + // For scripts{} entries not in deny[], we skip the broader rules/default check + // so a sha256-matched script doesn't also need an explicit exec rule in the base policy. const hasScriptOverride = argv0 in (opts.permissions.scripts ?? {}); + const denyVerdict = checkAccessPolicy(argv0, "exec", { + deny: opts.permissions.deny, + }); + if (denyVerdict === "deny") { + throw new Error(`exec denied by access policy: ${argv0}`); + } if (!hasScriptOverride && checkAccessPolicy(argv0, "exec", effectivePermissions) === "deny") { throw new Error(`exec denied by access policy: ${argv0}`); } From a9a1e7d912fee28741e10bb03d45a87a4f6a3c0e Mon Sep 17 00:00:00 2001 From: subrih Date: Fri, 13 Mar 2026 19:46:35 -0700 Subject: [PATCH 11/29] style: oxfmt reflow argv0 assignment --- src/agents/bash-tools.exec-runtime.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 2b0475d197b..24977f4b158 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -359,8 +359,7 @@ export async function runExecProcess(opts: { // 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 argv0 = resolveArgv0(baseCommand, opts.workdir) ?? baseCommand.trim().split(/\s+/)[0] ?? baseCommand; const { policy: effectivePermissions, overrideRules, From 1c03f79f1f5002771ebc6079a6140ba3c3569a34 Mon Sep 17 00:00:00 2001 From: subrih Date: Fri, 13 Mar 2026 20:04:38 -0700 Subject: [PATCH 12/29] fix(access-policy): warn on file-specific bwrap deny entries, expand ~ in scripts keys --- docs/tools/access-policy.md | 2 ++ src/infra/access-policy.test.ts | 13 +++++++++++++ src/infra/access-policy.ts | 14 +++++++++++++- src/infra/exec-sandbox-bwrap.test.ts | 21 +++++++++++++++++++-- src/infra/exec-sandbox-bwrap.ts | 26 ++++++++++++++++++++++++-- 5 files changed, 71 insertions(+), 5 deletions(-) diff --git a/docs/tools/access-policy.md b/docs/tools/access-policy.md index 121f2fd6372..fb1504f0f54 100644 --- a/docs/tools/access-policy.md +++ b/docs/tools/access-policy.md @@ -130,6 +130,8 @@ For cross-agent MCP tool delegation (an orchestrator invoking a tool on behalf o **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, place it in the deny list (no read access). +**File-specific `deny[]` entries on Linux (bwrap).** On Linux, `deny[]` entries are enforced at the OS layer using `bwrap --tmpfs` overlays, which only work on directories. When a `deny[]` entry resolves to an existing file (e.g. `deny: ["~/.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. `deny: ["~/.aws/"]` rather than `deny: ["~/.aws/credentials"]`). On macOS, seatbelt handles file-level denials correctly with `(deny file-read* (literal ...))`. + **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 diff --git a/src/infra/access-policy.test.ts b/src/infra/access-policy.test.ts index 4950a996afb..b4b6b8ffdbf 100644 --- a/src/infra/access-policy.test.ts +++ b/src/infra/access-policy.test.ts @@ -865,6 +865,19 @@ describe("applyScriptPolicyOverride", () => { } }); + it("matches scripts key written with ~ even though resolvedArgv0 is absolute", () => { + // Regression: "~/bin/deploy.sh" in scripts{} must match resolvedArgv0 "/home/user/bin/deploy.sh". + // A direct object lookup misses tilde keys; ~ must be expanded before comparing. + const absPath = path.join(os.homedir(), "bin", "deploy.sh"); + const base: AccessPolicyConfig = { + default: "rwx", + scripts: { "~/bin/deploy.sh": { rules: { "/secret/**": "---" } } }, + }; + const { overrideRules, hashMismatch } = applyScriptPolicyOverride(base, absPath); + expect(hashMismatch).toBeUndefined(); + expect(overrideRules?.["/secret/**"]).toBe("---"); + }); + it("applies override when sha256 matches — rules in overrideRules, not policy", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ap-test-")); const scriptPath = path.join(tmpDir, "script.sh"); diff --git a/src/infra/access-policy.ts b/src/infra/access-policy.ts index 821a8b2249b..c86aa2c2cd1 100644 --- a/src/infra/access-policy.ts +++ b/src/infra/access-policy.ts @@ -456,7 +456,19 @@ export function applyScriptPolicyOverride( policy: AccessPolicyConfig, resolvedArgv0: string, ): { policy: AccessPolicyConfig; overrideRules?: Record; hashMismatch?: true } { - const override = policy.scripts?.[resolvedArgv0]; + // Normalise ~ in scripts keys so "~/bin/deploy.sh" matches the resolved absolute + // path "/home/user/bin/deploy.sh" that resolveArgv0 returns. A direct lookup + // would always miss tilde-keyed entries, silently skipping sha256 verification. + const scripts = policy.scripts; + const override = scripts + ? (scripts[resolvedArgv0] ?? + Object.entries(scripts).find(([k]) => { + if (!k.startsWith("~")) { + return false; + } + return k.replace(/^~(?=$|[/\\])/, os.homedir()) === resolvedArgv0; + })?.[1]) + : undefined; if (!override) { return { policy }; } diff --git a/src/infra/exec-sandbox-bwrap.test.ts b/src/infra/exec-sandbox-bwrap.test.ts index 7d1eb0d858c..df771b76f10 100644 --- a/src/infra/exec-sandbox-bwrap.test.ts +++ b/src/infra/exec-sandbox-bwrap.test.ts @@ -1,7 +1,11 @@ import os from "node:os"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { AccessPolicyConfig } from "../config/types.tools.js"; -import { generateBwrapArgs, wrapCommandWithBwrap } from "./exec-sandbox-bwrap.js"; +import { + _warnBwrapFileDenyOnce, + generateBwrapArgs, + wrapCommandWithBwrap, +} from "./exec-sandbox-bwrap.js"; const HOME = os.homedir(); @@ -302,6 +306,19 @@ describe("generateBwrapArgs", () => { expect(tmpfsMounts).not.toContain("/etc/hosts"); }); + it("emits a console.error warning when a file-specific deny[] entry is skipped", () => { + // Use /etc/passwd (always a file) rather than /etc/hosts which is already in + // _bwrapFileDenyWarnedPaths from the generateBwrapArgs test above. + 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 deny[] entry that resolves to a directory", () => { // Non-existent paths are treated as directories (forward-protection). const config: AccessPolicyConfig = { default: "r--", deny: [`${HOME}/.nonexistent-dir/**`] }; diff --git a/src/infra/exec-sandbox-bwrap.ts b/src/infra/exec-sandbox-bwrap.ts index 7790ae90a15..0c570255597 100644 --- a/src/infra/exec-sandbox-bwrap.ts +++ b/src/infra/exec-sandbox-bwrap.ts @@ -26,6 +26,25 @@ const SYSTEM_RO_BIND_PATHS = ["/usr", "/bin", "/lib", "/lib64", "/sbin", "/etc", let bwrapAvailableCache: boolean | undefined; +// Warn once per process when a file-specific deny[] entry 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. +// See docs/tools/access-policy.md — "File-specific deny[] entries on Linux". +const _bwrapFileDenyWarnedPaths = new Set(); +export function _warnBwrapFileDenyOnce(filePath: string): void { + if (_bwrapFileDenyWarnedPaths.has(filePath)) { + return; + } + _bwrapFileDenyWarnedPaths.add(filePath); + console.error( + `[access-policy] bwrap: deny[] entry "${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, deny its parent directory instead.`, + ); +} + /** * Returns true if bwrap is installed and executable on this system. * Result is cached after the first call. @@ -195,9 +214,12 @@ export function generateBwrapArgs( } if (isDir) { args.push("--tmpfs", p); + } else { + // File-specific entry: tool-layer checkAccessPolicy still denies read/write/edit + // tool calls, but exec commands inside the sandbox can still access the file + // directly. Warn operators so they know to deny the parent directory instead. + _warnBwrapFileDenyOnce(p); } - // File-specific entry: tool-layer checkAccessPolicy already denies reads/writes; - // bwrap cannot mount tmpfs over a file so skip the OS-layer mount silently. } // Script-specific override mounts — emitted after deny[] so they can reopen From a7fb92a6060067405ec4c28132180390ee8a2fb7 Mon Sep 17 00:00:00 2001 From: subrih Date: Fri, 13 Mar 2026 20:16:00 -0700 Subject: [PATCH 13/29] fix(access-policy): expand ~ in hasScriptOverride to match tilde-keyed scripts entries --- src/agents/bash-tools.exec-runtime.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 24977f4b158..b146f9acaee 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -359,7 +359,8 @@ export async function runExecProcess(opts: { // 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 argv0 = + resolveArgv0(baseCommand, opts.workdir) ?? baseCommand.trim().split(/\s+/)[0] ?? baseCommand; const { policy: effectivePermissions, overrideRules, @@ -377,7 +378,15 @@ export async function runExecProcess(opts: { // itself. A script in both deny[] and scripts{} is a config error; the deny wins. // For scripts{} entries not in deny[], we skip the broader rules/default check // so a sha256-matched script doesn't also need an explicit exec rule in the base policy. - const hasScriptOverride = argv0 in (opts.permissions.scripts ?? {}); + // Mirror the tilde-expansion logic in applyScriptPolicyOverride so that + // scripts keys written as "~/bin/deploy.sh" are matched even though argv0 + // is always an absolute path after resolveArgv0. + const _scripts = opts.permissions.scripts ?? {}; + const hasScriptOverride = + argv0 in _scripts || + Object.keys(_scripts).some( + (k) => k.startsWith("~") && k.replace(/^~(?=$|[/\\])/, os.homedir()) === argv0, + ); const denyVerdict = checkAccessPolicy(argv0, "exec", { deny: opts.permissions.deny, }); From 643a9fa9c8d89c00e5ab82cf3efb05e08b50cf89 Mon Sep 17 00:00:00 2001 From: subrih Date: Fri, 13 Mar 2026 20:29:30 -0700 Subject: [PATCH 14/29] fix(access-policy): denyVerdict default rwx, bwrap /tmp policy-gated, file-like path auto-expand --- src/agents/bash-tools.exec-runtime.ts | 4 ++++ src/infra/access-policy.test.ts | 10 ++++++++++ src/infra/access-policy.ts | 18 ++++++++++++++++-- src/infra/exec-sandbox-bwrap.test.ts | 9 +++++++++ src/infra/exec-sandbox-bwrap.ts | 11 +++++++++-- 5 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index b146f9acaee..67cd18ced2e 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -387,8 +387,12 @@ export async function runExecProcess(opts: { Object.keys(_scripts).some( (k) => k.startsWith("~") && k.replace(/^~(?=$|[/\\])/, os.homedir()) === argv0, ); + // Use default:"rwx" so only actual deny-pattern hits produce "deny". + // Without a default, permAllows(undefined, "exec") → false → every path + // not matched by a deny pattern would be incorrectly blocked. const denyVerdict = checkAccessPolicy(argv0, "exec", { deny: opts.permissions.deny, + default: "rwx", }); if (denyVerdict === "deny") { throw new Error(`exec denied by access policy: ${argv0}`); diff --git a/src/infra/access-policy.test.ts b/src/infra/access-policy.test.ts index b4b6b8ffdbf..d9d0970630a 100644 --- a/src/infra/access-policy.test.ts +++ b/src/infra/access-policy.test.ts @@ -104,6 +104,16 @@ describe("validateAccessPolicyConfig", () => { expect(errs[0]).toMatch(/auto-expanded/); }); + it("does NOT auto-expand a non-existent deny[] path that looks like a file (has extension)", () => { + // "~/future-secrets.key" doesn't exist yet but the extension heuristic should + // prevent expansion to "~/future-secrets.key/**" — the user intends to protect + // the file itself, not a subtree of non-existent children. + const fileLikePath = path.join(os.tmpdir(), `openclaw-test-${Date.now()}.key`); + const config: AccessPolicyConfig = { deny: [fileLikePath] }; + validateAccessPolicyConfig(config); + expect(config.deny?.[0]).toBe(fileLikePath); // must NOT be expanded to /** + }); + it("does NOT auto-expand a bare deny[] entry that is an existing file", () => { // A specific file like "~/.ssh/id_rsa" must stay as an exact-match pattern. // Expanding it to "~/.ssh/id_rsa/**" would only match non-existent children, diff --git a/src/infra/access-policy.ts b/src/infra/access-policy.ts index c86aa2c2cd1..3a15374e3d4 100644 --- a/src/infra/access-policy.ts +++ b/src/infra/access-policy.ts @@ -94,12 +94,26 @@ export function validateAccessPolicyConfig(config: AccessPolicyConfig): string[] ? pattern.replace(/^~(?=$|[/\\])/, os.homedir()) : pattern; let isExistingFile = false; + // For non-existent paths: treat as a future file (skip /**-expansion) when + // the last segment looks like a filename — has a dot but is not a dotfile-only + // name (e.g. ".ssh") and has a non-empty extension (e.g. "secrets.key"). + // This preserves the intent of "deny: ['~/future-secrets.key']" where the + // user wants to protect that specific file once it is created. + // Plain names without an extension (e.g. "myfolder") are still treated as + // future directories and expanded to /**. + let looksLikeFile = false; try { isExistingFile = !fs.statSync(expandedForStat).isDirectory(); } catch { - // Path does not exist — treat as a future directory and expand to /**. + const lastName = + expandedForStat + .replace(/[/\\]$/, "") + .split(/[/\\]/) + .pop() ?? ""; + // Has a dot that is not the leading dot (dotfile), and has chars after the dot. + looksLikeFile = /[^.]\.[^/\\]+$/.test(lastName); } - if (!isExistingFile) { + if (!isExistingFile && !looksLikeFile) { const fixed = `${pattern}/**`; config.deny[i] = fixed; if (!_autoExpandedWarned.has(`deny:${pattern}`)) { diff --git a/src/infra/exec-sandbox-bwrap.test.ts b/src/infra/exec-sandbox-bwrap.test.ts index df771b76f10..dae956bdcff 100644 --- a/src/infra/exec-sandbox-bwrap.test.ts +++ b/src/infra/exec-sandbox-bwrap.test.ts @@ -115,6 +115,15 @@ describe("generateBwrapArgs", () => { expect(tmpfsMounts).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 = { default: "r--", rules: { "/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("does not add --tmpfs /tmp in restrictive mode (default: ---)", () => { const config: AccessPolicyConfig = { default: "---" }; const args = generateBwrapArgs(config, HOME); diff --git a/src/infra/exec-sandbox-bwrap.ts b/src/infra/exec-sandbox-bwrap.ts index 0c570255597..452e06d44a6 100644 --- a/src/infra/exec-sandbox-bwrap.ts +++ b/src/infra/exec-sandbox-bwrap.ts @@ -3,6 +3,7 @@ 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); @@ -142,8 +143,14 @@ export function generateBwrapArgs( // 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"); - // Upgrade /tmp to writable tmpfs and overlay a real /dev for normal process operation. - args.push("--tmpfs", "/tmp"); + // Add writable /tmp tmpfs unless the policy has an explicit rule that denies + // write on /tmp. Without an explicit rule, /tmp is writable by default (needed + // by most processes for temp files). With an explicit "r--" or "---" rule on + // /tmp/**, respect it — /tmp remains read-only via the --ro-bind / / base. + const explicitTmpPerm = findBestRule("/tmp/.", config.rules ?? {}, homeDir); + if (explicitTmpPerm === null || explicitTmpPerm[1] === "w") { + args.push("--tmpfs", "/tmp"); + } args.push("--dev", "/dev"); } else { // Restrictive base: only bind system paths needed to run processes. From ee289fb2d6349e0f68d8c39c629d4b9a00cd538a Mon Sep 17 00:00:00 2001 From: subrih Date: Fri, 13 Mar 2026 21:06:19 -0700 Subject: [PATCH 15/29] fix(access-policy): validate scripts rules/deny, tighten looksLikeFile, explicit bwrap /tmp guard --- src/infra/access-policy.test.ts | 38 ++++++++++++++++++++++++++++ src/infra/access-policy.ts | 30 ++++++++++++++++++++-- src/infra/exec-sandbox-bwrap.test.ts | 11 ++++++++ src/infra/exec-sandbox-bwrap.ts | 20 +++++++++------ 4 files changed, 89 insertions(+), 10 deletions(-) diff --git a/src/infra/access-policy.test.ts b/src/infra/access-policy.test.ts index d9d0970630a..839a4c9216a 100644 --- a/src/infra/access-policy.test.ts +++ b/src/infra/access-policy.test.ts @@ -104,6 +104,18 @@ describe("validateAccessPolicyConfig", () => { expect(errs[0]).toMatch(/auto-expanded/); }); + it("auto-expands non-existent versioned directory names (v1.0, app-2.3) in deny[]", () => { + // Versioned names like "v1.0" or "pkg-1.0.0" look like files via naive dot-detection + // but are almost always directories. The tightened heuristic requires the extension + // to contain at least one letter — digits-only extensions (like ".0") are treated as + // directory-like and expanded to /**. + const base = os.tmpdir(); + const versionedDir = path.join(base, `openclaw-test-v1.0-${Date.now()}`); + const config: AccessPolicyConfig = { deny: [versionedDir] }; + validateAccessPolicyConfig(config); + expect(config.deny?.[0]).toBe(`${versionedDir}/**`); // must be expanded + }); + it("does NOT auto-expand a non-existent deny[] path that looks like a file (has extension)", () => { // "~/future-secrets.key" doesn't exist yet but the extension heuristic should // prevent expansion to "~/future-secrets.key/**" — the user intends to protect @@ -133,6 +145,32 @@ describe("validateAccessPolicyConfig", () => { expect(checkAccessPolicy(file, "read", config)).toBe("deny"); }); + it("validates scripts[].rules perm strings and emits diagnostics for bad ones", () => { + // A typo like "rwX" in a script's rules must produce a diagnostic, not silently + // fail closed (which would deny exec with no operator-visible error). + const config: AccessPolicyConfig = { + scripts: { + "/usr/local/bin/deploy.sh": { + rules: { "~/deploy/**": "rwX" }, // invalid: uppercase X + }, + }, + }; + const errs = validateAccessPolicyConfig(config); + expect(errs.some((e) => e.includes("rwX") && e.includes("scripts"))).toBe(true); + }); + + it("validates scripts[].deny entries and emits diagnostics for empty patterns", () => { + const config: AccessPolicyConfig = { + scripts: { + "/usr/local/bin/deploy.sh": { + deny: ["", "~/.secrets/**"], // first entry is invalid empty string + }, + }, + }; + const errs = validateAccessPolicyConfig(config); + expect(errs.some((e) => e.includes("scripts") && e.includes("deny"))).toBe(true); + }); + it("accepts valid 'rwx' and '---' perm strings", () => { expect(validateAccessPolicyConfig({ default: "rwx" })).toEqual([]); expect(validateAccessPolicyConfig({ default: "---" })).toEqual([]); diff --git a/src/infra/access-policy.ts b/src/infra/access-policy.ts index 3a15374e3d4..bdf9dcb6210 100644 --- a/src/infra/access-policy.ts +++ b/src/infra/access-policy.ts @@ -75,6 +75,29 @@ export function validateAccessPolicyConfig(config: AccessPolicyConfig): string[] } } + if (config.scripts) { + for (const [scriptPath, entry] of Object.entries(config.scripts)) { + if (entry.rules) { + for (const [pattern, perm] of Object.entries(entry.rules)) { + if (!PERM_STR_RE.test(perm)) { + errors.push( + `access-policy.scripts["${scriptPath}"].rules["${pattern}"] "${perm}" is invalid: must be exactly 3 chars (e.g. "rwx", "r--", "---")`, + ); + } + } + } + if (entry.deny) { + for (let i = 0; i < entry.deny.length; i++) { + if (!entry.deny[i]) { + errors.push( + `access-policy.scripts["${scriptPath}"].deny[${i}] must be a non-empty glob pattern`, + ); + } + } + } + } + } + if (config.deny) { for (let i = 0; i < config.deny.length; i++) { const pattern = config.deny[i]; @@ -110,8 +133,11 @@ export function validateAccessPolicyConfig(config: AccessPolicyConfig): string[] .replace(/[/\\]$/, "") .split(/[/\\]/) .pop() ?? ""; - // Has a dot that is not the leading dot (dotfile), and has chars after the dot. - looksLikeFile = /[^.]\.[^/\\]+$/.test(lastName); + // Has a dot that is not the leading dot (dotfile), and the extension + // contains at least one letter — this excludes version-like suffixes + // (.0, .3, -1.0, app-2.3) which look like versioned directory names. + // Examples: "secrets.key" → file; "v1.0" → directory; ".ssh" → directory. + looksLikeFile = /[^.]\.[a-zA-Z][^/\\]*$/.test(lastName); } if (!isExistingFile && !looksLikeFile) { const fixed = `${pattern}/**`; diff --git a/src/infra/exec-sandbox-bwrap.test.ts b/src/infra/exec-sandbox-bwrap.test.ts index dae956bdcff..458e209bb28 100644 --- a/src/infra/exec-sandbox-bwrap.test.ts +++ b/src/infra/exec-sandbox-bwrap.test.ts @@ -115,6 +115,17 @@ describe("generateBwrapArgs", () => { expect(tmpfsMounts).toContain("/tmp"); }); + it("does not add --tmpfs /tmp in restrictive mode even with no explicit /tmp rule", () => { + // Regression guard: the defaultAllowsRead guard on the /tmp block must prevent + // a writable tmpfs being mounted under default:"---" when no /tmp rule exists. + // explicitTmpPerm === null is true (no rule), but defaultAllowsRead is false, + // so the entire /tmp block must be skipped. + const config: AccessPolicyConfig = { default: "---" }; + 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. diff --git a/src/infra/exec-sandbox-bwrap.ts b/src/infra/exec-sandbox-bwrap.ts index 452e06d44a6..17f3162f0a6 100644 --- a/src/infra/exec-sandbox-bwrap.ts +++ b/src/infra/exec-sandbox-bwrap.ts @@ -143,14 +143,6 @@ export function generateBwrapArgs( // 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"); - // Add writable /tmp tmpfs unless the policy has an explicit rule that denies - // write on /tmp. Without an explicit rule, /tmp is writable by default (needed - // by most processes for temp files). With an explicit "r--" or "---" rule on - // /tmp/**, respect it — /tmp remains read-only via the --ro-bind / / base. - const explicitTmpPerm = findBestRule("/tmp/.", config.rules ?? {}, homeDir); - if (explicitTmpPerm === null || explicitTmpPerm[1] === "w") { - args.push("--tmpfs", "/tmp"); - } args.push("--dev", "/dev"); } else { // Restrictive base: only bind system paths needed to run processes. @@ -165,6 +157,18 @@ export function generateBwrapArgs( // 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.rules ?? {}, homeDir); + if (explicitTmpPerm === null || explicitTmpPerm[1] === "w") { + 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 From 651599da521e39a9cfae1f6139c0b95540b2ac70 Mon Sep 17 00:00:00 2001 From: subrih Date: Fri, 13 Mar 2026 21:31:06 -0700 Subject: [PATCH 16/29] fix(access-policy): dotfile expand gap, env -S recursion, quoted env arg, PATHEXT on win32, bwrap test skip on non-Linux --- src/infra/access-policy-file.test.ts | 11 ++++-- src/infra/access-policy.test.ts | 41 ++++++++++++++++++++ src/infra/access-policy.ts | 58 ++++++++++++++++++++-------- src/infra/exec-sandbox-bwrap.test.ts | 5 ++- 4 files changed, 95 insertions(+), 20 deletions(-) diff --git a/src/infra/access-policy-file.test.ts b/src/infra/access-policy-file.test.ts index 4d8f64862f2..62b014b5a94 100644 --- a/src/infra/access-policy-file.test.ts +++ b/src/infra/access-policy-file.test.ts @@ -188,9 +188,14 @@ describe("loadAccessPolicyFile", () => { }; writeFile(content); const result = loadAccessPolicyFile(); - expect(result?.version).toBe(1); - expect(result?.base?.default).toBe("r--"); - expect(result?.agents?.subri?.rules?.["~/dev/**"]).toBe("rwx"); + 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.base?.default).toBe("r--"); + expect(result.agents?.subri?.rules?.["~/dev/**"]).toBe("rwx"); }); }); diff --git a/src/infra/access-policy.test.ts b/src/infra/access-policy.test.ts index 839a4c9216a..2cbc2eed31f 100644 --- a/src/infra/access-policy.test.ts +++ b/src/infra/access-policy.test.ts @@ -126,6 +126,19 @@ describe("validateAccessPolicyConfig", () => { expect(config.deny?.[0]).toBe(fileLikePath); // must NOT be expanded to /** }); + it("does NOT auto-expand dotfile-style non-existent deny[] paths (.env, .netrc)", () => { + // Leading-dot names like ".env" or ".netrc" are files, not directories. Expanding + // "~/.env" to "~/.env/**" would protect only non-existent children, leaving the + // file itself unprotected once created. The heuristic treats leading-dot names + // (no further dot/slash) as file-like and skips auto-expansion. + const base = os.tmpdir(); + for (const name of [".env", ".netrc", ".htpasswd", ".npmrc"]) { + const config: AccessPolicyConfig = { deny: [path.join(base, name)] }; + validateAccessPolicyConfig(config); + expect(config.deny?.[0]).toBe(path.join(base, name)); // must NOT be expanded to /** + } + }); + it("does NOT auto-expand a bare deny[] entry that is an existing file", () => { // A specific file like "~/.ssh/id_rsa" must stay as an exact-match pattern. // Expanding it to "~/.ssh/id_rsa/**" would only match non-existent children, @@ -820,6 +833,34 @@ describe("resolveArgv0", () => { expect(resolveArgv0("env --default-signal /bin/sh")).toMatch(/sh$/); expect(resolveArgv0("env --ignore-signal /bin/sh")).toMatch(/sh$/); }); + + it("recurses into env -S split-string argument to find real argv0", () => { + // env -S "FOO=1 /bin/sh -c echo" — the argument to -S is itself a command string. + // Must recurse and return /bin/sh, not null or /usr/bin/env. + const result = resolveArgv0('env -S "FOO=1 /bin/sh -c echo"'); + expect(result).not.toBeNull(); + expect(result).toMatch(/sh$/); + }); + + it("recurses into env --split-string long form", () => { + const result = resolveArgv0("env --split-string '/bin/sh -c echo'"); + expect(result).not.toBeNull(); + expect(result).toMatch(/sh$/); + }); + + it("looks through env -C with a quoted directory arg containing spaces", () => { + // env -C "/path with space" /bin/sh — the dir arg is quoted; must not leave a + // dangling fragment that gets treated as the command. + const result = resolveArgv0('env -C "/path with space" /bin/sh -c echo'); + expect(result).not.toBeNull(); + expect(result).toMatch(/sh$/); + }); + + it("looks through env --chdir with a quoted directory arg", () => { + const result = resolveArgv0("env --chdir '/tmp/my dir' /bin/sh -c echo"); + expect(result).not.toBeNull(); + expect(result).toMatch(/sh$/); + }); }); // --------------------------------------------------------------------------- diff --git a/src/infra/access-policy.ts b/src/infra/access-policy.ts index bdf9dcb6210..2b3aa514581 100644 --- a/src/infra/access-policy.ts +++ b/src/infra/access-policy.ts @@ -133,11 +133,13 @@ export function validateAccessPolicyConfig(config: AccessPolicyConfig): string[] .replace(/[/\\]$/, "") .split(/[/\\]/) .pop() ?? ""; - // Has a dot that is not the leading dot (dotfile), and the extension - // contains at least one letter — this excludes version-like suffixes - // (.0, .3, -1.0, app-2.3) which look like versioned directory names. - // Examples: "secrets.key" → file; "v1.0" → directory; ".ssh" → directory. - looksLikeFile = /[^.]\.[a-zA-Z][^/\\]*$/.test(lastName); + // Looks like a file when either: + // a) Pure dotfile name: leading dot, no further dot/slash — covers ".env", + // ".netrc", ".htpasswd". These are typically files, not directories. + // b) Non-leading dot with a letter-containing extension — covers "secrets.key", + // "config.json". Digit-only extensions (v1.0, app-2.3) are treated as + // versioned directory names and excluded. + looksLikeFile = /^\.[^./\\]+$/.test(lastName) || /[^.]\.[a-zA-Z][^/\\]*$/.test(lastName); } if (!isExistingFile && !looksLikeFile) { const fixed = `${pattern}/**`; @@ -334,16 +336,25 @@ export function checkAccessPolicy( */ 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; } - const candidate = path.join(dir, name); - try { - fs.accessSync(candidate, fs.constants.X_OK); - return candidate; - } catch { - // not in this dir + 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; @@ -454,19 +465,34 @@ export function resolveArgv0(command: string, cwd?: string): string | null { let 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, -S STR) are handled - // explicitly; all other flags (e.g. -i, --ignore-environment) are single tokens. + // 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. - // Short options that consume the next token as a separate argument. // --block-signal, --default-signal, --ignore-signal use [=SIG] syntax (never space-separated). - const envOptWithArgRe = /^(-[uCS]|--(unset|chdir|split-string))\s+/; + const envOptWithArgRe = /^(-[uC]|--(unset|chdir))\s+/; + const envSplitStringRe = /^(-S|--(split-string))\s+/; while (afterEnv) { if (afterEnv === "--" || afterEnv.startsWith("-- ")) { afterEnv = afterEnv.slice(2).trimStart(); break; // -- terminates env options; what follows is the command } + if (envSplitStringRe.test(afterEnv)) { + // -S/--split-string: the argument is itself a command string — recurse into it. + afterEnv = afterEnv.replace(/^\S+\s+/, ""); // strip "-S " or "--split-string " + // Strip surrounding quotes that the shell added around the embedded command. + if ( + (afterEnv.startsWith('"') && afterEnv.endsWith('"')) || + (afterEnv.startsWith("'") && afterEnv.endsWith("'")) + ) { + afterEnv = afterEnv.slice(1, -1); + } + return afterEnv ? resolveArgv0(afterEnv, cwd) : null; + } if (envOptWithArgRe.test(afterEnv)) { - afterEnv = afterEnv.replace(/^\S+\s+\S+\s*/, ""); // strip option + its arg + // 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 { diff --git a/src/infra/exec-sandbox-bwrap.test.ts b/src/infra/exec-sandbox-bwrap.test.ts index 458e209bb28..0c1e181e012 100644 --- a/src/infra/exec-sandbox-bwrap.test.ts +++ b/src/infra/exec-sandbox-bwrap.test.ts @@ -9,7 +9,10 @@ import { const HOME = os.homedir(); -describe("generateBwrapArgs", () => { +// 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 default allows reads", () => { const config: AccessPolicyConfig = { default: "r--" }; const args = generateBwrapArgs(config, HOME); From d1a36051777b5f9d1b6c94a29efeb82f6f298f56 Mon Sep 17 00:00:00 2001 From: subrih Date: Fri, 13 Mar 2026 21:54:56 -0700 Subject: [PATCH 17/29] fix(access-policy): env basename extname on Windows, normalize ~ separators, skip Unix-only resolveArgv0 tests on Windows --- src/infra/access-policy.test.ts | 32 ++++++++++++++++++-------------- src/infra/access-policy.ts | 8 ++++++-- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/infra/access-policy.test.ts b/src/infra/access-policy.test.ts index 2cbc2eed31f..c34536b6beb 100644 --- a/src/infra/access-policy.test.ts +++ b/src/infra/access-policy.test.ts @@ -757,7 +757,11 @@ describe("resolveArgv0", () => { expect(result).toMatch(/sh$/); }); - it("looks through quoted /usr/bin/env to the real script", () => { + // The following tests use /bin/sh and Unix env behaviour — skip on Windows where + // /bin/sh doesn't exist and env resolves to env.EXE with different semantics. + const itUnix = it.skipIf(process.platform === "win32"); + + itUnix("looks through quoted /usr/bin/env to the real script", () => { // `"/usr/bin/env" /bin/sh` — argv0 is quoted, but env look-through must still fire. // Without this fix, commandRest was empty in the quoted branch so env look-through // was skipped and the function returned /usr/bin/env instead of /bin/sh. @@ -766,32 +770,32 @@ describe("resolveArgv0", () => { expect(result).toMatch(/sh$/); }); - it("looks through env -i flag to reach the real script", () => { + itUnix("looks through env -i flag to reach the real script", () => { // `env -i /bin/sh` — without fix, recurses on `-i /bin/sh` and resolves `-i` as argv0. const result = resolveArgv0("env -i /bin/sh -c echo"); expect(result).not.toBeNull(); expect(result).toMatch(/sh$/); }); - it("looks through env --ignore-environment long flag", () => { + itUnix("looks through env --ignore-environment long flag", () => { const result = resolveArgv0("env --ignore-environment /bin/sh -c echo"); expect(result).not.toBeNull(); expect(result).toMatch(/sh$/); }); - it("looks through env -u VAR (option that consumes next token)", () => { + itUnix("looks through env -u VAR (option that consumes next token)", () => { const result = resolveArgv0("env -u HOME /bin/sh -c echo"); expect(result).not.toBeNull(); expect(result).toMatch(/sh$/); }); - it("looks through env -- end-of-options marker", () => { + itUnix("looks through env -- end-of-options marker", () => { const result = resolveArgv0("env -- /bin/sh -c echo"); expect(result).not.toBeNull(); expect(result).toMatch(/sh$/); }); - it("resolves bare binary name via PATH rather than cwd", () => { + itUnix("resolves bare binary name via PATH rather than cwd", () => { // `sh` with no `/` should find /bin/sh on PATH, not /sh. // Without fix, path.resolve(cwd, "sh") produces /sh which doesn't exist. const result = resolveArgv0("sh -c echo", "/nonexistent/cwd"); @@ -805,7 +809,7 @@ describe("resolveArgv0", () => { expect(resolveArgv0("./script.py", undefined)).toBeNull(); // no cwd → null }); - it("uses a literal PATH= env prefix override when looking up bare names", () => { + itUnix("uses a literal PATH= env prefix override when looking up bare names", () => { // PATH=/nonexistent has no $, so findOnPath uses /nonexistent — sh not found there, // falls back to cwd resolution rather than the real process PATH. const result = resolveArgv0("PATH=/nonexistent sh", "/some/cwd"); @@ -815,26 +819,26 @@ describe("resolveArgv0", () => { } }); - it("ignores PATH= prefix containing shell vars and uses process PATH instead", () => { + itUnix("ignores PATH= prefix containing shell vars and uses process PATH instead", () => { // PATH=/alt:$PATH has $, so the override is skipped; sh found on process PATH. const result = resolveArgv0("PATH=/alt:$PATH sh"); expect(result).not.toBeNull(); expect(result).toMatch(/sh$/); }); - it("strips --block-signal as a standalone flag without consuming next token", () => { + itUnix("strips --block-signal as a standalone flag without consuming next token", () => { // --block-signal uses [=SIG] syntax — must not consume /bin/sh as its argument. const result = resolveArgv0("env --block-signal /bin/sh -c echo"); expect(result).not.toBeNull(); expect(result).toMatch(/sh$/); }); - it("strips --default-signal and --ignore-signal as standalone flags", () => { + itUnix("strips --default-signal and --ignore-signal as standalone flags", () => { expect(resolveArgv0("env --default-signal /bin/sh")).toMatch(/sh$/); expect(resolveArgv0("env --ignore-signal /bin/sh")).toMatch(/sh$/); }); - it("recurses into env -S split-string argument to find real argv0", () => { + itUnix("recurses into env -S split-string argument to find real argv0", () => { // env -S "FOO=1 /bin/sh -c echo" — the argument to -S is itself a command string. // Must recurse and return /bin/sh, not null or /usr/bin/env. const result = resolveArgv0('env -S "FOO=1 /bin/sh -c echo"'); @@ -842,13 +846,13 @@ describe("resolveArgv0", () => { expect(result).toMatch(/sh$/); }); - it("recurses into env --split-string long form", () => { + itUnix("recurses into env --split-string long form", () => { const result = resolveArgv0("env --split-string '/bin/sh -c echo'"); expect(result).not.toBeNull(); expect(result).toMatch(/sh$/); }); - it("looks through env -C with a quoted directory arg containing spaces", () => { + itUnix("looks through env -C with a quoted directory arg containing spaces", () => { // env -C "/path with space" /bin/sh — the dir arg is quoted; must not leave a // dangling fragment that gets treated as the command. const result = resolveArgv0('env -C "/path with space" /bin/sh -c echo'); @@ -856,7 +860,7 @@ describe("resolveArgv0", () => { expect(result).toMatch(/sh$/); }); - it("looks through env --chdir with a quoted directory arg", () => { + itUnix("looks through env --chdir with a quoted directory arg", () => { const result = resolveArgv0("env --chdir '/tmp/my dir' /bin/sh -c echo"); expect(result).not.toBeNull(); expect(result).toMatch(/sh$/); diff --git a/src/infra/access-policy.ts b/src/infra/access-policy.ts index 2b3aa514581..757af4d6f9e 100644 --- a/src/infra/access-policy.ts +++ b/src/infra/access-policy.ts @@ -460,7 +460,7 @@ export function resolveArgv0(command: string, cwd?: string): string | null { // 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) === "env" && commandRest) { + if (path.basename(token, path.extname(token)) === "env" && commandRest) { // Strip the env/"/usr/bin/env" token itself from commandRest. let afterEnv = commandRest.replace(/^\S+\s*/, ""); // Skip env options and their arguments so `env -i /script.sh` resolves to @@ -532,7 +532,11 @@ export function applyScriptPolicyOverride( if (!k.startsWith("~")) { return false; } - return k.replace(/^~(?=$|[/\\])/, os.homedir()) === resolvedArgv0; + // path.normalize() harmonises separator style so "~/bin/x" expanded on + // Windows (C:\Users\Runner/bin/x) matches resolvedArgv0 (C:\Users\Runner\bin\x). + return ( + path.normalize(k.replace(/^~(?=$|[/\\])/, os.homedir())) === path.normalize(resolvedArgv0) + ); })?.[1]) : undefined; if (!override) { From 8e0e02d7ac362c4153d25f3225e23a8028865967 Mon Sep 17 00:00:00 2001 From: subrih Date: Fri, 13 Mar 2026 22:06:00 -0700 Subject: [PATCH 18/29] fix(access-policy): scripts deep-merge, mid-path wildcard diagnostic, test reset exports --- src/agents/bash-tools.exec-runtime.test.ts | 24 +++++++++- src/agents/bash-tools.exec-runtime.ts | 10 ++++ src/infra/access-policy-file.test.ts | 55 ++++++++++++++++++++++ src/infra/access-policy-file.ts | 30 ++++++++++-- src/infra/access-policy.test.ts | 44 +++++++++++++++++ src/infra/access-policy.ts | 34 +++++++++++++ 6 files changed, 192 insertions(+), 5 deletions(-) diff --git a/src/agents/bash-tools.exec-runtime.test.ts b/src/agents/bash-tools.exec-runtime.test.ts index 35a38b5483d..53291425e70 100644 --- a/src/agents/bash-tools.exec-runtime.test.ts +++ b/src/agents/bash-tools.exec-runtime.test.ts @@ -10,7 +10,11 @@ vi.mock("../infra/system-events.js", () => ({ import { requestHeartbeatNow } from "../infra/heartbeat-wake.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 enqueueSystemEventMock = vi.mocked(enqueueSystemEvent); @@ -62,3 +66,21 @@ describe("emitExecSystemEvent", () => { 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(); + }); +}); diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 67cd18ced2e..3d63b08a358 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -311,6 +311,11 @@ function _warnBwrapUnavailableOnce(): void { ); } +/** 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) { @@ -322,6 +327,11 @@ function _warnWindowsUnconfiguredOnce(): void { ); } +/** Reset the one-time Windows-unconfigured warning flag. Only for use in tests. */ +export function _resetWindowsUnconfiguredWarnedForTest(): void { + _windowsUnconfiguredWarned = false; +} + export async function runExecProcess(opts: { command: string; // Execute this instead of `command` (which is kept for display/session/logging). diff --git a/src/infra/access-policy-file.test.ts b/src/infra/access-policy-file.test.ts index 62b014b5a94..afd54177624 100644 --- a/src/infra/access-policy-file.test.ts +++ b/src/infra/access-policy-file.test.ts @@ -102,6 +102,61 @@ describe("mergeAccessPolicy", () => { expect(result?.deny).toBeUndefined(); expect(result?.rules).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", + rules: { "~/deploy/**": "rwx" as const }, + deny: ["~/.ssh/**"], + }, + }, + }; + const override = { + scripts: { + "/usr/local/bin/deploy.sh": { + // Agent block supplies same key — must NOT be able to drop sha256 or deny[]. + rules: { "~/deploy/**": "r--" as const }, // narrower override — fine + deny: ["~/extra-deny/**"], + }, + }, + }; + const result = mergeAccessPolicy(base, override); + const merged = result?.scripts?.["/usr/local/bin/deploy.sh"]; + // sha256 from base must survive. + expect(merged?.sha256).toBe("abc123"); + // deny[] must be additive — base deny cannot be removed. + expect(merged?.deny).toContain("~/.ssh/**"); + expect(merged?.deny).toContain("~/extra-deny/**"); + // rules: override key wins on collision. + expect(merged?.rules?.["~/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": { rules: { "/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. + expect(result?.scripts?.["/bin/new.sh"]?.rules?.["/tmp/**"]).toBe("rwx"); + }); + + it("scripts deep-merge: base deny[] cannot be removed by override supplying empty deny[]", () => { + const base = { + scripts: { "/bin/s.sh": { deny: ["~/.secrets/**"] } }, + }; + const override = { + scripts: { "/bin/s.sh": { deny: [] } }, // empty override deny — base must survive + }; + const result = mergeAccessPolicy(base, override); + expect(result?.scripts?.["/bin/s.sh"]?.deny).toContain("~/.secrets/**"); + }); }); // --------------------------------------------------------------------------- diff --git a/src/infra/access-policy-file.ts b/src/infra/access-policy-file.ts index 1ce1fb5f2b4..60955ffaf5d 100644 --- a/src/infra/access-policy-file.ts +++ b/src/infra/access-policy-file.ts @@ -50,8 +50,30 @@ export function mergeAccessPolicy( } const deny = [...(base.deny ?? []), ...(override.deny ?? [])]; const rules = { ...base.rules, ...override.rules }; - // scripts: shallow merge — override key wins (same semantics as rules) - const scripts = { ...base.scripts, ...override.scripts }; + // scripts: deep-merge per key — base sha256 and deny[] are preserved regardless of + // what the agent override supplies. A plain spread ({ ...base.scripts, ...override.scripts }) + // would silently drop the admin-configured hash integrity check and per-script deny list + // when an agent block supplies the same script key, defeating the security intent. + const mergedScripts: NonNullable = { ...base.scripts }; + for (const [key, overrideEntry] of Object.entries(override.scripts ?? {})) { + const baseEntry = base.scripts?.[key]; + if (!baseEntry) { + mergedScripts[key] = overrideEntry; + continue; + } + const entryDeny = [...(baseEntry.deny ?? []), ...(overrideEntry.deny ?? [])]; + mergedScripts[key] = { + // sha256: base always wins — cannot be removed or replaced by an agent override. + ...(baseEntry.sha256 !== undefined ? { sha256: baseEntry.sha256 } : {}), + // rules: shallow-merge, override key wins on collision. + ...(Object.keys({ ...baseEntry.rules, ...overrideEntry.rules }).length > 0 + ? { rules: { ...baseEntry.rules, ...overrideEntry.rules } } + : {}), + // deny: additive — base per-script deny cannot be removed. + ...(entryDeny.length > 0 ? { deny: entryDeny } : {}), + }; + } + const scripts = Object.keys(mergedScripts).length > 0 ? mergedScripts : undefined; const result: AccessPolicyConfig = {}; if (deny.length > 0) { result.deny = deny; @@ -59,7 +81,7 @@ export function mergeAccessPolicy( if (Object.keys(rules).length > 0) { result.rules = rules; } - if (Object.keys(scripts).length > 0) { + if (scripts) { result.scripts = scripts; } if (override.default !== undefined) { @@ -228,7 +250,7 @@ export function resolveAccessPolicyForAgent(agentId?: string): AccessPolicyConfi // 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"))) { + 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).`); } } diff --git a/src/infra/access-policy.test.ts b/src/infra/access-policy.test.ts index c34536b6beb..f9f16559190 100644 --- a/src/infra/access-policy.test.ts +++ b/src/infra/access-policy.test.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import type { AccessPolicyConfig } from "../config/types.tools.js"; import { _resetAutoExpandedWarnedForTest, + _resetMidPathWildcardWarnedForTest, applyScriptPolicyOverride, checkAccessPolicy, findBestRule, @@ -26,6 +27,7 @@ const HOME = os.homedir(); describe("validateAccessPolicyConfig", () => { beforeEach(() => { _resetAutoExpandedWarnedForTest(); + _resetMidPathWildcardWarnedForTest(); }); it("returns no errors for a valid config", () => { @@ -255,6 +257,48 @@ describe("validateAccessPolicyConfig", () => { }), ).toEqual([]); }); + + it("emits a one-time diagnostic for mid-path wildcard rules (OS-level enforcement skipped)", () => { + _resetMidPathWildcardWarnedForTest(); + // "/home/*/secrets/**" has a wildcard in a non-final segment — bwrap and + // Seatbelt cannot derive a concrete mount path so they skip it silently. + // validateAccessPolicyConfig must surface this so operators know. + const errs = validateAccessPolicyConfig({ + rules: { "/home/*/secrets/**": "---" }, + }); + expect(errs).toHaveLength(1); + expect(errs[0]).toMatch(/mid-path wildcard/); + expect(errs[0]).toMatch(/OS-level.*enforcement/); + }); + + it("deduplicates mid-path wildcard rule diagnostics across calls", () => { + _resetMidPathWildcardWarnedForTest(); + const config = { rules: { "/home/*/secrets/**": "---" } }; + const first = validateAccessPolicyConfig(config); + const second = validateAccessPolicyConfig(config); + expect(first.filter((e) => e.includes("mid-path wildcard"))).toHaveLength(1); + expect(second.filter((e) => e.includes("mid-path wildcard"))).toHaveLength(0); + }); + + it("emits a one-time diagnostic for mid-path wildcard deny[] entries", () => { + _resetMidPathWildcardWarnedForTest(); + const errs = validateAccessPolicyConfig({ + deny: ["/tmp/*/sensitive/**"], + }); + expect(errs).toHaveLength(1); + expect(errs[0]).toMatch(/mid-path wildcard/); + expect(errs[0]).toMatch(/OS-level.*enforcement/); + }); + + it("does NOT emit mid-path wildcard diagnostic for final-segment wildcards", () => { + _resetMidPathWildcardWarnedForTest(); + // "/home/user/**" — wildcard is in the final segment, no path separator follows. + const errs = validateAccessPolicyConfig({ + rules: { "/home/user/**": "r--", "~/**": "rwx" }, + deny: ["/tmp/**"], + }); + expect(errs.filter((e) => e.includes("mid-path wildcard"))).toHaveLength(0); + }); }); // --------------------------------------------------------------------------- diff --git a/src/infra/access-policy.ts b/src/infra/access-policy.ts index 757af4d6f9e..860d6d0505f 100644 --- a/src/infra/access-policy.ts +++ b/src/infra/access-policy.ts @@ -18,6 +18,28 @@ export function _resetAutoExpandedWarnedForTest(): void { _autoExpandedWarned.clear(); } +// Track mid-path wildcard patterns already warned about — one diagnostic per pattern. +const _midPathWildcardWarned = new Set(); + +/** 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)); +} + /** * Validates and normalizes an AccessPolicyConfig for well-formedness. * Returns an array of human-readable diagnostic strings; empty = valid. @@ -42,6 +64,12 @@ export function validateAccessPolicyConfig(config: AccessPolicyConfig): string[] `access-policy.rules["${pattern}"] "${perm}" is invalid: must be exactly 3 chars (e.g. "rwx", "r--", "---")`, ); } + if (hasMidPathWildcard(pattern) && !_midPathWildcardWarned.has(`rules:${pattern}`)) { + _midPathWildcardWarned.add(`rules:${pattern}`); + errors.push( + `access-policy.rules["${pattern}"] contains a mid-path wildcard — OS-level (bwrap/Seatbelt) enforcement cannot apply; tool-layer enforcement is still active.`, + ); + } // 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. @@ -105,6 +133,12 @@ export function validateAccessPolicyConfig(config: AccessPolicyConfig): string[] errors.push(`access-policy.deny[${i}] must be a non-empty glob pattern`); continue; } + if (hasMidPathWildcard(pattern) && !_midPathWildcardWarned.has(`deny:${pattern}`)) { + _midPathWildcardWarned.add(`deny:${pattern}`); + errors.push( + `access-policy.deny entry "${pattern}" contains a mid-path wildcard — OS-level (bwrap/Seatbelt) enforcement cannot apply; tool-layer enforcement is still active.`, + ); + } // Bare-path auto-expand for directories: "~/.ssh" → "~/.ssh/**" so the // entire directory tree is denied, not just the directory inode itself. // For paths that exist and are confirmed files (statSync), keep the bare From 6b9828182cb07622d9f1ce9404bd917330eac43e Mon Sep 17 00:00:00 2001 From: subrih Date: Fri, 13 Mar 2026 22:27:30 -0700 Subject: [PATCH 19/29] fix(access-policy): remove dotdir looksLikeFile gap, drop dead bwrap tmpfs branch --- src/infra/access-policy.test.ts | 18 ++++++++++-------- src/infra/access-policy.ts | 17 ++++++++++------- src/infra/exec-sandbox-bwrap.test.ts | 15 +++++++++++++++ src/infra/exec-sandbox-bwrap.ts | 7 ++++++- 4 files changed, 41 insertions(+), 16 deletions(-) diff --git a/src/infra/access-policy.test.ts b/src/infra/access-policy.test.ts index f9f16559190..0fb47be1b90 100644 --- a/src/infra/access-policy.test.ts +++ b/src/infra/access-policy.test.ts @@ -128,16 +128,18 @@ describe("validateAccessPolicyConfig", () => { expect(config.deny?.[0]).toBe(fileLikePath); // must NOT be expanded to /** }); - it("does NOT auto-expand dotfile-style non-existent deny[] paths (.env, .netrc)", () => { - // Leading-dot names like ".env" or ".netrc" are files, not directories. Expanding - // "~/.env" to "~/.env/**" would protect only non-existent children, leaving the - // file itself unprotected once created. The heuristic treats leading-dot names - // (no further dot/slash) as file-like and skips auto-expansion. + it("auto-expands non-existent bare dotnames (.ssh, .aws, .env) to /** — treats as future directory", () => { + // Bare dotnames without a secondary extension (.ssh, .aws, .env, .gnupg) cannot be + // reliably identified as file vs. directory before they exist. The safe choice is to + // expand to /** so the subtree is protected if a directory is created there. + // When the path later exists as a file, statSync confirms it and the bare pattern is kept. const base = os.tmpdir(); - for (const name of [".env", ".netrc", ".htpasswd", ".npmrc"]) { - const config: AccessPolicyConfig = { deny: [path.join(base, name)] }; + for (const name of [".ssh", ".aws", ".env", ".netrc", ".gnupg", ".config"]) { + _resetAutoExpandedWarnedForTest(); + const p = path.join(base, name); + const config: AccessPolicyConfig = { deny: [p] }; validateAccessPolicyConfig(config); - expect(config.deny?.[0]).toBe(path.join(base, name)); // must NOT be expanded to /** + expect(config.deny?.[0]).toBe(`${p}/**`); // expanded to protect subtree } }); diff --git a/src/infra/access-policy.ts b/src/infra/access-policy.ts index 860d6d0505f..888244bafa1 100644 --- a/src/infra/access-policy.ts +++ b/src/infra/access-policy.ts @@ -167,13 +167,16 @@ export function validateAccessPolicyConfig(config: AccessPolicyConfig): string[] .replace(/[/\\]$/, "") .split(/[/\\]/) .pop() ?? ""; - // Looks like a file when either: - // a) Pure dotfile name: leading dot, no further dot/slash — covers ".env", - // ".netrc", ".htpasswd". These are typically files, not directories. - // b) Non-leading dot with a letter-containing extension — covers "secrets.key", - // "config.json". Digit-only extensions (v1.0, app-2.3) are treated as - // versioned directory names and excluded. - looksLikeFile = /^\.[^./\\]+$/.test(lastName) || /[^.]\.[a-zA-Z][^/\\]*$/.test(lastName); + // Looks like a file when the last segment has a non-leading dot followed by a + // letter-containing extension — covers "secrets.key", "config.json", ".npmrc". + // Digit-only suffixes (v1.0, app-2.3) are treated as versioned directory names. + // Bare dotnames without a secondary extension (.ssh, .aws, .env, .gnupg) are + // NOT treated as file-like: they are expanded to /** so the subtree is protected + // when the path does not yet exist. For .env-style plain files the expansion is + // conservative but safe — once the file exists, statSync confirms it and the bare + // path is kept. The leading-dot heuristic was removed because it also matched + // common directory names (.ssh, .aws, .config) and silently skipped expansion. + looksLikeFile = /[^.]\.[a-zA-Z][^/\\]*$/.test(lastName); } if (!isExistingFile && !looksLikeFile) { const fixed = `${pattern}/**`; diff --git a/src/infra/exec-sandbox-bwrap.test.ts b/src/infra/exec-sandbox-bwrap.test.ts index 0c1e181e012..bbd37064ee3 100644 --- a/src/infra/exec-sandbox-bwrap.test.ts +++ b/src/infra/exec-sandbox-bwrap.test.ts @@ -138,6 +138,21 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { 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 = { default: "r--", rules: { "/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 (default: ---)", () => { const config: AccessPolicyConfig = { default: "---" }; const args = generateBwrapArgs(config, HOME); diff --git a/src/infra/exec-sandbox-bwrap.ts b/src/infra/exec-sandbox-bwrap.ts index 17f3162f0a6..279193de2aa 100644 --- a/src/infra/exec-sandbox-bwrap.ts +++ b/src/infra/exec-sandbox-bwrap.ts @@ -164,7 +164,12 @@ export function generateBwrapArgs( // control tmpfs access explicitly (e.g. "/tmp/**":"rwx" is handled by the rules loop). if (defaultAllowsRead) { const explicitTmpPerm = findBestRule("/tmp/.", config.rules ?? {}, homeDir); - if (explicitTmpPerm === null || explicitTmpPerm[1] === "w") { + 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"); } } From 4c0ffe0884a79940cc3cfed414d199b2a72f3f39 Mon Sep 17 00:00:00 2001 From: subrih Date: Fri, 13 Mar 2026 22:44:11 -0700 Subject: [PATCH 20/29] fix(access-policy): bwrap file-path tmpfs guard, seatbelt /tmp exec bit, mtime cache --- src/infra/access-policy-file.test.ts | 53 +++++++++++++++++++++++++ src/infra/access-policy-file.ts | 40 ++++++++++++++++++- src/infra/exec-sandbox-bwrap.test.ts | 17 ++++++++ src/infra/exec-sandbox-bwrap.ts | 15 ++++++- src/infra/exec-sandbox-seatbelt.test.ts | 27 +++++++++++++ src/infra/exec-sandbox-seatbelt.ts | 3 ++ 6 files changed, 153 insertions(+), 2 deletions(-) diff --git a/src/infra/access-policy-file.test.ts b/src/infra/access-policy-file.test.ts index afd54177624..6255ae48b96 100644 --- a/src/infra/access-policy-file.test.ts +++ b/src/infra/access-policy-file.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { BROKEN_POLICY_FILE, + _resetFileCacheForTest, _resetNotFoundWarnedForTest, loadAccessPolicyFile, mergeAccessPolicy, @@ -29,6 +30,7 @@ const FP_DIR = path.dirname(FP_FILE); beforeEach(() => { fs.mkdirSync(FP_DIR, { recursive: true }); _resetNotFoundWarnedForTest(); + _resetFileCacheForTest(); }); afterEach(() => { @@ -254,6 +256,57 @@ describe("loadAccessPolicyFile", () => { }); }); +// --------------------------------------------------------------------------- +// loadAccessPolicyFile — mtime cache +// --------------------------------------------------------------------------- + +describe("loadAccessPolicyFile — mtime cache", () => { + it("returns cached result on second call without re-reading the file", () => { + writeFile({ version: 1, base: { default: "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, base: { default: "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, base: { default: "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.base?.default).toBe("rwx"); + }); + + it("clears cache when file is deleted", () => { + writeFile({ version: 1, base: { default: "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 // --------------------------------------------------------------------------- diff --git a/src/infra/access-policy-file.ts b/src/infra/access-policy-file.ts index 60955ffaf5d..87c613ffad3 100644 --- a/src/infra/access-policy-file.ts +++ b/src/infra/access-policy-file.ts @@ -138,19 +138,57 @@ function validateAccessPolicyFileStructure(filePath: string, parsed: unknown): s */ 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(); - if (!fs.existsSync(filePath)) { + + // 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"); diff --git a/src/infra/exec-sandbox-bwrap.test.ts b/src/infra/exec-sandbox-bwrap.test.ts index bbd37064ee3..cddd53e0835 100644 --- a/src/infra/exec-sandbox-bwrap.test.ts +++ b/src/infra/exec-sandbox-bwrap.test.ts @@ -177,6 +177,23 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { expect(bindMounts).not.toContain(`${HOME}/secret`); }); + it("narrowing rule on an existing file does not emit --tmpfs (bwrap only accepts dirs)", () => { + // Reproducer: { "default": "r--", "rules": { "~/secrets.key": "---" } } + // where ~/secrets.key is an existing file. The old code emitted --tmpfs on the + // file path, causing bwrap to abort with "Not a directory". Fix: mirror the + // isDir guard already present in the deny[] branch. + // process.execPath is always an existing file — use it as the test target. + const filePath = process.execPath; + const config: AccessPolicyConfig = { + default: "r--", + rules: { [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 = { diff --git a/src/infra/exec-sandbox-bwrap.ts b/src/infra/exec-sandbox-bwrap.ts index 279193de2aa..7f2aa197ee6 100644 --- a/src/infra/exec-sandbox-bwrap.ts +++ b/src/infra/exec-sandbox-bwrap.ts @@ -205,7 +205,20 @@ export function generateBwrapArgs( // path is hidden even though --ro-bind / / made it readable by default. // This mirrors what deny[] does — without this, "---" rules under a permissive // default are silently ignored at the bwrap layer. - args.push("--tmpfs", p); + // 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. } diff --git a/src/infra/exec-sandbox-seatbelt.test.ts b/src/infra/exec-sandbox-seatbelt.test.ts index eaa97e95dec..b66d64db877 100644 --- a/src/infra/exec-sandbox-seatbelt.test.ts +++ b/src/infra/exec-sandbox-seatbelt.test.ts @@ -183,6 +183,33 @@ describe("generateSeatbeltProfile", () => { expect(profile).not.toMatch(/allow file-read[^)]*\(subpath "\/private\/tmp"\)/); }); + it("exec-only /tmp rule grants process-exec* on /private/tmp in restrictive mode", () => { + // Regression: when default:"---" and a rule grants exec on /tmp (e.g. "--x"), + // the seatbelt profile must emit (allow process-exec* (subpath "/private/tmp")). + // Without this fix, no exec allowance was emitted and binaries in /tmp could not + // be executed even though the policy explicitly permitted it. + const config: AccessPolicyConfig = { + default: "---", + rules: { "/tmp/**": "--x" }, + }; + const profile = generateSeatbeltProfile(config, HOME); + expect(profile).toMatch(/allow process-exec\*[^)]*\(subpath "\/private\/tmp"\)/); + // Read and write must NOT be granted. + 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 in restrictive mode", () => { + const config: AccessPolicyConfig = { + default: "---", + rules: { "/tmp/**": "r-x" }, + }; + const profile = generateSeatbeltProfile(config, 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 // diff --git a/src/infra/exec-sandbox-seatbelt.ts b/src/infra/exec-sandbox-seatbelt.ts index bb8ce918067..081110fc03d 100644 --- a/src/infra/exec-sandbox-seatbelt.ts +++ b/src/infra/exec-sandbox-seatbelt.ts @@ -218,6 +218,9 @@ export function generateSeatbeltProfile( if (tmpPerm[1] === "w") { lines.push(`(allow file-write* (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)`); lines.push(`(allow mach*)`); From 849077ecb57a2232cfbace2ba6c0d320dc1c607b Mon Sep 17 00:00:00 2001 From: subrih Date: Fri, 13 Mar 2026 23:14:36 -0700 Subject: [PATCH 21/29] fix(access-policy): resolveArgv0 -S= and -SVAL forms, depth cap, bwrap reset export, npmrc comment --- src/infra/access-policy.test.ts | 33 ++++++++++++++++++++++ src/infra/access-policy.ts | 42 ++++++++++++++++++++-------- src/infra/exec-sandbox-bwrap.test.ts | 18 ++++++++++++ src/infra/exec-sandbox-bwrap.ts | 4 +++ 4 files changed, 86 insertions(+), 11 deletions(-) diff --git a/src/infra/access-policy.test.ts b/src/infra/access-policy.test.ts index 0fb47be1b90..e8876bbfebf 100644 --- a/src/infra/access-policy.test.ts +++ b/src/infra/access-policy.test.ts @@ -911,6 +911,39 @@ describe("resolveArgv0", () => { expect(result).not.toBeNull(); expect(result).toMatch(/sh$/); }); + + itUnix("recurses into env --split-string=VALUE equals form", () => { + // --split-string=CMD (equals form) was previously not handled — resolveArgv0 + // returned null, causing the fallback to treat "env" as argv0 and silently + // bypass tool-layer hash/policy checks for the embedded script. + const result = resolveArgv0("env --split-string='/bin/sh -c echo'"); + expect(result).not.toBeNull(); + expect(result).toMatch(/sh$/); + }); + + itUnix("recurses into env -S=VALUE equals form (short flag with equals)", () => { + const result = resolveArgv0("env -S='/bin/sh -c echo'"); + expect(result).not.toBeNull(); + expect(result).toMatch(/sh$/); + }); + + itUnix("recurses into env -SVALUE compact form (no space, no equals)", () => { + const result = resolveArgv0("env -S'/bin/sh -c echo'"); + expect(result).not.toBeNull(); + expect(result).toMatch(/sh$/); + }); + + it("returns null for deeply nested env -S to prevent stack overflow", () => { + // Build a deeply nested "env -S 'env -S ...' " string beyond the depth cap (8). + let cmd = "/bin/sh"; + for (let i = 0; i < 10; i++) { + cmd = `env -S '${cmd}'`; + } + // Should not throw; depth cap returns null before stack overflow. + expect(() => resolveArgv0(cmd)).not.toThrow(); + // Result may be null (cap hit) or a resolved path — either is acceptable. + // The important invariant is: no RangeError. + }); }); // --------------------------------------------------------------------------- diff --git a/src/infra/access-policy.ts b/src/infra/access-policy.ts index 888244bafa1..c4ddcde9a28 100644 --- a/src/infra/access-policy.ts +++ b/src/infra/access-policy.ts @@ -168,7 +168,9 @@ export function validateAccessPolicyConfig(config: AccessPolicyConfig): string[] .split(/[/\\]/) .pop() ?? ""; // Looks like a file when the last segment has a non-leading dot followed by a - // letter-containing extension — covers "secrets.key", "config.json", ".npmrc". + // letter-containing extension — covers "secrets.key", "config.json". + // Note: pure dotnames like ".npmrc", ".env", ".ssh" do NOT match this regex + // (they have no non-leading dot) and are therefore expanded to /** below. // Digit-only suffixes (v1.0, app-2.3) are treated as versioned directory names. // Bare dotnames without a secondary extension (.ssh, .aws, .env, .gnupg) are // NOT treated as file-like: they are expanded to /** so the subtree is protected @@ -406,7 +408,12 @@ function findOnPath(name: string, pathOverride?: string): string | null { * * Returns null when the command is empty or the path cannot be determined. */ -export function resolveArgv0(command: string, cwd?: string): string | null { +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; @@ -509,23 +516,36 @@ export function resolveArgv0(command: string, cwd?: string): string | null { // 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+/; - const envSplitStringRe = /^(-S|--(split-string))\s+/; while (afterEnv) { if (afterEnv === "--" || afterEnv.startsWith("-- ")) { afterEnv = afterEnv.slice(2).trimStart(); break; // -- terminates env options; what follows is the command } - if (envSplitStringRe.test(afterEnv)) { - // -S/--split-string: the argument is itself a command string — recurse into it. - afterEnv = afterEnv.replace(/^\S+\s+/, ""); // strip "-S " or "--split-string " + // -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 ( - (afterEnv.startsWith('"') && afterEnv.endsWith('"')) || - (afterEnv.startsWith("'") && afterEnv.endsWith("'")) + (inner.startsWith('"') && inner.endsWith('"')) || + (inner.startsWith("'") && inner.endsWith("'")) ) { - afterEnv = afterEnv.slice(1, -1); + inner = inner.slice(1, -1); } - return afterEnv ? resolveArgv0(afterEnv, cwd) : null; + return inner ? resolveArgv0(inner, cwd, _depth + 1) : null; } if (envOptWithArgRe.test(afterEnv)) { // Strip option + its argument; handle quoted values with spaces. @@ -536,7 +556,7 @@ export function resolveArgv0(command: string, cwd?: string): string | null { break; // first non-option token — may still be NAME=value, handled by recursion } } - return afterEnv ? resolveArgv0(afterEnv, cwd) : null; + return afterEnv ? resolveArgv0(afterEnv, cwd, _depth + 1) : null; } return token; diff --git a/src/infra/exec-sandbox-bwrap.test.ts b/src/infra/exec-sandbox-bwrap.test.ts index cddd53e0835..d44cf2447d0 100644 --- a/src/infra/exec-sandbox-bwrap.test.ts +++ b/src/infra/exec-sandbox-bwrap.test.ts @@ -2,6 +2,7 @@ import os from "node:os"; import { describe, expect, it, vi } from "vitest"; import type { AccessPolicyConfig } from "../config/types.tools.js"; import { + _resetBwrapFileDenyWarnedPathsForTest, _warnBwrapFileDenyOnce, generateBwrapArgs, wrapCommandWithBwrap, @@ -411,3 +412,20 @@ describe("wrapCommandWithBwrap", () => { expect(result).toContain("cat /etc/hosts"); }); }); + +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(); + }); +}); diff --git a/src/infra/exec-sandbox-bwrap.ts b/src/infra/exec-sandbox-bwrap.ts index 7f2aa197ee6..a041d84b5d9 100644 --- a/src/infra/exec-sandbox-bwrap.ts +++ b/src/infra/exec-sandbox-bwrap.ts @@ -33,6 +33,10 @@ let bwrapAvailableCache: boolean | undefined; // the file directly inside the sandbox are not blocked at the syscall level. // See docs/tools/access-policy.md — "File-specific deny[] entries on Linux". const _bwrapFileDenyWarnedPaths = new Set(); +/** Reset the one-time file-deny warning set. Only for use in tests. */ +export function _resetBwrapFileDenyWarnedPathsForTest(): void { + _bwrapFileDenyWarnedPaths.clear(); +} export function _warnBwrapFileDenyOnce(filePath: string): void { if (_bwrapFileDenyWarnedPaths.has(filePath)) { return; From 4f4d1af887a53ad7a3634b8becb9c401a51d5d20 Mon Sep 17 00:00:00 2001 From: subrih Date: Sat, 14 Mar 2026 05:06:58 -0700 Subject: [PATCH 22/29] fix(access-policy): resolve symlink keys in scripts lookup, document /bin/sh and network* intent --- src/agents/bash-tools.exec-runtime.ts | 17 +++---- src/infra/access-policy.test.ts | 65 ++++++++++++++++++++++++++- src/infra/access-policy.ts | 42 +++++++++++------ src/infra/exec-sandbox-bwrap.ts | 5 +++ src/infra/exec-sandbox-seatbelt.ts | 9 ++++ 5 files changed, 114 insertions(+), 24 deletions(-) diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 3d63b08a358..9252ea0056b 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -22,6 +22,7 @@ import { applyScriptPolicyOverride, checkAccessPolicy, resolveArgv0, + resolveScriptKey, } from "../infra/access-policy.js"; import { isBwrapAvailable, wrapCommandWithBwrap } from "../infra/exec-sandbox-bwrap.js"; import { @@ -388,15 +389,15 @@ export async function runExecProcess(opts: { // itself. A script in both deny[] and scripts{} is a config error; the deny wins. // For scripts{} entries not in deny[], we skip the broader rules/default check // so a sha256-matched script doesn't also need an explicit exec rule in the base policy. - // Mirror the tilde-expansion logic in applyScriptPolicyOverride so that - // scripts keys written as "~/bin/deploy.sh" are matched even though argv0 - // is always an absolute path after resolveArgv0. + // 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. Without symlink + // resolution, a symlink-keyed script would silently bypass the override gate, + // running under the base policy with no integrity or deny-narrowing checks. const _scripts = opts.permissions.scripts ?? {}; - const hasScriptOverride = - argv0 in _scripts || - Object.keys(_scripts).some( - (k) => k.startsWith("~") && k.replace(/^~(?=$|[/\\])/, os.homedir()) === argv0, - ); + const hasScriptOverride = Object.keys(_scripts).some( + (k) => path.normalize(resolveScriptKey(k)) === path.normalize(argv0), + ); // Use default:"rwx" so only actual deny-pattern hits produce "deny". // Without a default, permAllows(undefined, "exec") → false → every path // not matched by a deny pattern would be incorrectly blocked. diff --git a/src/infra/access-policy.test.ts b/src/infra/access-policy.test.ts index e8876bbfebf..f92f0c63b3d 100644 --- a/src/infra/access-policy.test.ts +++ b/src/infra/access-policy.test.ts @@ -11,6 +11,7 @@ import { checkAccessPolicy, findBestRule, resolveArgv0, + resolveScriptKey, validateAccessPolicyConfig, } from "./access-policy.js"; @@ -946,6 +947,33 @@ describe("resolveArgv0", () => { }); }); +// --------------------------------------------------------------------------- +// resolveScriptKey +// --------------------------------------------------------------------------- + +describe("resolveScriptKey", () => { + it("expands leading ~", () => { + const result = resolveScriptKey("~/bin/deploy.sh"); + expect(result).toBe(path.join(HOME, "bin/deploy.sh")); + }); + + it("returns non-absolute keys unchanged", () => { + expect(resolveScriptKey("deploy.sh")).toBe("deploy.sh"); + }); + + it("returns non-existent absolute path unchanged", () => { + const p = "/no/such/path/definitely-missing-xyz"; + expect(resolveScriptKey(p)).toBe(p); + }); + + it("resolves an absolute path that exists to its real path", () => { + // Use os.tmpdir() itself — guaranteed to exist; realpathSync may resolve + // macOS /tmp → /private/tmp so we accept either the same string or a longer one. + const result = resolveScriptKey(os.tmpdir()); + expect(path.isAbsolute(result)).toBe(true); + }); +}); + // --------------------------------------------------------------------------- // applyScriptPolicyOverride // --------------------------------------------------------------------------- @@ -968,6 +996,31 @@ describe("applyScriptPolicyOverride", () => { expect(policy).toBe(base); }); + it("matches a scripts key that is a symlink to the resolved argv0 path", () => { + // Simulate the symlink case: create a real file and a symlink to it, then + // register the symlink path as the scripts key. resolveArgv0 returns the + // realpathSync result, so the key must be resolved the same way to match. + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ap-symlink-test-")); + const realScript = path.join(tmpDir, "script-real.sh"); + const symlinkScript = path.join(tmpDir, "script-link.sh"); + fs.writeFileSync(realScript, "#!/bin/sh\necho ok\n"); + fs.symlinkSync(realScript, symlinkScript); + try { + const resolvedReal = fs.realpathSync(symlinkScript); + const base: AccessPolicyConfig = { + rules: { "/**": "r--" }, + // Key is the symlink path; resolvedArgv0 will be the real path. + scripts: { [symlinkScript]: { rules: { "/tmp/**": "rwx" } } }, + }; + const { overrideRules, hashMismatch } = applyScriptPolicyOverride(base, resolvedReal); + expect(hashMismatch).toBeUndefined(); + // Without symlink resolution in the key lookup this would be undefined. + expect(overrideRules?.["/tmp/**"]).toBe("rwx"); + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } + }); + it("returns override rules separately so seatbelt emits them after deny", () => { const base: AccessPolicyConfig = { rules: { "/**": "r--" }, @@ -1023,13 +1076,17 @@ describe("applyScriptPolicyOverride", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ap-test-")); const scriptPath = path.join(tmpDir, "script.sh"); fs.writeFileSync(scriptPath, "#!/bin/sh\necho hi\n"); + // resolveArgv0 returns the realpathSync result; simulate that here so the + // key lookup (which also calls realpathSync) matches correctly on macOS where + // os.tmpdir() returns /var/folders/... but realpathSync yields /private/var/... + const realScriptPath = fs.realpathSync(scriptPath); try { const base: AccessPolicyConfig = { scripts: { [scriptPath]: { sha256: "deadbeef".padEnd(64, "0"), rules: { "/tmp/**": "rwx" } }, }, }; - const { policy, hashMismatch } = applyScriptPolicyOverride(base, scriptPath); + const { policy, hashMismatch } = applyScriptPolicyOverride(base, realScriptPath); expect(hashMismatch).toBe(true); expect(policy).toBe(base); } finally { @@ -1056,12 +1113,16 @@ describe("applyScriptPolicyOverride", () => { const content = "#!/bin/sh\necho hi\n"; fs.writeFileSync(scriptPath, content); const hash = crypto.createHash("sha256").update(Buffer.from(content)).digest("hex"); + const realScriptPath = fs.realpathSync(scriptPath); try { const base: AccessPolicyConfig = { rules: { "/**": "r--" }, scripts: { [scriptPath]: { sha256: hash, rules: { "/tmp/**": "rwx" } } }, }; - const { policy, overrideRules, hashMismatch } = applyScriptPolicyOverride(base, scriptPath); + const { policy, overrideRules, hashMismatch } = applyScriptPolicyOverride( + base, + realScriptPath, + ); expect(hashMismatch).toBeUndefined(); expect(overrideRules?.["/tmp/**"]).toBe("rwx"); expect(policy.rules?.["/tmp/**"]).toBeUndefined(); diff --git a/src/infra/access-policy.ts b/src/infra/access-policy.ts index c4ddcde9a28..9081f96cac3 100644 --- a/src/infra/access-policy.ts +++ b/src/infra/access-policy.ts @@ -562,6 +562,26 @@ export function resolveArgv0(command: string, cwd?: string, _depth = 0): string 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 { + const expanded = k.startsWith("~") ? 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. * @@ -579,22 +599,16 @@ export function applyScriptPolicyOverride( policy: AccessPolicyConfig, resolvedArgv0: string, ): { policy: AccessPolicyConfig; overrideRules?: Record; hashMismatch?: true } { - // Normalise ~ in scripts keys so "~/bin/deploy.sh" matches the resolved absolute - // path "/home/user/bin/deploy.sh" that resolveArgv0 returns. A direct lookup - // would always miss tilde-keyed entries, silently skipping sha256 verification. + // 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 override = scripts - ? (scripts[resolvedArgv0] ?? - Object.entries(scripts).find(([k]) => { - if (!k.startsWith("~")) { - return false; - } - // path.normalize() harmonises separator style so "~/bin/x" expanded on - // Windows (C:\Users\Runner/bin/x) matches resolvedArgv0 (C:\Users\Runner\bin\x). - return ( - path.normalize(k.replace(/^~(?=$|[/\\])/, os.homedir())) === path.normalize(resolvedArgv0) - ); - })?.[1]) + ? Object.entries(scripts).find( + ([k]) => path.normalize(resolveScriptKey(k)) === path.normalize(resolvedArgv0), + )?.[1] : undefined; if (!override) { return { policy }; diff --git a/src/infra/exec-sandbox-bwrap.ts b/src/infra/exec-sandbox-bwrap.ts index a041d84b5d9..36ba42db230 100644 --- a/src/infra/exec-sandbox-bwrap.ts +++ b/src/infra/exec-sandbox-bwrap.ts @@ -299,5 +299,10 @@ export function wrapCommandWithBwrap( ): 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)}`; } diff --git a/src/infra/exec-sandbox-seatbelt.ts b/src/infra/exec-sandbox-seatbelt.ts index 081110fc03d..01ea9db7c87 100644 --- a/src/infra/exec-sandbox-seatbelt.ts +++ b/src/infra/exec-sandbox-seatbelt.ts @@ -223,6 +223,11 @@ export function generateSeatbeltProfile( } 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*)`); @@ -356,5 +361,9 @@ export function wrapCommandWithSeatbelt(command: string, profile: string): strin } 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); } From cd2d9c3b8db465947ab9473379f594baf4a5d5b6 Mon Sep 17 00:00:00 2001 From: subrih Date: Sat, 14 Mar 2026 05:44:16 -0700 Subject: [PATCH 23/29] =?UTF-8?q?refactor(access-policy):=20drop=20deny[]?= =?UTF-8?q?=20and=20default=20=E2=80=94=20rules=20only,=20---=20implies=20?= =?UTF-8?q?deny?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agents/bash-tools.exec-runtime.ts | 21 +- .../pi-tools.read.edit-permission.test.ts | 3 - src/config/types.tools.ts | 9 +- src/infra/access-policy-file.test.ts | 122 +++----- src/infra/access-policy-file.ts | 33 +- src/infra/access-policy.test.ts | 285 +++++------------- src/infra/access-policy.ts | 119 +------- src/infra/exec-sandbox-bwrap.test.ts | 124 +++----- src/infra/exec-sandbox-bwrap.ts | 61 ++-- src/infra/exec-sandbox-seatbelt.test.ts | 125 +++----- src/infra/exec-sandbox-seatbelt.ts | 28 +- 11 files changed, 246 insertions(+), 684 deletions(-) diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 9252ea0056b..7e425ab4202 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -384,30 +384,15 @@ export async function runExecProcess(opts: { // enforcement (seatbelt/bwrap) is unavailable (Linux without bwrap, Windows). // Mirrors the checkAccessPolicy calls in read/write tools for consistency. // - // deny[] always wins — checked unconditionally even for scripts{} entries. - // The hash check authorises the policy overlay (effectivePermissions), not exec - // itself. A script in both deny[] and scripts{} is a config error; the deny wins. - // For scripts{} entries not in deny[], we skip the broader rules/default check - // so a sha256-matched script doesn't also need an explicit exec rule in the base policy. + // 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. Without symlink - // resolution, a symlink-keyed script would silently bypass the override gate, - // running under the base policy with no integrity or deny-narrowing checks. + // is always the realpathSync result from resolveArgv0. const _scripts = opts.permissions.scripts ?? {}; const hasScriptOverride = Object.keys(_scripts).some( (k) => path.normalize(resolveScriptKey(k)) === path.normalize(argv0), ); - // Use default:"rwx" so only actual deny-pattern hits produce "deny". - // Without a default, permAllows(undefined, "exec") → false → every path - // not matched by a deny pattern would be incorrectly blocked. - const denyVerdict = checkAccessPolicy(argv0, "exec", { - deny: opts.permissions.deny, - default: "rwx", - }); - if (denyVerdict === "deny") { - throw new Error(`exec denied by access policy: ${argv0}`); - } if (!hasScriptOverride && checkAccessPolicy(argv0, "exec", effectivePermissions) === "deny") { throw new Error(`exec denied by access policy: ${argv0}`); } diff --git a/src/agents/pi-tools.read.edit-permission.test.ts b/src/agents/pi-tools.read.edit-permission.test.ts index b98b8411836..a5a0c632642 100644 --- a/src/agents/pi-tools.read.edit-permission.test.ts +++ b/src/agents/pi-tools.read.edit-permission.test.ts @@ -55,7 +55,6 @@ describe("createHostWorkspaceEditTool edit read-permission check", () => { // "-w-" policy: write allowed, read denied. // Edit must NOT be allowed to read the file even if write is permitted. const permissions: AccessPolicyConfig = { - default: "---", rules: { [`${tmpDir}/**`]: "-w-" }, }; createHostWorkspaceEditTool(tmpDir, { workspaceOnly: false, permissions }); @@ -73,7 +72,6 @@ describe("createHostWorkspaceEditTool edit read-permission check", () => { await fs.writeFile(filePath, "content", "utf8"); const permissions: AccessPolicyConfig = { - default: "---", rules: { [`${tmpDir}/**`]: "rw-" }, }; createHostWorkspaceEditTool(tmpDir, { workspaceOnly: false, permissions }); @@ -92,7 +90,6 @@ describe("createHostWorkspaceEditTool edit read-permission check", () => { // "r--" policy: read allowed, write denied. const permissions: AccessPolicyConfig = { - default: "---", rules: { [`${tmpDir}/**`]: "r--" }, }; createHostWorkspaceEditTool(tmpDir, { workspaceOnly: false, permissions }); diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index d7422fe3e41..41e6b933af6 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -10,8 +10,6 @@ export type PermStr = string; export type ScriptPolicyEntry = { /** Restrict/expand rules for this script. Merged over the base policy rules. */ rules?: Record; - /** Additional deny patterns added when this script runs (additive). */ - deny?: string[]; /** SHA-256 hex of the script file for integrity checking (best-effort, not atomic). */ sha256?: string; }; @@ -19,14 +17,13 @@ export type ScriptPolicyEntry = { /** * 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 = { - /** Fallback permission when no rule matches. Defaults to `"---"` (deny-all) when absent. */ - default?: PermStr; /** Glob-pattern rules: path → permission string. Longest prefix wins. */ rules?: Record; - /** Patterns that are always denied regardless of rules (additive across merges). */ - deny?: string[]; /** Per-script argv0 policy overrides keyed by resolved binary path. */ scripts?: Record; }; diff --git a/src/infra/access-policy-file.test.ts b/src/infra/access-policy-file.test.ts index 6255ae48b96..807883b39c0 100644 --- a/src/infra/access-policy-file.test.ts +++ b/src/infra/access-policy-file.test.ts @@ -56,39 +56,15 @@ describe("mergeAccessPolicy", () => { }); it("returns base when override is undefined", () => { - const base = { default: "r--" }; + const base = { rules: { "/**": "r--" as const } }; expect(mergeAccessPolicy(base, undefined)).toEqual(base); }); it("returns override when base is undefined", () => { - const override = { default: "rwx" }; + const override = { rules: { "/**": "rwx" as const } }; expect(mergeAccessPolicy(undefined, override)).toEqual(override); }); - it("override default wins", () => { - const result = mergeAccessPolicy({ default: "r--" }, { default: "rwx" }); - expect(result?.default).toBe("rwx"); - }); - - it("base default survives when override has no default", () => { - const result = mergeAccessPolicy({ default: "r--" }, { rules: { "/**": "r-x" } }); - expect(result?.default).toBe("r--"); - }); - - it("deny arrays are concatenated — base denies cannot be removed", () => { - const result = mergeAccessPolicy( - { deny: ["~/.ssh/**", "~/.aws/**"] }, - { deny: ["~/.gnupg/**"] }, - ); - expect(result?.deny).toEqual(["~/.ssh/**", "~/.aws/**", "~/.gnupg/**"]); - }); - - it("override deny extends base deny", () => { - const result = mergeAccessPolicy({ deny: ["~/.ssh/**"] }, { deny: ["~/.env"] }); - expect(result?.deny).toContain("~/.ssh/**"); - expect(result?.deny).toContain("~/.env"); - }); - it("rules are shallow-merged, override key wins on collision", () => { const result = mergeAccessPolicy( { rules: { "/**": "r--", "~/**": "rw-" } }, @@ -99,9 +75,8 @@ describe("mergeAccessPolicy", () => { expect(result?.rules?.["~/dev/**"]).toBe("rwx"); // override adds }); - it("omits empty deny/rules from result", () => { - const result = mergeAccessPolicy({ default: "r--" }, { default: "rwx" }); - expect(result?.deny).toBeUndefined(); + it("omits empty rules from result", () => { + const result = mergeAccessPolicy({ scripts: { "/s.sh": { sha256: "abc" } } }, {}); expect(result?.rules).toBeUndefined(); }); @@ -113,16 +88,14 @@ describe("mergeAccessPolicy", () => { "/usr/local/bin/deploy.sh": { sha256: "abc123", rules: { "~/deploy/**": "rwx" as const }, - deny: ["~/.ssh/**"], }, }, }; const override = { scripts: { "/usr/local/bin/deploy.sh": { - // Agent block supplies same key — must NOT be able to drop sha256 or deny[]. + // Agent block supplies same key — must NOT be able to drop sha256. rules: { "~/deploy/**": "r--" as const }, // narrower override — fine - deny: ["~/extra-deny/**"], }, }, }; @@ -130,9 +103,6 @@ describe("mergeAccessPolicy", () => { const merged = result?.scripts?.["/usr/local/bin/deploy.sh"]; // sha256 from base must survive. expect(merged?.sha256).toBe("abc123"); - // deny[] must be additive — base deny cannot be removed. - expect(merged?.deny).toContain("~/.ssh/**"); - expect(merged?.deny).toContain("~/extra-deny/**"); // rules: override key wins on collision. expect(merged?.rules?.["~/deploy/**"]).toBe("r--"); }); @@ -148,17 +118,6 @@ describe("mergeAccessPolicy", () => { // New script from override is added. expect(result?.scripts?.["/bin/new.sh"]?.rules?.["/tmp/**"]).toBe("rwx"); }); - - it("scripts deep-merge: base deny[] cannot be removed by override supplying empty deny[]", () => { - const base = { - scripts: { "/bin/s.sh": { deny: ["~/.secrets/**"] } }, - }; - const override = { - scripts: { "/bin/s.sh": { deny: [] } }, // empty override deny — base must survive - }; - const result = mergeAccessPolicy(base, override); - expect(result?.scripts?.["/bin/s.sh"]?.deny).toContain("~/.secrets/**"); - }); }); // --------------------------------------------------------------------------- @@ -219,12 +178,12 @@ describe("loadAccessPolicyFile", () => { spy.mockRestore(); }); - it("returns BROKEN_POLICY_FILE and logs error when 'deny' is misplaced at top level", () => { + 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, deny: ["~/.ssh/**"] }); + 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 "deny"')); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('unexpected top-level key "scripts"')); spy.mockRestore(); }); @@ -240,7 +199,7 @@ describe("loadAccessPolicyFile", () => { it("returns parsed file when valid", () => { const content: AccessPolicyFile = { version: 1, - base: { default: "r--", deny: ["~/.ssh/**"] }, + base: { rules: { "/**": "r--", "~/.ssh/**": "---" } }, agents: { subri: { rules: { "~/dev/**": "rwx" } } }, }; writeFile(content); @@ -251,7 +210,7 @@ describe("loadAccessPolicyFile", () => { throw new Error("unexpected"); } expect(result.version).toBe(1); - expect(result.base?.default).toBe("r--"); + expect(result.base?.rules?.["/**"]).toBe("r--"); expect(result.agents?.subri?.rules?.["~/dev/**"]).toBe("rwx"); }); }); @@ -262,7 +221,7 @@ describe("loadAccessPolicyFile", () => { describe("loadAccessPolicyFile — mtime cache", () => { it("returns cached result on second call without re-reading the file", () => { - writeFile({ version: 1, base: { default: "r--" } }); + writeFile({ version: 1, base: { rules: { "/**": "r--" } } }); const spy = vi.spyOn(fs, "readFileSync"); loadAccessPolicyFile(); // populate cache loadAccessPolicyFile(); // should hit cache @@ -272,11 +231,11 @@ describe("loadAccessPolicyFile — mtime cache", () => { }); it("re-reads when mtime changes (file updated)", () => { - writeFile({ version: 1, base: { default: "r--" } }); + writeFile({ version: 1, base: { rules: { "/**": "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, base: { default: "rwx" } }); + writeFile({ version: 1, base: { rules: { "/**": "rwx" } } }); const future = Date.now() / 1000 + 1; fs.utimesSync(FP_FILE, future, future); const result = loadAccessPolicyFile(); @@ -284,7 +243,7 @@ describe("loadAccessPolicyFile — mtime cache", () => { if (result === null || result === BROKEN_POLICY_FILE) { throw new Error("unexpected"); } - expect(result.base?.default).toBe("rwx"); + expect(result.base?.rules?.["/**"]).toBe("rwx"); }); it("clears cache when file is deleted", () => { @@ -328,7 +287,7 @@ describe("resolveAccessPolicyForAgent", () => { it("does not warn when config file exists and is valid", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - writeFile({ version: 1, base: { default: "r--" } }); + writeFile({ version: 1, base: { rules: { "/**": "r--" } } }); resolveAccessPolicyForAgent("subri"); expect(warnSpy).not.toHaveBeenCalled(); warnSpy.mockRestore(); @@ -341,8 +300,8 @@ describe("resolveAccessPolicyForAgent", () => { const result = resolveAccessPolicyForAgent("subri"); expect(warnSpy).not.toHaveBeenCalled(); expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("Failing closed")); - // Broken file must fail-closed: deny-all policy, not undefined - expect(result).toEqual({ default: "---" }); + // Broken file must fail-closed: deny-all policy (empty rules = implicit "---"), not undefined + expect(result).toEqual({}); warnSpy.mockRestore(); errSpy.mockRestore(); }); @@ -351,50 +310,48 @@ describe("resolveAccessPolicyForAgent", () => { const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); writeFile({ version: 1, rules: { "/**": "r--" } }); // misplaced key — broken const result = resolveAccessPolicyForAgent("subri"); - expect(result).toEqual({ default: "---" }); + 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)["default"] = "rwx"; + (result as Record)["rules"] = { "/**": "rwx" }; } catch { // Object.freeze throws in strict mode — that's fine too. } _resetNotFoundWarnedForTest(); const result2 = resolveAccessPolicyForAgent("subri"); - expect(result2).toEqual({ default: "---" }); + expect(result2).toEqual({}); errSpy.mockRestore(); }); it("returns base when no agent block exists", () => { writeFile({ version: 1, - base: { default: "r--", deny: ["~/.ssh/**"] }, + base: { rules: { "/**": "r--", [`~/.ssh/**`]: "---" } }, }); const result = resolveAccessPolicyForAgent("subri"); - expect(result?.default).toBe("r--"); - expect(result?.deny).toContain("~/.ssh/**"); + expect(result?.rules?.["/**"]).toBe("r--"); + expect(result?.rules?.["~/.ssh/**"]).toBe("---"); }); it("merges base + named agent", () => { writeFile({ version: 1, - base: { default: "---", deny: ["~/.ssh/**"], rules: { "/**": "r--" } }, - agents: { subri: { rules: { "~/dev/**": "rwx" }, default: "r--" } }, + base: { rules: { "/**": "r--", [`~/.ssh/**`]: "---" } }, + agents: { subri: { rules: { "~/dev/**": "rwx" } } }, }); const result = resolveAccessPolicyForAgent("subri"); - // default: agent wins - expect(result?.default).toBe("r--"); - // deny: additive - expect(result?.deny).toContain("~/.ssh/**"); - // rules: merged + // rules: merged, agent rule wins on collision expect(result?.rules?.["/**"]).toBe("r--"); expect(result?.rules?.["~/dev/**"]).toBe("rwx"); + // base "---" rule preserved + expect(result?.rules?.["~/.ssh/**"]).toBe("---"); }); it("wildcard agent applies before named agent", () => { writeFile({ version: 1, - base: { default: "---" }, + base: {}, agents: { "*": { rules: { "/usr/bin/**": "r-x" } }, subri: { rules: { "~/dev/**": "rwx" } }, @@ -403,27 +360,26 @@ describe("resolveAccessPolicyForAgent", () => { const result = resolveAccessPolicyForAgent("subri"); expect(result?.rules?.["/usr/bin/**"]).toBe("r-x"); // from wildcard expect(result?.rules?.["~/dev/**"]).toBe("rwx"); // from named agent - expect(result?.default).toBe("---"); // from base }); it("wildcard applies even when no named agent block", () => { writeFile({ version: 1, - base: { default: "---" }, - agents: { "*": { deny: ["~/.ssh/**"] } }, + base: {}, + agents: { "*": { rules: { [`~/.ssh/**`]: "---" } } }, }); const result = resolveAccessPolicyForAgent("other-agent"); - expect(result?.deny).toContain("~/.ssh/**"); + expect(result?.rules?.["~/.ssh/**"]).toBe("---"); }); it("wildcard key itself is not treated as a named agent", () => { writeFile({ version: 1, - agents: { "*": { deny: ["~/.ssh/**"] } }, + agents: { "*": { rules: { [`~/.ssh/**`]: "---" } } }, }); // Requesting agentId "*" should not double-apply wildcard as named const result = resolveAccessPolicyForAgent("*"); - expect(result?.deny).toEqual(["~/.ssh/**"]); + expect(result?.rules?.["~/.ssh/**"]).toBe("---"); }); it("returns undefined when file is empty (no base, no agents)", () => { @@ -470,14 +426,14 @@ describe("resolveAccessPolicyForAgent", () => { errSpy.mockRestore(); }); - it("named agent deny extends global deny — global deny cannot be removed", () => { + it("narrowing rules from base and agent are all preserved in merged result", () => { writeFile({ version: 1, - base: { deny: ["~/.ssh/**"] }, - agents: { paranoid: { deny: ["~/.aws/**"] } }, + base: { rules: { [`~/.ssh/**`]: "---" } }, + agents: { paranoid: { rules: { [`~/.aws/**`]: "---" } } }, }); const result = resolveAccessPolicyForAgent("paranoid"); - expect(result?.deny).toContain("~/.ssh/**"); - expect(result?.deny).toContain("~/.aws/**"); + expect(result?.rules?.["~/.ssh/**"]).toBe("---"); + expect(result?.rules?.["~/.aws/**"]).toBe("---"); }); }); diff --git a/src/infra/access-policy-file.ts b/src/infra/access-policy-file.ts index 87c613ffad3..915b9880d86 100644 --- a/src/infra/access-policy-file.ts +++ b/src/infra/access-policy-file.ts @@ -15,9 +15,8 @@ export type AccessPolicyFile = { * base → agents["*"] → agents[agentId] * * Within each layer: - * - deny: additive (concat) — a base deny entry can never be removed by an override * - rules: shallow-merge, override key wins on collision - * - default: override wins if set + * - scripts: deep-merge per key; base sha256 is preserved */ agents?: Record; }; @@ -31,9 +30,8 @@ export function resolveAccessPolicyPath(): string { /** * Merge two AccessPolicyConfig layers. - * - deny: additive (cannot remove a base deny) * - rules: shallow merge, override key wins - * - default: override wins if set + * - scripts: deep-merge per key; base sha256 is preserved (cannot be removed by override) */ export function mergeAccessPolicy( base: AccessPolicyConfig | undefined, @@ -48,12 +46,11 @@ export function mergeAccessPolicy( if (!override) { return base; } - const deny = [...(base.deny ?? []), ...(override.deny ?? [])]; const rules = { ...base.rules, ...override.rules }; - // scripts: deep-merge per key — base sha256 and deny[] are preserved regardless of + // 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 and per-script deny list - // when an agent block supplies the same script key, defeating the security intent. + // 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 = { ...base.scripts }; for (const [key, overrideEntry] of Object.entries(override.scripts ?? {})) { const baseEntry = base.scripts?.[key]; @@ -61,7 +58,6 @@ export function mergeAccessPolicy( mergedScripts[key] = overrideEntry; continue; } - const entryDeny = [...(baseEntry.deny ?? []), ...(overrideEntry.deny ?? [])]; mergedScripts[key] = { // sha256: base always wins — cannot be removed or replaced by an agent override. ...(baseEntry.sha256 !== undefined ? { sha256: baseEntry.sha256 } : {}), @@ -69,26 +65,16 @@ export function mergeAccessPolicy( ...(Object.keys({ ...baseEntry.rules, ...overrideEntry.rules }).length > 0 ? { rules: { ...baseEntry.rules, ...overrideEntry.rules } } : {}), - // deny: additive — base per-script deny cannot be removed. - ...(entryDeny.length > 0 ? { deny: entryDeny } : {}), }; } const scripts = Object.keys(mergedScripts).length > 0 ? mergedScripts : undefined; const result: AccessPolicyConfig = {}; - if (deny.length > 0) { - result.deny = deny; - } if (Object.keys(rules).length > 0) { result.rules = rules; } if (scripts) { result.scripts = scripts; } - if (override.default !== undefined) { - result.default = override.default; - } else if (base.default !== undefined) { - result.default = base.default; - } return result; } @@ -119,8 +105,8 @@ function validateAccessPolicyFileStructure(filePath: string, parsed: unknown): s } // Catch common mistake: AccessPolicyConfig fields accidentally at top level - // (e.g. user puts "rules" or "deny" directly instead of under "base"). - for (const key of ["rules", "deny", "default", "scripts"] as const) { + // (e.g. user puts "rules" or "scripts" directly instead of under "base"). + for (const key of ["rules", "scripts"] as const) { if (p[key] !== undefined) { errors.push( `${filePath}: unexpected top-level key "${key}" — did you mean to put it under "base"?`, @@ -249,8 +235,9 @@ export function _resetNotFoundWarnedForTest(): void { * 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). */ -const DENY_ALL_POLICY: AccessPolicyConfig = Object.freeze({ default: "---" }); +/** 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(); diff --git a/src/infra/access-policy.test.ts b/src/infra/access-policy.test.ts index f92f0c63b3d..20d0a55643f 100644 --- a/src/infra/access-policy.test.ts +++ b/src/infra/access-policy.test.ts @@ -34,9 +34,7 @@ describe("validateAccessPolicyConfig", () => { it("returns no errors for a valid config", () => { expect( validateAccessPolicyConfig({ - rules: { "/**": "r--", [`${HOME}/**`]: "rwx" }, - deny: [`${HOME}/.ssh/**`], - default: "---", + rules: { "/**": "r--", [`${HOME}/**`]: "rwx", [`${HOME}/.ssh/**`]: "---" }, }), ).toEqual([]); }); @@ -45,24 +43,6 @@ describe("validateAccessPolicyConfig", () => { expect(validateAccessPolicyConfig({})).toEqual([]); }); - it("rejects invalid default perm string — too short", () => { - const errs = validateAccessPolicyConfig({ default: "rw" }); - expect(errs).toHaveLength(1); - expect(errs[0]).toMatch(/default/); - }); - - it("rejects invalid default perm string — too long", () => { - const errs = validateAccessPolicyConfig({ default: "rwxr" }); - expect(errs).toHaveLength(1); - expect(errs[0]).toMatch(/default/); - }); - - it("rejects invalid default perm string — wrong chars", () => { - const errs = validateAccessPolicyConfig({ default: "rq-" }); - expect(errs).toHaveLength(1); - expect(errs[0]).toMatch(/default/); - }); - it("rejects invalid rule perm value", () => { const errs = validateAccessPolicyConfig({ rules: { "/**": "rx" } }); expect(errs).toHaveLength(1); @@ -75,90 +55,17 @@ describe("validateAccessPolicyConfig", () => { expect(errs[0]).toMatch(/rules/); }); - it("reports multiple errors when both default and a rule are invalid", () => { - const errs = validateAccessPolicyConfig({ - default: "bad", - rules: { "/**": "xyz" }, - }); - expect(errs.length).toBeGreaterThanOrEqual(2); + it("reports an error when a rule perm value is invalid", () => { + const errs = validateAccessPolicyConfig({ rules: { "/**": "xyz" } }); + expect(errs.length).toBeGreaterThanOrEqual(1); }); - it("rejects empty deny entry", () => { - const errs = validateAccessPolicyConfig({ deny: [""] }); - expect(errs).toHaveLength(1); - expect(errs[0]).toMatch(/deny/); - }); - - it("auto-expands a bare directory path in deny[] to /**", () => { - const dir = os.tmpdir(); - const config: AccessPolicyConfig = { deny: [dir] }; - const errs = validateAccessPolicyConfig(config); - expect(errs).toHaveLength(1); - expect(errs[0]).toMatch(/auto-expanded/); - expect(config.deny?.[0]).toBe(`${dir}/**`); - }); - - it("auto-expands bare deny[] entry even when the directory does not yet exist", () => { - // Non-existent paths are treated as future directories and expanded to /**. - const nonExistent = path.join(os.tmpdir(), "openclaw-test-nonexistent-" + Date.now()); - const config: AccessPolicyConfig = { deny: [nonExistent] }; - const errs = validateAccessPolicyConfig(config); - expect(config.deny?.[0]).toBe(`${nonExistent}/**`); - expect(errs[0]).toMatch(/auto-expanded/); - }); - - it("auto-expands non-existent versioned directory names (v1.0, app-2.3) in deny[]", () => { - // Versioned names like "v1.0" or "pkg-1.0.0" look like files via naive dot-detection - // but are almost always directories. The tightened heuristic requires the extension - // to contain at least one letter — digits-only extensions (like ".0") are treated as - // directory-like and expanded to /**. - const base = os.tmpdir(); - const versionedDir = path.join(base, `openclaw-test-v1.0-${Date.now()}`); - const config: AccessPolicyConfig = { deny: [versionedDir] }; - validateAccessPolicyConfig(config); - expect(config.deny?.[0]).toBe(`${versionedDir}/**`); // must be expanded - }); - - it("does NOT auto-expand a non-existent deny[] path that looks like a file (has extension)", () => { - // "~/future-secrets.key" doesn't exist yet but the extension heuristic should - // prevent expansion to "~/future-secrets.key/**" — the user intends to protect - // the file itself, not a subtree of non-existent children. - const fileLikePath = path.join(os.tmpdir(), `openclaw-test-${Date.now()}.key`); - const config: AccessPolicyConfig = { deny: [fileLikePath] }; - validateAccessPolicyConfig(config); - expect(config.deny?.[0]).toBe(fileLikePath); // must NOT be expanded to /** - }); - - it("auto-expands non-existent bare dotnames (.ssh, .aws, .env) to /** — treats as future directory", () => { - // Bare dotnames without a secondary extension (.ssh, .aws, .env, .gnupg) cannot be - // reliably identified as file vs. directory before they exist. The safe choice is to - // expand to /** so the subtree is protected if a directory is created there. - // When the path later exists as a file, statSync confirms it and the bare pattern is kept. - const base = os.tmpdir(); - for (const name of [".ssh", ".aws", ".env", ".netrc", ".gnupg", ".config"]) { - _resetAutoExpandedWarnedForTest(); - const p = path.join(base, name); - const config: AccessPolicyConfig = { deny: [p] }; - validateAccessPolicyConfig(config); - expect(config.deny?.[0]).toBe(`${p}/**`); // expanded to protect subtree - } - }); - - it("does NOT auto-expand a bare deny[] entry that is an existing file", () => { - // A specific file like "~/.ssh/id_rsa" must stay as an exact-match pattern. - // Expanding it to "~/.ssh/id_rsa/**" would only match non-existent children, - // leaving the file itself unprotected at the tool layer and in bwrap. - // process.execPath is the running node/bun binary — always a real file. + it("file-specific '---' rule blocks access via checkAccessPolicy", () => { + // A "---" rule on a specific file path must block reads at the tool layer. const file = process.execPath; - const config: AccessPolicyConfig = { deny: [file] }; - validateAccessPolicyConfig(config); - expect(config.deny?.[0]).toBe(file); // kept as bare path, not expanded - }); - - it("file-specific deny[] entry blocks access to the file via checkAccessPolicy", () => { - // Regression: bare file path in deny[] must block reads at the tool layer. - const file = process.execPath; - const config: AccessPolicyConfig = { default: "rwx", deny: [file] }; + const config: AccessPolicyConfig = { + rules: { "/**": "rwx", [file]: "---" }, + }; validateAccessPolicyConfig(config); // applies normalization in-place expect(checkAccessPolicy(file, "read", config)).toBe("deny"); }); @@ -177,22 +84,10 @@ describe("validateAccessPolicyConfig", () => { expect(errs.some((e) => e.includes("rwX") && e.includes("scripts"))).toBe(true); }); - it("validates scripts[].deny entries and emits diagnostics for empty patterns", () => { - const config: AccessPolicyConfig = { - scripts: { - "/usr/local/bin/deploy.sh": { - deny: ["", "~/.secrets/**"], // first entry is invalid empty string - }, - }, - }; - const errs = validateAccessPolicyConfig(config); - expect(errs.some((e) => e.includes("scripts") && e.includes("deny"))).toBe(true); - }); - - it("accepts valid 'rwx' and '---' perm strings", () => { - expect(validateAccessPolicyConfig({ default: "rwx" })).toEqual([]); - expect(validateAccessPolicyConfig({ default: "---" })).toEqual([]); - expect(validateAccessPolicyConfig({ default: "r-x" })).toEqual([]); + it("accepts valid rule perm strings", () => { + expect(validateAccessPolicyConfig({ rules: { "/**": "rwx" } })).toEqual([]); + expect(validateAccessPolicyConfig({ rules: { "/**": "---" } })).toEqual([]); + expect(validateAccessPolicyConfig({ rules: { "/**": "r-x" } })).toEqual([]); }); it("auto-expands a bare path that points to a real directory", () => { @@ -283,22 +178,11 @@ describe("validateAccessPolicyConfig", () => { expect(second.filter((e) => e.includes("mid-path wildcard"))).toHaveLength(0); }); - it("emits a one-time diagnostic for mid-path wildcard deny[] entries", () => { - _resetMidPathWildcardWarnedForTest(); - const errs = validateAccessPolicyConfig({ - deny: ["/tmp/*/sensitive/**"], - }); - expect(errs).toHaveLength(1); - expect(errs[0]).toMatch(/mid-path wildcard/); - expect(errs[0]).toMatch(/OS-level.*enforcement/); - }); - it("does NOT emit mid-path wildcard diagnostic for final-segment wildcards", () => { _resetMidPathWildcardWarnedForTest(); // "/home/user/**" — wildcard is in the final segment, no path separator follows. const errs = validateAccessPolicyConfig({ - rules: { "/home/user/**": "r--", "~/**": "rwx" }, - deny: ["/tmp/**"], + rules: { "/home/user/**": "r--", "~/**": "rwx", "/tmp/**": "---" }, }); expect(errs.filter((e) => e.includes("mid-path wildcard"))).toHaveLength(0); }); @@ -311,17 +195,17 @@ describe("validateAccessPolicyConfig", () => { describe("checkAccessPolicy — malformed permission characters fail closed", () => { it("treats a typo like 'r1-' as deny for write (only exact 'w' grants write)", () => { // "r1-": index 1 is "1", not "w" — must deny write, not allow it. - const config = { rules: { "/tmp/**": "r1-" as unknown as "r--" }, default: "---" }; + const config = { rules: { "/tmp/**": "r1-" as unknown as "r--" } }; expect(checkAccessPolicy("/tmp/foo.txt", "write", config)).toBe("deny"); }); it("treats 'R--' (uppercase) as deny for read (only lowercase 'r' grants read)", () => { - const config = { rules: { "/tmp/**": "R--" as unknown as "r--" }, default: "---" }; + const config = { rules: { "/tmp/**": "R--" as unknown as "r--" } }; expect(checkAccessPolicy("/tmp/foo.txt", "read", config)).toBe("deny"); }); it("treats 'rWx' (uppercase W) as deny for write", () => { - const config = { rules: { "/tmp/**": "rWx" as unknown as "rwx" }, default: "---" }; + const config = { rules: { "/tmp/**": "rWx" as unknown as "rwx" } }; expect(checkAccessPolicy("/tmp/foo.txt", "write", config)).toBe("deny"); }); }); @@ -332,29 +216,28 @@ describe("checkAccessPolicy — malformed permission characters fail closed", () describe("checkAccessPolicy — trailing slash shorthand", () => { it('"/tmp/" is equivalent to "/tmp/**"', () => { - const config: AccessPolicyConfig = { rules: { "/tmp/": "rwx" }, default: "---" }; + const config: AccessPolicyConfig = { rules: { "/tmp/": "rwx" } }; expect(checkAccessPolicy("/tmp/foo.txt", "write", config)).toBe("allow"); expect(checkAccessPolicy("/tmp/a/b/c", "write", config)).toBe("allow"); }); it('"~/" is equivalent to "~/**"', () => { - const config: AccessPolicyConfig = { rules: { "~/": "rw-" }, default: "---" }; + const config: AccessPolicyConfig = { rules: { "~/": "rw-" } }; expect(checkAccessPolicy(`${HOME}/foo.txt`, "read", config)).toBe("allow"); expect(checkAccessPolicy(`${HOME}/foo.txt`, "write", config)).toBe("allow"); expect(checkAccessPolicy(`${HOME}/foo.txt`, "exec", config)).toBe("deny"); }); - it("trailing slash in deny list blocks subtree", () => { + it('"---" rule with trailing slash blocks subtree', () => { const config: AccessPolicyConfig = { - rules: { "/**": "rwx" }, - deny: [`${HOME}/.ssh/`], + rules: { "/**": "rwx", [`${HOME}/.ssh/`]: "---" }, }; expect(checkAccessPolicy(`${HOME}/.ssh/id_rsa`, "read", config)).toBe("deny"); }); it("trailing slash and /** produce identical results", () => { - const withSlash: AccessPolicyConfig = { rules: { "/tmp/": "rwx" }, default: "---" }; - const withGlob: AccessPolicyConfig = { rules: { "/tmp/**": "rwx" }, default: "---" }; + const withSlash: AccessPolicyConfig = { rules: { "/tmp/": "rwx" } }; + const withGlob: AccessPolicyConfig = { rules: { "/tmp/**": "rwx" } }; const paths = ["/tmp/a", "/tmp/a/b", "/tmp/a/b/c.txt"]; for (const p of paths) { expect(checkAccessPolicy(p, "write", withSlash)).toBe( @@ -368,7 +251,6 @@ describe("checkAccessPolicy — trailing slash shorthand", () => { // path ~/.openclaw/heartbeat (no trailing component), not just its contents. const config: AccessPolicyConfig = { rules: { "/**": "r--", [`${HOME}/.openclaw/heartbeat/`]: "rw-" }, - default: "---", }; expect(checkAccessPolicy(`${HOME}/.openclaw/heartbeat`, "write", config)).toBe("allow"); expect(checkAccessPolicy(`${HOME}/.openclaw/heartbeat/test.txt`, "write", config)).toBe( @@ -376,10 +258,9 @@ describe("checkAccessPolicy — trailing slash shorthand", () => { ); }); - it("trailing slash in deny list blocks the directory itself", () => { + it('"---" trailing-slash rule blocks the directory itself and its contents', () => { const config: AccessPolicyConfig = { - rules: { "/**": "rwx" }, - deny: [`${HOME}/.ssh/`], + rules: { "/**": "rwx", [`${HOME}/.ssh/`]: "---" }, }; // Both the directory and its contents should be denied. expect(checkAccessPolicy(`${HOME}/.ssh`, "read", config)).toBe("deny"); @@ -400,7 +281,6 @@ describe.skipIf(process.platform !== "darwin")( "/var/**": "r--", "/etc/**": "r--", }, - default: "---", }; it("/private/tmp path is treated as /tmp — write allowed", () => { @@ -419,19 +299,17 @@ describe.skipIf(process.platform !== "darwin")( expect(checkAccessPolicy("/tmp/foo.txt", "write", config)).toBe("allow"); }); - it("deny list entry /tmp/** also blocks /private/tmp/**", () => { + it('"---" rule for /tmp/** also blocks /private/tmp/**', () => { const denyConfig: AccessPolicyConfig = { - deny: ["/tmp/**"], - rules: { "/**": "rwx" }, + rules: { "/**": "rwx", "/tmp/**": "---" }, }; expect(checkAccessPolicy("/private/tmp/evil.sh", "exec", denyConfig)).toBe("deny"); }); - it("/private/tmp/** pattern in deny list blocks /tmp/** target", () => { - // Pattern written with /private/tmp must still match the normalized /tmp target. + it("/private/tmp/** deny rule blocks /tmp/** target", () => { + // Rule written with /private/tmp must still match the normalized /tmp target. const denyConfig: AccessPolicyConfig = { - deny: ["/private/tmp/**"], - rules: { "/**": "rwx" }, + rules: { "/**": "rwx", "/private/tmp/**": "---" }, }; expect(checkAccessPolicy("/tmp/evil.sh", "read", denyConfig)).toBe("deny"); }); @@ -439,7 +317,6 @@ describe.skipIf(process.platform !== "darwin")( it("/private/tmp/** rule matches /tmp/** target", () => { // Rule written with /private/* prefix must match a /tmp/* target path. const cfg: AccessPolicyConfig = { - default: "---", rules: { "/private/tmp/**": "rwx" }, }; expect(checkAccessPolicy("/tmp/foo.txt", "write", cfg)).toBe("allow"); @@ -500,15 +377,13 @@ describe("findBestRule", () => { }); // --------------------------------------------------------------------------- -// checkAccessPolicy — deny list +// checkAccessPolicy — "---" rules act as deny // --------------------------------------------------------------------------- -describe("checkAccessPolicy — deny list", () => { - it("deny always blocks, even when a rule would allow", () => { +describe('checkAccessPolicy — "---" rules act as deny', () => { + it('"---" rule blocks all ops, even when a broader rule would allow', () => { const config: AccessPolicyConfig = { - rules: { "/**": "rwx" }, - deny: [`${HOME}/.ssh/**`], - default: "rwx", + rules: { "/**": "rwx", [`${HOME}/.ssh/**`]: "---" }, }; expect(checkAccessPolicy(`${HOME}/.ssh/id_rsa`, "read", config)).toBe("deny"); expect(checkAccessPolicy(`${HOME}/.ssh/id_rsa`, "write", config)).toBe("deny"); @@ -516,20 +391,18 @@ describe("checkAccessPolicy — deny list", () => { }); it.skipIf(process.platform === "win32")( - "deny does not affect paths outside the deny glob", + '"---" rule does not affect paths outside its glob', () => { const config: AccessPolicyConfig = { - rules: { "/**": "rwx" }, - deny: [`${HOME}/.ssh/**`], + rules: { "/**": "rwx", [`${HOME}/.ssh/**`]: "---" }, }; expect(checkAccessPolicy(`${HOME}/workspace/foo.py`, "read", config)).toBe("allow"); }, ); - it("multiple deny entries — first match blocks", () => { + it("multiple narrowing rules block distinct subtrees", () => { const config: AccessPolicyConfig = { - rules: { "/**": "rwx" }, - deny: [`${HOME}/.ssh/**`, `${HOME}/.gnupg/**`], + rules: { "/**": "rwx", [`${HOME}/.ssh/**`]: "---", [`${HOME}/.gnupg/**`]: "---" }, }; expect(checkAccessPolicy(`${HOME}/.gnupg/secring.gpg`, "read", config)).toBe("deny"); }); @@ -599,30 +472,30 @@ describe("checkAccessPolicy — rules", () => { }); // --------------------------------------------------------------------------- -// checkAccessPolicy — default +// checkAccessPolicy — implicit fallback to "---" // --------------------------------------------------------------------------- -describe("checkAccessPolicy — default", () => { - it("uses default when no rule matches", () => { +describe("checkAccessPolicy — implicit fallback to ---", () => { + it("denies all ops when no rule matches (implicit --- fallback)", () => { const config: AccessPolicyConfig = { rules: { [`${HOME}/**`]: "rwx" }, - default: "r--", + }; + expect(checkAccessPolicy("/etc/passwd", "read", config)).toBe("deny"); + expect(checkAccessPolicy("/etc/passwd", "write", config)).toBe("deny"); + expect(checkAccessPolicy("/etc/passwd", "exec", config)).toBe("deny"); + }); + + it('"/**" rule acts as catch-all for unmatched paths', () => { + const config: AccessPolicyConfig = { + rules: { [`${HOME}/**`]: "rwx", "/**": "r--" }, }; expect(checkAccessPolicy("/etc/passwd", "read", config)).toBe("allow"); expect(checkAccessPolicy("/etc/passwd", "write", config)).toBe("deny"); }); - it("absent default is treated as --- (deny all)", () => { - const config: AccessPolicyConfig = { - rules: { [`${HOME}/**`]: "rwx" }, - }; - expect(checkAccessPolicy("/etc/passwd", "read", config)).toBe("deny"); - }); - - it("default --- denies all ops on unmatched paths", () => { + it("empty rules deny everything via implicit fallback", () => { const config: AccessPolicyConfig = { rules: { [`${HOME}/workspace/**`]: "rw-" }, - default: "---", }; expect(checkAccessPolicy("/tmp/foo", "read", config)).toBe("deny"); expect(checkAccessPolicy("/tmp/foo", "write", config)).toBe("deny"); @@ -635,27 +508,26 @@ describe("checkAccessPolicy — default", () => { // --------------------------------------------------------------------------- describe("checkAccessPolicy — precedence integration", () => { - it("deny beats rules beats default — all three in play", () => { + it("narrowing rule beats broader allow — all in play", () => { const config: AccessPolicyConfig = { rules: { "/**": "r--", [`${HOME}/**`]: "rwx", + [`${HOME}/.ssh/**`]: "---", }, - deny: [`${HOME}/.ssh/**`], - default: "---", }; - // Rule allows home paths + // Broader home rule allows writes expect(checkAccessPolicy(`${HOME}/workspace/foo`, "write", config)).toBe("allow"); - // Deny beats the home rule + // Narrowing "---" beats the home rwx rule expect(checkAccessPolicy(`${HOME}/.ssh/id_rsa`, "read", config)).toBe("deny"); - // Outer rule applies outside home + // Outer "/**" rule applies outside home expect(checkAccessPolicy("/etc/hosts", "read", config)).toBe("allow"); expect(checkAccessPolicy("/etc/hosts", "write", config)).toBe("deny"); - // Nothing matches /proc → default --- - expect(checkAccessPolicy("/proc/self/mem", "read", config)).toBe("allow"); // matches /** + // "/proc/self/mem" matches "/**" (r--) + expect(checkAccessPolicy("/proc/self/mem", "read", config)).toBe("allow"); }); - it("empty config denies everything (no rules, no default)", () => { + it("empty config denies everything (implicit --- fallback)", () => { const config: AccessPolicyConfig = {}; expect(checkAccessPolicy("/anything", "read", config)).toBe("deny"); expect(checkAccessPolicy("/anything", "write", config)).toBe("deny"); @@ -672,13 +544,11 @@ describe("checkAccessPolicy — precedence integration", () => { // --------------------------------------------------------------------------- describe("checkAccessPolicy — symlink resolved-path scenarios", () => { - it("denies read on resolved symlink target that falls under deny list", () => { - // ~/workspace/link → ~/.ssh/id_rsa (symlink in allowed dir to denied file) - // Caller passes the resolved path; deny wins. + it('denies read on resolved symlink target covered by "---" rule', () => { + // ~/workspace/link → ~/.ssh/id_rsa (symlink in allowed dir to denied-subpath) + // Caller passes the resolved path; the "---" rule wins. const config: AccessPolicyConfig = { - rules: { [`${HOME}/workspace/**`]: "rw-" }, - deny: [`${HOME}/.ssh/**`], - default: "---", + rules: { [`${HOME}/workspace/**`]: "rw-", [`${HOME}/.ssh/**`]: "---" }, }; expect(checkAccessPolicy(`${HOME}/.ssh/id_rsa`, "read", config, HOME)).toBe("deny"); }); @@ -691,7 +561,6 @@ describe("checkAccessPolicy — symlink resolved-path scenarios", () => { [`${HOME}/workspace/**`]: "rw-", [`${HOME}/workspace/secret/**`]: "r--", }, - default: "---", }; expect(checkAccessPolicy(`${HOME}/workspace/secret/file.txt`, "write", config, HOME)).toBe( "deny", @@ -702,13 +571,10 @@ describe("checkAccessPolicy — symlink resolved-path scenarios", () => { ); }); - it("symlink source path in allowed dir would be allowed; resolved denied target is denied", () => { + it("symlink source path in allowed dir is allowed; resolved denied target is denied", () => { // This illustrates that the policy must be checked on the resolved path. - // The symlink path itself looks allowed; the real target does not. const config: AccessPolicyConfig = { - rules: { [`${HOME}/workspace/**`]: "rw-" }, - deny: [`${HOME}/.aws/**`], - default: "---", + rules: { [`${HOME}/workspace/**`]: "rw-", [`${HOME}/.aws/**`]: "---" }, }; // Source path (the symlink) — allowed expect(checkAccessPolicy(`${HOME}/workspace/creds`, "read", config, HOME)).toBe("allow"); @@ -980,7 +846,7 @@ describe("resolveScriptKey", () => { describe("applyScriptPolicyOverride", () => { it("returns base policy unchanged when no scripts block", () => { - const base: AccessPolicyConfig = { rules: { "/**": "r--" }, default: "---" }; + const base: AccessPolicyConfig = { rules: { "/**": "r--" } }; const { policy, hashMismatch } = applyScriptPolicyOverride(base, "/any/path"); expect(hashMismatch).toBeUndefined(); expect(policy).toBe(base); @@ -1021,10 +887,9 @@ describe("applyScriptPolicyOverride", () => { } }); - it("returns override rules separately so seatbelt emits them after deny", () => { + it("returns override rules separately so seatbelt emits them after base rules", () => { const base: AccessPolicyConfig = { rules: { "/**": "r--" }, - default: "---", scripts: { "/my/script.sh": { rules: { [`${HOME}/.openclaw/credentials/`]: "r--" } } }, }; const { policy, overrideRules, hashMismatch } = applyScriptPolicyOverride( @@ -1035,23 +900,11 @@ describe("applyScriptPolicyOverride", () => { // Base rules unchanged in policy expect(policy.rules?.["/**"]).toBe("r--"); expect(policy.rules?.[`${HOME}/.openclaw/credentials/`]).toBeUndefined(); - // Override rules returned separately — caller emits them after deny in seatbelt profile + // Override rules returned separately — caller emits them last in seatbelt profile expect(overrideRules?.[`${HOME}/.openclaw/credentials/`]).toBe("r--"); expect(policy.scripts).toBeUndefined(); }); - it("appends deny additively", () => { - const base: AccessPolicyConfig = { - deny: [`${HOME}/.ssh/**`], - scripts: { - "/my/script.sh": { deny: ["/tmp/**"] }, - }, - }; - const { policy } = applyScriptPolicyOverride(base, "/my/script.sh"); - expect(policy.deny).toContain(`${HOME}/.ssh/**`); - expect(policy.deny).toContain("/tmp/**"); - }); - it("override rules returned separately — base policy rule unchanged", () => { const base: AccessPolicyConfig = { rules: { [`${HOME}/workspace/**`]: "r--" }, @@ -1099,7 +952,7 @@ describe("applyScriptPolicyOverride", () => { // A direct object lookup misses tilde keys; ~ must be expanded before comparing. const absPath = path.join(os.homedir(), "bin", "deploy.sh"); const base: AccessPolicyConfig = { - default: "rwx", + rules: { "/**": "rwx" }, scripts: { "~/bin/deploy.sh": { rules: { "/secret/**": "---" } } }, }; const { overrideRules, hashMismatch } = applyScriptPolicyOverride(base, absPath); diff --git a/src/infra/access-policy.ts b/src/infra/access-policy.ts index 9081f96cac3..e6fc9adf25e 100644 --- a/src/infra/access-policy.ts +++ b/src/infra/access-policy.ts @@ -43,17 +43,11 @@ function hasMidPathWildcard(pattern: string): boolean { /** * Validates and normalizes an AccessPolicyConfig for well-formedness. * Returns an array of human-readable diagnostic strings; empty = valid. - * May mutate config.rules and config.deny in place (e.g. auto-expanding bare directory paths). + * May mutate config.rules in place (e.g. auto-expanding bare directory paths). */ export function validateAccessPolicyConfig(config: AccessPolicyConfig): string[] { const errors: string[] = []; - if (config.default !== undefined && !PERM_STR_RE.test(config.default)) { - errors.push( - `access-policy.default "${config.default}" is invalid: must be exactly 3 chars (e.g. "rwx", "r--", "---")`, - ); - } - if (config.rules) { for (const [pattern, perm] of Object.entries(config.rules)) { if (!pattern) { @@ -114,83 +108,6 @@ export function validateAccessPolicyConfig(config: AccessPolicyConfig): string[] } } } - if (entry.deny) { - for (let i = 0; i < entry.deny.length; i++) { - if (!entry.deny[i]) { - errors.push( - `access-policy.scripts["${scriptPath}"].deny[${i}] must be a non-empty glob pattern`, - ); - } - } - } - } - } - - if (config.deny) { - for (let i = 0; i < config.deny.length; i++) { - const pattern = config.deny[i]; - if (!pattern) { - errors.push(`access-policy.deny[${i}] must be a non-empty glob pattern`); - continue; - } - if (hasMidPathWildcard(pattern) && !_midPathWildcardWarned.has(`deny:${pattern}`)) { - _midPathWildcardWarned.add(`deny:${pattern}`); - errors.push( - `access-policy.deny entry "${pattern}" contains a mid-path wildcard — OS-level (bwrap/Seatbelt) enforcement cannot apply; tool-layer enforcement is still active.`, - ); - } - // Bare-path auto-expand for directories: "~/.ssh" → "~/.ssh/**" so the - // entire directory tree is denied, not just the directory inode itself. - // For paths that exist and are confirmed files (statSync), keep the bare - // pattern — expanding to "/**" would only match non-existent children, - // leaving the file itself unprotected at both the tool layer and bwrap. - // Non-existent paths are treated as future directories and always expanded - // so the subtree is protected before the directory is created. - if (!pattern.endsWith("/") && !/[*?[]/.test(pattern)) { - const expandedForStat = pattern.startsWith("~") - ? pattern.replace(/^~(?=$|[/\\])/, os.homedir()) - : pattern; - let isExistingFile = false; - // For non-existent paths: treat as a future file (skip /**-expansion) when - // the last segment looks like a filename — has a dot but is not a dotfile-only - // name (e.g. ".ssh") and has a non-empty extension (e.g. "secrets.key"). - // This preserves the intent of "deny: ['~/future-secrets.key']" where the - // user wants to protect that specific file once it is created. - // Plain names without an extension (e.g. "myfolder") are still treated as - // future directories and expanded to /**. - let looksLikeFile = false; - try { - isExistingFile = !fs.statSync(expandedForStat).isDirectory(); - } catch { - const lastName = - expandedForStat - .replace(/[/\\]$/, "") - .split(/[/\\]/) - .pop() ?? ""; - // Looks like a file when the last segment has a non-leading dot followed by a - // letter-containing extension — covers "secrets.key", "config.json". - // Note: pure dotnames like ".npmrc", ".env", ".ssh" do NOT match this regex - // (they have no non-leading dot) and are therefore expanded to /** below. - // Digit-only suffixes (v1.0, app-2.3) are treated as versioned directory names. - // Bare dotnames without a secondary extension (.ssh, .aws, .env, .gnupg) are - // NOT treated as file-like: they are expanded to /** so the subtree is protected - // when the path does not yet exist. For .env-style plain files the expansion is - // conservative but safe — once the file exists, statSync confirms it and the bare - // path is kept. The leading-dot heuristic was removed because it also matched - // common directory names (.ssh, .aws, .config) and silently skipped expansion. - looksLikeFile = /[^.]\.[a-zA-Z][^/\\]*$/.test(lastName); - } - if (!isExistingFile && !looksLikeFile) { - const fixed = `${pattern}/**`; - config.deny[i] = fixed; - if (!_autoExpandedWarned.has(`deny:${pattern}`)) { - _autoExpandedWarned.add(`deny:${pattern}`); - errors.push( - `access-policy.deny["${pattern}"] auto-expanded to "${fixed}" so it covers all directory contents.`, - ); - } - } - } } } @@ -313,9 +230,9 @@ export function findBestRule( * Checks whether a given operation on targetPath is permitted by the config. * * Precedence: - * 1. deny[] — any matching glob always blocks, no override. - * 2. rules — longest matching glob wins; check the relevant bit. - * 3. default — catch-all (treated as "---" when absent). + * 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, @@ -341,16 +258,7 @@ export function checkAccessPolicy( ); } - // 1. deny list always wins. - for (const pattern of config.deny ?? []) { - // Normalize so /private/tmp/** patterns match /tmp/** targets on macOS. - const expanded = normalizePlatformPath(expandPattern(pattern, homeDir)); - if (matchesPattern(expanded)) { - return "deny"; - } - } - - // 2. rules — longest match wins (check both path and path + "/" variants). + // 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.rules ?? {})) { @@ -365,8 +273,8 @@ export function checkAccessPolicy( return permAllows(bestPerm, op) ? "allow" : "deny"; } - // 3. default catch-all (absent = "---" = deny all). - return permAllows(config.default, op) ? "allow" : "deny"; + // Implicit fallback: "---" (deny-all) when no rule matches. + return "deny"; } /** @@ -637,16 +545,11 @@ export function applyScriptPolicyOverride( // Build the merged policy WITHOUT the override rules merged in. // Override rules are returned separately so the caller can emit them AFTER - // the deny list in the seatbelt profile (last-match-wins — grants must come - // after deny entries to override broad deny patterns like ~/.secrets/**). + // 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, - deny: [...(base.deny ?? []), ...(override.deny ?? [])], - }; - if (merged.deny?.length === 0) { - delete merged.deny; - } + const merged: AccessPolicyConfig = { ...base }; return { policy: merged, overrideRules: diff --git a/src/infra/exec-sandbox-bwrap.test.ts b/src/infra/exec-sandbox-bwrap.test.ts index d44cf2447d0..bc47b2e659f 100644 --- a/src/infra/exec-sandbox-bwrap.test.ts +++ b/src/infra/exec-sandbox-bwrap.test.ts @@ -14,14 +14,14 @@ const HOME = os.homedir(); // 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 default allows reads", () => { - const config: AccessPolicyConfig = { default: "r--" }; + it("starts with --ro-bind / / when /** rule allows reads", () => { + const config: AccessPolicyConfig = { rules: { "/**": "r--" } }; const args = generateBwrapArgs(config, HOME); expect(args.slice(0, 3)).toEqual(["--ro-bind", "/", "/"]); }); - it("does not use --ro-bind / / when default is ---", () => { - const config: AccessPolicyConfig = { default: "---" }; + 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( @@ -31,15 +31,14 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { }); it("ends with --", () => { - const config: AccessPolicyConfig = { default: "r--" }; + const config: AccessPolicyConfig = { rules: { "/**": "r--" } }; const args = generateBwrapArgs(config, HOME); expect(args[args.length - 1]).toBe("--"); }); - it("adds --tmpfs for each deny entry", () => { + it('adds --tmpfs for "---" rules in permissive mode', () => { const config: AccessPolicyConfig = { - deny: [`${HOME}/.ssh/**`, `${HOME}/.gnupg/**`], - default: "r--", + rules: { "/**": "r--", [`${HOME}/.ssh/**`]: "---", [`${HOME}/.gnupg/**`]: "---" }, }; const args = generateBwrapArgs(config, HOME); const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean); @@ -47,10 +46,9 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { expect(tmpfsMounts).toContain(`${HOME}/.gnupg`); }); - it("expands ~ in deny patterns using homeDir", () => { + it('expands ~ in "---" rules using homeDir', () => { const config: AccessPolicyConfig = { - deny: ["~/.ssh/**"], - default: "r--", + rules: { "/**": "r--", "~/.ssh/**": "---" }, }; const args = generateBwrapArgs(config, HOME); const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean); @@ -59,8 +57,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { it("adds --bind for paths with w bit in rules", () => { const config: AccessPolicyConfig = { - rules: { [`${HOME}/workspace/**`]: "rw-" }, - default: "r--", + rules: { "/**": "r--", [`${HOME}/workspace/**`]: "rw-" }, }; const args = generateBwrapArgs(config, HOME); const bindPairs: string[] = []; @@ -74,8 +71,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { it("does not add --bind for read-only rules on permissive base", () => { const config: AccessPolicyConfig = { - rules: { "/usr/bin/**": "r--" }, - default: "r--", + rules: { "/**": "r--", "/usr/bin/**": "r--" }, }; const args = generateBwrapArgs(config, HOME); // /usr/bin should NOT appear as a --bind-try (it's already ro-bound via /) @@ -88,11 +84,9 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { expect(bindPairs).not.toContain("/usr/bin"); }); - it("deny entry tmpfs appears in args regardless of rule for that path", () => { + it('"---" rule for sensitive path appears in args regardless of broader rule', () => { const config: AccessPolicyConfig = { - rules: { [`${HOME}/**`]: "rwx" }, - deny: [`${HOME}/.ssh/**`], - default: "r--", + rules: { "/**": "r--", [`${HOME}/**`]: "rwx", [`${HOME}/.ssh/**`]: "---" }, }; const args = generateBwrapArgs(config, HOME); const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean); @@ -106,25 +100,22 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { 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({ default: "r--" }, HOME); + const args = generateBwrapArgs({ rules: { "/**": "r--" } }, HOME); const procIdx = args.indexOf("--proc"); expect(procIdx).toBeGreaterThan(-1); expect(args[procIdx + 1]).toBe("/proc"); }); - it("adds --tmpfs /tmp in permissive mode", () => { - const config: AccessPolicyConfig = { default: "r--" }; + it("adds --tmpfs /tmp in permissive mode (/** allows reads)", () => { + const config: AccessPolicyConfig = { rules: { "/**": "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 even with no explicit /tmp rule", () => { - // Regression guard: the defaultAllowsRead guard on the /tmp block must prevent - // a writable tmpfs being mounted under default:"---" when no /tmp rule exists. - // explicitTmpPerm === null is true (no rule), but defaultAllowsRead is false, - // so the entire /tmp block must be skipped. - const config: AccessPolicyConfig = { default: "---" }; + 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"); @@ -133,7 +124,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { 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 = { default: "r--", rules: { "/tmp/**": "r--" } }; + const config: AccessPolicyConfig = { rules: { "/**": "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"); @@ -144,7 +135,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { // 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 = { default: "r--", rules: { "/tmp/**": "rw-" } }; + const config: AccessPolicyConfig = { rules: { "/**": "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 @@ -154,19 +145,19 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { expect(bindMounts).toContain("/tmp"); }); - it("does not add --tmpfs /tmp in restrictive mode (default: ---)", () => { - const config: AccessPolicyConfig = { default: "---" }; + 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 = { rules: { [`${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 default:"r--", --ro-bind / / makes everything readable. A narrowing + // With "/**":"r--", --ro-bind / / makes everything readable. A narrowing // rule like "/secret/**": "---" must overlay --tmpfs so the path is hidden. const config: AccessPolicyConfig = { - default: "r--", - rules: { [`${HOME}/secret/**`]: "---" }, + rules: { "/**": "r--", [`${HOME}/secret/**`]: "---" }, }; const args = generateBwrapArgs(config, HOME); const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean); @@ -179,15 +170,10 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { }); it("narrowing rule on an existing file does not emit --tmpfs (bwrap only accepts dirs)", () => { - // Reproducer: { "default": "r--", "rules": { "~/secrets.key": "---" } } - // where ~/secrets.key is an existing file. The old code emitted --tmpfs on the - // file path, causing bwrap to abort with "Not a directory". Fix: mirror the - // isDir guard already present in the deny[] branch. // process.execPath is always an existing file — use it as the test target. const filePath = process.execPath; const config: AccessPolicyConfig = { - default: "r--", - rules: { [filePath]: "---" }, + rules: { "/**": "r--", [filePath]: "---" }, }; const args = generateBwrapArgs(config, HOME); const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean); @@ -198,8 +184,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { 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 = { - default: "r--", - rules: { [`${HOME}/scripts/**`]: "--x" }, + rules: { "/**": "r--", [`${HOME}/scripts/**`]: "--x" }, }; const args = generateBwrapArgs(config, HOME); const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean); @@ -212,7 +197,6 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { // for a "---" rule would silently grant read access to paths that should // be fully blocked. const config: AccessPolicyConfig = { - default: "---", rules: { [`${HOME}/workspace/**`]: "rwx", // allowed: should produce --bind-try [`${HOME}/workspace/private/**`]: "---", // denied: must NOT produce any mount @@ -232,7 +216,6 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { 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 = { - default: "---", rules: { [`${HOME}/scripts/**`]: "--x" }, }; const args = generateBwrapArgs(config, HOME); @@ -243,12 +226,11 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { }); it('"-w-" rule in restrictive mode emits --bind-try so writes do not silently fail', () => { - // A write-only rule ("-w-") under default:"---" now emits --bind-try so the path + // 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 = { - default: "---", rules: { [`${HOME}/logs/**`]: "-w-" }, }; const args = generateBwrapArgs(config, HOME); @@ -259,12 +241,11 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { }); it('"-w-" rule in permissive mode emits --bind-try (write upgrade, reads already allowed)', () => { - // Under default:"r--", --ro-bind / / already grants reads everywhere. + // 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 = { - default: "r--", - rules: { [`${HOME}/output/**`]: "-w-" }, + rules: { "/**": "r--", [`${HOME}/output/**`]: "-w-" }, }; const args = generateBwrapArgs(config, HOME); const bindMounts = args @@ -278,9 +259,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { // The pattern must be silently ignored rather than binding /home. const fakeHome = "/home/testuser"; const config: AccessPolicyConfig = { - default: "r--", - deny: ["/home/*/.ssh/**"], - rules: { "/home/*/.config/**": "---" }, + rules: { "/**": "r--", "/home/*/.config/**": "---" }, }; const args = generateBwrapArgs(config, fakeHome); const allMountTargets = args @@ -296,8 +275,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { // "/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 = { - default: "r--", - deny: ["/var/log/secret*"], + rules: { "/**": "r--", "/var/log/secret*": "---" }, }; const args = generateBwrapArgs(config, HOME); const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean); @@ -315,7 +293,6 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { [`${HOME}/dev/secret/**`]: "r--", [`${HOME}/dev/**`]: "rw-", }, - default: "---", }; const args = generateBwrapArgs(config, HOME); const bindArgs = args.filter((a) => a === "--bind-try" || a === "--ro-bind-try"); @@ -333,12 +310,11 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { expect(bindArgs[secretIdx]).toBe("--ro-bind-try"); }); - it('script override "-w-" under restrictive default emits --bind-try, not --tmpfs', () => { - // Greptile: permAllowsWrite && (r || defaultR) condition was wrong — for -w- under --- + 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 = { - default: "---", rules: { [`${HOME}/workspace/**`]: "rwx" }, }; const overrides = { [`${HOME}/logs/**`]: "-w-" as const }; @@ -351,20 +327,16 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { expect(tmpfsMounts).not.toContain(`${HOME}/logs`); }); - it("skips --tmpfs for deny[] entry that resolves to an existing file (not a directory)", () => { - // /etc/hosts is a file on both macOS and Linux; bwrap --tmpfs rejects file paths. - // The deny entry is expanded to "/etc/hosts/**" by validateAccessPolicyConfig, and - // patternToPath strips the "/**" back to "/etc/hosts". generateBwrapArgs must not - // emit "--tmpfs /etc/hosts" — it should be silently skipped. - const config: AccessPolicyConfig = { default: "r--", deny: ["/etc/hosts/**"] }; + 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 = { rules: { "/**": "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 deny[] entry is skipped", () => { - // Use /etc/passwd (always a file) rather than /etc/hosts which is already in - // _bwrapFileDenyWarnedPaths from the generateBwrapArgs test above. + 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"); @@ -375,9 +347,11 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { } }); - it("still emits --tmpfs for deny[] entry that resolves to a directory", () => { + it('still emits --tmpfs for "---" rule that resolves to a directory', () => { // Non-existent paths are treated as directories (forward-protection). - const config: AccessPolicyConfig = { default: "r--", deny: [`${HOME}/.nonexistent-dir/**`] }; + const config: AccessPolicyConfig = { + rules: { "/**": "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`); @@ -386,8 +360,8 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { 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({ default: "---", rules: { "/tmp/": "rw-" } }, HOME); - const withGlob = generateBwrapArgs({ default: "---", rules: { "/tmp/**": "rw-" } }, HOME); + const withSlash = generateBwrapArgs({ rules: { "/tmp/": "rw-" } }, HOME); + const withGlob = generateBwrapArgs({ rules: { "/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"); @@ -397,17 +371,17 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { describe("wrapCommandWithBwrap", () => { it("starts with bwrap", () => { - const result = wrapCommandWithBwrap("ls /tmp", { default: "r--" }, HOME); + const result = wrapCommandWithBwrap("ls /tmp", { rules: { "/**": "r--" } }, HOME); expect(result).toMatch(/^bwrap /); }); it("contains -- separator before the command", () => { - const result = wrapCommandWithBwrap("ls /tmp", { default: "r--" }, HOME); + const result = wrapCommandWithBwrap("ls /tmp", { rules: { "/**": "r--" } }, HOME); expect(result).toContain("-- /bin/sh -c"); }); it("wraps command in /bin/sh -c", () => { - const result = wrapCommandWithBwrap("cat /etc/hosts", { default: "r--" }, HOME); + const result = wrapCommandWithBwrap("cat /etc/hosts", { rules: { "/**": "r--" } }, HOME); expect(result).toContain("/bin/sh -c"); expect(result).toContain("cat /etc/hosts"); }); diff --git a/src/infra/exec-sandbox-bwrap.ts b/src/infra/exec-sandbox-bwrap.ts index 36ba42db230..09a276b1223 100644 --- a/src/infra/exec-sandbox-bwrap.ts +++ b/src/infra/exec-sandbox-bwrap.ts @@ -27,11 +27,10 @@ const SYSTEM_RO_BIND_PATHS = ["/usr", "/bin", "/lib", "/lib64", "/sbin", "/etc", let bwrapAvailableCache: boolean | undefined; -// Warn once per process when a file-specific deny[] entry cannot be enforced at +// 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. -// See docs/tools/access-policy.md — "File-specific deny[] entries on Linux". const _bwrapFileDenyWarnedPaths = new Set(); /** Reset the one-time file-deny warning set. Only for use in tests. */ export function _resetBwrapFileDenyWarnedPathsForTest(): void { @@ -43,10 +42,10 @@ export function _warnBwrapFileDenyOnce(filePath: string): void { } _bwrapFileDenyWarnedPaths.add(filePath); console.error( - `[access-policy] bwrap: deny[] entry "${filePath}" resolves to a file — ` + + `[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, deny its parent directory instead.`, + `To protect this file at the OS layer on Linux, use a "---" rule on its parent directory instead.`, ); } @@ -119,25 +118,27 @@ function permAllowsWrite(perm: PermStr): boolean { * Generate bwrap argument array for the given permissions config. * * Strategy: - * 1. Start with --ro-bind / / (read-only view of entire host FS) - * 2. For each rule with w bit: upgrade to --bind (read-write) - * 3. For each deny[] entry: overlay with --tmpfs (empty, blocks reads too) - * 4. Add /tmp and /dev as writable tmpfs mounts (required for most processes) - * 5. When default is "---": use a more restrictive base (only bind explicit allow paths) + * 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 deny list so they win over - * broad deny patterns — mirrors the Seatbelt scriptOverrideRules behaviour. + * 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[] { const args: string[] = []; - const defaultPerm = config.default ?? "---"; - const defaultAllowsRead = defaultPerm[0] === "r"; + // Determine base stance from the "/**" catch-all rule (replaces the removed `default` field). + const catchAllPerm = findBestRule("/**", config.rules ?? {}, homeDir) ?? "---"; + const defaultAllowsRead = catchAllPerm[0] === "r"; if (defaultAllowsRead) { // Permissive base: everything is read-only by default. @@ -199,12 +200,12 @@ export function generateBwrapArgs( // 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 (defaultPerm[0] !== "r" && perm[0] === "r") { + } else if (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. args.push("--ro-bind-try", p, p); - } else if (defaultPerm[0] === "r" && perm[0] !== "r") { + } else if (catchAllPerm[0] === "r" && perm[0] !== "r") { // Permissive base + narrowing rule (no read bit): overlay with tmpfs so the // path is hidden even though --ro-bind / / made it readable by default. // This mirrors what deny[] does — without this, "---" rules under a permissive @@ -227,35 +228,7 @@ export function generateBwrapArgs( // Permissive base + read-only rule: already covered by --ro-bind / /; no extra mount. } - // deny[] entries: overlay with empty tmpfs — path exists but is empty. - // tmpfs overlay hides the real contents regardless of how the path was expressed. - // Guard: bwrap --tmpfs only accepts a directory as the mount point. deny[] entries - // like "~/.ssh/id_rsa" are unconditionally expanded to "~/.ssh/id_rsa/**" by - // validateAccessPolicyConfig and resolve back to the file path here. Passing a - // file to --tmpfs causes bwrap to error out ("Not a directory"). Non-existent - // paths are assumed to be directories (the common case for protecting future dirs). - for (const pattern of config.deny ?? []) { - const p = patternToPath(pattern, homeDir); - if (!p || p === "/") { - continue; - } - let isDir = true; - try { - isDir = fs.statSync(p).isDirectory(); - } catch { - // Non-existent path — assume directory (forward-protection for dirs not yet created). - } - if (isDir) { - args.push("--tmpfs", p); - } else { - // File-specific entry: tool-layer checkAccessPolicy still denies read/write/edit - // tool calls, but exec commands inside the sandbox can still access the file - // directly. Warn operators so they know to deny the parent directory instead. - _warnBwrapFileDenyOnce(p); - } - } - - // Script-specific override mounts — emitted after deny[] so they can reopen + // 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]) => { diff --git a/src/infra/exec-sandbox-seatbelt.test.ts b/src/infra/exec-sandbox-seatbelt.test.ts index b66d64db877..4b7a2296529 100644 --- a/src/infra/exec-sandbox-seatbelt.test.ts +++ b/src/infra/exec-sandbox-seatbelt.test.ts @@ -16,54 +16,48 @@ describe("generateSeatbeltProfile", () => { expect(profile).toMatch(/^\(version 1\)/); }); - it("uses (deny default) when default is ---", () => { - const profile = generateSeatbeltProfile({ default: "---" }, HOME); + 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 default has any permission", () => { - const profile = generateSeatbeltProfile({ default: "r--" }, HOME); + it("uses (allow default) when /** rule has any permission", () => { + const profile = generateSeatbeltProfile({ rules: { "/**": "r--" } }, HOME); expect(profile).toContain("(allow default)"); expect(profile).not.toContain("(deny default)"); }); - it("includes system baseline reads when default is ---", () => { - const profile = generateSeatbeltProfile({ default: "---" }, HOME); + 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("deny list entries appear as deny file-read*, file-write*, process-exec*", () => { + skipOnWindows("--- rule emits deny file-read*, file-write*, process-exec* for that path", () => { const config: AccessPolicyConfig = { - deny: [`${HOME}/.ssh/**`], - default: "rwx", + rules: { "/**": "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*`); - // Should contain the path expect(profile).toContain(HOME + "/.ssh"); }); - skipOnWindows("expands ~ in deny patterns using provided homeDir", () => { + skipOnWindows("expands ~ in --- rules using provided homeDir", () => { const config: AccessPolicyConfig = { - deny: ["~/.ssh/**"], - default: "rwx", + rules: { "/**": "rwx", "~/.ssh/**": "---" }, }; const profile = generateSeatbeltProfile(config, HOME); expect(profile).toContain(HOME + "/.ssh"); - // Should NOT contain literal ~ - const denySection = profile.split("; Deny list")[1] ?? ""; - expect(denySection).not.toContain("~/.ssh"); + expect(profile).not.toContain("~/.ssh"); }); skipOnWindows("expands ~ in rules using provided homeDir", () => { const config: AccessPolicyConfig = { rules: { "~/**": "rw-" }, - default: "---", }; const profile = generateSeatbeltProfile(config, HOME); expect(profile).toContain(HOME); @@ -72,7 +66,6 @@ describe("generateSeatbeltProfile", () => { it("rw- rule emits allow read+write, deny exec for that path", () => { const config: AccessPolicyConfig = { rules: { [`${HOME}/workspace/**`]: "rw-" }, - default: "---", }; const profile = generateSeatbeltProfile(config, HOME); expect(profile).toContain(`(allow file-read*`); @@ -83,7 +76,6 @@ describe("generateSeatbeltProfile", () => { it("r-x rule emits allow read+exec, deny write for that path", () => { const config: AccessPolicyConfig = { rules: { "/usr/bin/**": "r-x" }, - default: "---", }; const profile = generateSeatbeltProfile(config, HOME); const rulesSection = profile.split("; User-defined path rules")[1] ?? ""; @@ -92,17 +84,19 @@ describe("generateSeatbeltProfile", () => { expect(rulesSection).toContain("(deny file-write*"); }); - it("deny list section appears after rules section", () => { + 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 = { - rules: { [`${HOME}/**`]: "rwx" }, - deny: [`${HOME}/.ssh/**`], - default: "r--", + rules: { [`${HOME}/**`]: "rwx", [`${HOME}/.ssh/**`]: "---" }, }; const profile = generateSeatbeltProfile(config, HOME); const rulesIdx = profile.indexOf("; User-defined path rules"); - const denyIdx = profile.indexOf("; Deny list"); expect(rulesIdx).toBeGreaterThan(-1); - expect(denyIdx).toBeGreaterThan(rulesIdx); + // 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", () => { @@ -110,18 +104,17 @@ describe("generateSeatbeltProfile", () => { }); it("permissive base with no exec bit includes system baseline exec paths", () => { - // default:"r--" emits (deny process-exec* (subpath "/")) but must also allow + // "/**": "r--" emits (deny process-exec* (subpath "/")) but must also allow // system binaries — otherwise ls, grep, cat all fail inside the sandbox. - const profile = generateSeatbeltProfile({ default: "r--" }, HOME); + const profile = generateSeatbeltProfile({ rules: { "/**": "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", () => { - // default:"rwx" already allows everything including exec — no extra baseline needed. - const profile = generateSeatbeltProfile({ default: "rwx" }, HOME); - // (allow default) covers exec; no separate baseline exec section needed + // "/**": "rwx" already allows everything including exec — no extra baseline needed. + const profile = generateSeatbeltProfile({ rules: { "/**": "rwx" } }, HOME); expect(profile).toContain("(allow default)"); expect(profile).not.toContain("System baseline exec"); }); @@ -131,7 +124,6 @@ describe("generateSeatbeltProfile", () => { // Without deny ops in the override block, write would still be allowed. const config: AccessPolicyConfig = { rules: { [`${HOME}/workspace/**`]: "rw-" }, - default: "---", }; const overrideRules: Record = { [`${HOME}/workspace/locked/**`]: "r--" }; const profile = generateSeatbeltProfile(config, HOME, overrideRules); @@ -141,70 +133,37 @@ describe("generateSeatbeltProfile", () => { expect(overrideSection).toContain(`${HOME}/workspace/locked`); }); - it("omits /private/tmp baseline when default is --- and no rule grants /tmp", () => { - // In restrictive mode without an explicit /tmp rule, /tmp should NOT be in - // the baseline — emitting it unconditionally would contradict default: "---". - const config: AccessPolicyConfig = { default: "---" }; - const profile = generateSeatbeltProfile(config, HOME); + 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 config: AccessPolicyConfig = { - default: "---", - rules: { "/tmp/**": "rw-" }, - }; - const profile = generateSeatbeltProfile(config, HOME); + const profile = generateSeatbeltProfile({ rules: { "/tmp/**": "rw-" } }, HOME); expect(profile).toContain(`(subpath "/private/tmp")`); }); it("read-only /tmp rule does not grant file-write* on /private/tmp", () => { - // A policy of "/tmp/**": "r--" must grant reads but NOT writes to /tmp. - // The old code used (r || w) as the gate for both ops, so r-- inadvertently - // granted file-write* alongside read ops. - const config: AccessPolicyConfig = { - default: "---", - rules: { "/tmp/**": "r--" }, - }; - const profile = generateSeatbeltProfile(config, HOME); - // Read ops must be allowed for /tmp. + const profile = generateSeatbeltProfile({ rules: { "/tmp/**": "r--" } }, HOME); expect(profile).toMatch(/allow file-read[^)]*\(subpath "\/private\/tmp"\)/); - // Write must NOT be present for /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 config: AccessPolicyConfig = { - default: "---", - rules: { "/tmp/**": "-w-" }, - }; - const profile = generateSeatbeltProfile(config, HOME); + const profile = generateSeatbeltProfile({ rules: { "/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 in restrictive mode", () => { - // Regression: when default:"---" and a rule grants exec on /tmp (e.g. "--x"), - // the seatbelt profile must emit (allow process-exec* (subpath "/private/tmp")). - // Without this fix, no exec allowance was emitted and binaries in /tmp could not - // be executed even though the policy explicitly permitted it. - const config: AccessPolicyConfig = { - default: "---", - rules: { "/tmp/**": "--x" }, - }; - const profile = generateSeatbeltProfile(config, HOME); + it("exec-only /tmp rule grants process-exec* on /private/tmp", () => { + const profile = generateSeatbeltProfile({ rules: { "/tmp/**": "--x" } }, HOME); expect(profile).toMatch(/allow process-exec\*[^)]*\(subpath "\/private\/tmp"\)/); - // Read and write must NOT be granted. 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 in restrictive mode", () => { - const config: AccessPolicyConfig = { - default: "---", - rules: { "/tmp/**": "r-x" }, - }; - const profile = generateSeatbeltProfile(config, HOME); + it("r-x /tmp rule grants both read and exec on /private/tmp", () => { + const profile = generateSeatbeltProfile({ rules: { "/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"\)/); @@ -221,14 +180,15 @@ describe("generateSeatbeltProfile", () => { // --------------------------------------------------------------------------- skipOnWindows( - "deny list for sensitive path appears after workspace allow — symlink to deny target is blocked", + "--- 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 deny entry for ~/.ssh must appear after the workspace allow so it wins. + // The --- rule for ~/.ssh must appear after the workspace allow so it wins. const config: AccessPolicyConfig = { - rules: { [`${HOME}/workspace/**`]: "rw-" }, - deny: [`${HOME}/.ssh/**`], - default: "---", + rules: { + [`${HOME}/workspace/**`]: "rw-", + [`${HOME}/.ssh/**`]: "---", + }, }; const profile = generateSeatbeltProfile(config, HOME); const workspaceAllowIdx = profile.indexOf("(allow file-read*"); @@ -243,16 +203,11 @@ describe("generateSeatbeltProfile", () => { skipOnWindows( "restrictive rule on subdir appears after broader rw rule — covers symlink to restricted subtree", () => { - // ~/workspace/** is rw-, ~/workspace/secret/** is r--. - // A symlink ~/workspace/link → ~/workspace/secret/file: seatbelt sees the - // real path ~/workspace/secret/... which must hit the narrower r-- rule. - // The deny write for secret must appear after the allow write for workspace. const config: AccessPolicyConfig = { rules: { [`${HOME}/workspace/**`]: "rw-", [`${HOME}/workspace/secret/**`]: "r--", }, - default: "---", }; const profile = generateSeatbeltProfile(config, HOME); const workspaceWriteIdx = profile.indexOf("(allow file-write*"); @@ -265,11 +220,9 @@ describe("generateSeatbeltProfile", () => { it("glob patterns are stripped to their longest concrete prefix", () => { const config: AccessPolicyConfig = { - deny: ["/Users/kaveri/.ssh/**"], - default: "rwx", + rules: { "/Users/kaveri/.ssh/**": "---", "/**": "rwx" }, }; const profile = generateSeatbeltProfile(config, "/Users/kaveri"); - // ** should not appear in profile — stripped to subpath expect(profile).not.toContain("**"); expect(profile).toContain("/Users/kaveri/.ssh"); }); diff --git a/src/infra/exec-sandbox-seatbelt.ts b/src/infra/exec-sandbox-seatbelt.ts index 01ea9db7c87..9c1aa267092 100644 --- a/src/infra/exec-sandbox-seatbelt.ts +++ b/src/infra/exec-sandbox-seatbelt.ts @@ -170,10 +170,11 @@ export function generateSeatbeltProfile( lines.push("(version 1)"); lines.push(""); - // Determine base stance from default permission. - const defaultPerm = config.default ?? "---"; + // Determine base stance from the "/**" catch-all rule (replaces the removed `default` field). + const catchAllPerm = findBestRule("/**", config.rules ?? {}, homeDir) ?? "---"; + const defaultPerm = catchAllPerm; // alias for readability below const defaultAllowsAnything = - defaultPerm[0] === "r" || defaultPerm[1] === "w" || defaultPerm[2] === "x"; + catchAllPerm[0] === "r" || catchAllPerm[1] === "w" || catchAllPerm[2] === "x"; if (defaultAllowsAnything) { // Permissive base: allow everything, then restrict. @@ -209,7 +210,7 @@ export function generateSeatbeltProfile( // unconditionally granting /tmp access when default: "---". // Use "/tmp/." so glob rules like "/tmp/**" match correctly — findBestRule // on "/tmp" alone would miss "/**"-suffixed patterns that only match descendants. - const tmpPerm = findBestRule("/tmp/.", config.rules ?? {}, homeDir) ?? config.default ?? "---"; + const tmpPerm = findBestRule("/tmp/.", config.rules ?? {}, homeDir) ?? "---"; // 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") { @@ -262,24 +263,7 @@ export function generateSeatbeltProfile( } } - // deny[] entries — always win over base rules. - if ((config.deny ?? []).length > 0) { - lines.push(""); - lines.push("; Deny list — wins over base rules"); - for (const pattern of config.deny ?? []) { - for (const expanded of expandSbplAliases(pattern)) { - const matcher = patternToSbplMatcher(expanded, homeDir); - if (!matcher) { - continue; - } - lines.push(`(deny ${SEATBELT_READ_OPS} ${matcher})`); - lines.push(`(deny ${SEATBELT_WRITE_OPS} ${matcher})`); - lines.push(`(deny ${SEATBELT_EXEC_OPS} ${matcher})`); - } - } - } - - // Script-override rules emitted last — they win over deny entries above. + // 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) { From 77beb444bce8e38ebf5c6cd4f0f1d1ae8224d564 Mon Sep 17 00:00:00 2001 From: subrih Date: Sat, 14 Mar 2026 07:11:17 -0700 Subject: [PATCH 24/29] fix(access-policy): mid-path wildcard OS enforcement, structural validation, doc cleanup --- docs/tools/access-policy.md | 40 +++++++++++------ src/infra/access-policy-file.ts | 31 +++++++++++++ src/infra/access-policy.test.ts | 11 +++++ src/infra/access-policy.ts | 27 +++++++++-- src/infra/exec-sandbox-bwrap.test.ts | 23 ++++++++-- src/infra/exec-sandbox-bwrap.ts | 26 ++++++----- src/infra/exec-sandbox-seatbelt.test.ts | 23 ++++++++-- src/infra/exec-sandbox-seatbelt.ts | 60 ++++++++++++++++--------- 8 files changed, 187 insertions(+), 54 deletions(-) diff --git a/docs/tools/access-policy.md b/docs/tools/access-policy.md index fb1504f0f54..90fd7f4d8de 100644 --- a/docs/tools/access-policy.md +++ b/docs/tools/access-policy.md @@ -35,10 +35,10 @@ The file is **optional** — if absent, all operations pass through unchanged (a "/**": "r--", "/tmp/": "rwx", "~/": "rw-", - "~/dev/": "rwx" - }, - "deny": ["~/.ssh/", "~/.aws/", "~/.openclaw/credentials/"], - "default": "---" + "~/dev/": "rwx", + "~/.ssh/**": "---", + "~/.aws/**": "---" + } }, "agents": { "myagent": { "rules": { "~/private/": "rw-" } } @@ -58,6 +58,8 @@ Each rule value is a three-character string — one character per operation: 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. @@ -67,9 +69,19 @@ Examples: `"rwx"` (full access), `"r--"` (read only), `"r-x"` (read + exec), `"- ### Precedence -1. **`deny`** — always blocks, regardless of rules. Additive across layers — cannot be removed by agent overrides. -2. **`rules`** — longest matching glob wins (most specific pattern takes priority). -3. **`default`** — catch-all for unmatched paths. Omitting it is equivalent to `"---"`. +1. **`rules`** — 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 +"rules": { + "/**": "r--", + "~/.ssh/**": "---" +} +``` + +`~/.ssh/**` is longer than `/**` so it wins for any path under `~/.ssh/`. ## Layers @@ -77,9 +89,9 @@ Examples: `"rwx"` (full access), `"r--"` (read only), `"r-x"` (read + exec), `"- base → agents["*"] → agents["myagent"] ``` -- **`base`** — applies to all agents. Deny entries here can never be overridden. +- **`base`** — applies to all agents. - **`agents["*"]`** — wildcard block applied to every agent after `base`, before the agent-specific block. Useful for org-wide rules. -- **`agents`** — per-agent overrides. Each agent block is merged on top of `base` (and `agents["*"]` if present): deny is additive, rules are shallow-merged (agent wins on collision), default is agent-wins if set. +- **`agents`** — per-agent overrides. Each agent block is merged on top of `base` (and `agents["*"]` if present): rules are shallow-merged (agent wins on collision). ## Enforcement @@ -104,9 +116,9 @@ If the file exists but cannot be parsed, or contains structural errors (wrong ne Common mistakes caught by the validator: -- `rules`, `deny`, or `default` placed at the top level instead of under `base` +- `rules` or `scripts` placed at the top level instead of under `base` - Permission strings that are not exactly 3 characters (`"rwx"`, `"r--"`, `"---"`, etc.) -- Empty deny entries +- `deny` or `default` keys inside `base` or agent blocks — these fields were removed; use `"---"` rules instead ### Bare directory paths @@ -128,9 +140,11 @@ For cross-agent MCP tool delegation (an orchestrator invoking a tool on behalf o **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, place it in the deny list (no read access). +**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-specific `deny[]` entries on Linux (bwrap).** On Linux, `deny[]` entries are enforced at the OS layer using `bwrap --tmpfs` overlays, which only work on directories. When a `deny[]` entry resolves to an existing file (e.g. `deny: ["~/.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. `deny: ["~/.aws/"]` rather than `deny: ["~/.aws/credentials"]`). On macOS, seatbelt handles file-level denials correctly with `(deny file-read* (literal ...))`. +**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). **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. diff --git a/src/infra/access-policy-file.ts b/src/infra/access-policy-file.ts index 915b9880d86..f7b1e4d80eb 100644 --- a/src/infra/access-policy-file.ts +++ b/src/infra/access-policy-file.ts @@ -92,6 +92,31 @@ function validateAccessPolicyFileStructure(filePath: string, parsed: unknown): s ) { errors.push(`${filePath}: "base" must be an object`); } + // 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; + const KNOWN_CONFIG_KEYS = new Set(["rules", "scripts"]); + + function checkRemovedKeys(block: Record, 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.`, + ); + } + } + for (const key of Object.keys(block)) { + if (!KNOWN_CONFIG_KEYS.has(key)) { + // Only warn for keys that look like removed/misplaced fields, not arbitrary agent data. + if (REMOVED_KEYS.includes(key as (typeof REMOVED_KEYS)[number])) { + continue; + } // already reported above + // Unknown keys that are not known config keys — warn but don't fail-close for forward compat. + } + } + } + if (p["agents"] !== undefined) { if (typeof p["agents"] !== "object" || p["agents"] === null || Array.isArray(p["agents"])) { errors.push(`${filePath}: "agents" must be an object`); @@ -99,11 +124,17 @@ function validateAccessPolicyFileStructure(filePath: string, parsed: unknown): s for (const [agentId, block] of Object.entries(p["agents"] as Record)) { if (typeof block !== "object" || block === null || Array.isArray(block)) { errors.push(`${filePath}: agents["${agentId}"] must be an object`); + } else { + checkRemovedKeys(block as Record, `agents["${agentId}"]`); } } } } + if (typeof p["base"] === "object" && p["base"] !== null && !Array.isArray(p["base"])) { + checkRemovedKeys(p["base"] as Record, `base`); + } + // Catch common mistake: AccessPolicyConfig fields accidentally at top level // (e.g. user puts "rules" or "scripts" directly instead of under "base"). for (const key of ["rules", "scripts"] as const) { diff --git a/src/infra/access-policy.test.ts b/src/infra/access-policy.test.ts index 20d0a55643f..b41a7db0240 100644 --- a/src/infra/access-policy.test.ts +++ b/src/infra/access-policy.test.ts @@ -178,6 +178,17 @@ describe("validateAccessPolicyConfig", () => { expect(second.filter((e) => e.includes("mid-path wildcard"))).toHaveLength(0); }); + it("non-deny mid-path wildcard emits approximate-prefix diagnostic (not cannot-apply)", () => { + _resetMidPathWildcardWarnedForTest(); + const errs = validateAccessPolicyConfig({ + rules: { "~/.openclaw/agents/subri/workspace/skills/**/*.sh": "r-x" }, + }); + expect(errs).toHaveLength(1); + expect(errs[0]).toMatch(/mid-path wildcard/); + expect(errs[0]).toMatch(/prefix match/); + expect(errs[0]).not.toMatch(/cannot apply/); + }); + it("does NOT emit mid-path wildcard diagnostic for final-segment wildcards", () => { _resetMidPathWildcardWarnedForTest(); // "/home/user/**" — wildcard is in the final segment, no path separator follows. diff --git a/src/infra/access-policy.ts b/src/infra/access-policy.ts index e6fc9adf25e..e4ffce84ac8 100644 --- a/src/infra/access-policy.ts +++ b/src/infra/access-policy.ts @@ -60,9 +60,20 @@ export function validateAccessPolicyConfig(config: AccessPolicyConfig): string[] } if (hasMidPathWildcard(pattern) && !_midPathWildcardWarned.has(`rules:${pattern}`)) { _midPathWildcardWarned.add(`rules:${pattern}`); - errors.push( - `access-policy.rules["${pattern}"] contains a mid-path wildcard — OS-level (bwrap/Seatbelt) enforcement cannot apply; tool-layer enforcement is still active.`, - ); + 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.rules["${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.rules["${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 @@ -478,7 +489,10 @@ export function resolveArgv0(command: string, cwd?: string, _depth = 0): string * resolveArgv0 returns the real path "/usr/bin/python3.12". */ export function resolveScriptKey(k: string): string { - const expanded = k.startsWith("~") ? k.replace(/^~(?=$|[/\\])/, os.homedir()) : k; + // 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; } @@ -533,6 +547,11 @@ export function applyScriptPolicyOverride( 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 { diff --git a/src/infra/exec-sandbox-bwrap.test.ts b/src/infra/exec-sandbox-bwrap.test.ts index bc47b2e659f..0d1d173b77c 100644 --- a/src/infra/exec-sandbox-bwrap.test.ts +++ b/src/infra/exec-sandbox-bwrap.test.ts @@ -254,9 +254,9 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { expect(bindMounts).toContain(`${HOME}/output`); }); - it("skips mid-path wildcard patterns — truncated prefix would be too broad", () => { - // "/home/*/.ssh/**" truncates to "/home" — far too broad for a bwrap mount. - // The pattern must be silently ignored rather than binding /home. + 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 = { rules: { "/**": "r--", "/home/*/.config/**": "---" }, @@ -267,10 +267,25 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { ["--tmpfs", "--bind-try", "--ro-bind-try"].includes(args[i - 1] ?? "") ? a : null, ) .filter(Boolean); - // "/home" must NOT appear as a mount target — it's the over-broad truncation. 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 = { + rules: { "/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. diff --git a/src/infra/exec-sandbox-bwrap.ts b/src/infra/exec-sandbox-bwrap.ts index 09a276b1223..bb87783d7dd 100644 --- a/src/infra/exec-sandbox-bwrap.ts +++ b/src/infra/exec-sandbox-bwrap.ts @@ -82,11 +82,12 @@ function expandPattern(pattern: string, homeDir: string): string { * e.g. "/Users/kaveri/**" → "/Users/kaveri" * "/tmp/foo" → "/tmp/foo" * - * Returns null when a wildcard appears in a non-final segment (e.g. "/home/*\/.ssh/**") - * because the truncated prefix ("/home") would be far too broad for a bwrap mount - * and the caller must skip it entirely. + * 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): string | null { +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(/[*?[]/); @@ -95,11 +96,16 @@ function patternToPath(pattern: string, homeDir: string): string | null { 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. /home/*/foo) and the - // concrete prefix (/home) is too broad to be a safe mount target. + // If so, the wildcard is in a non-final segment (e.g. skills/**/*.sh). const afterWildcard = expanded.slice(wildcardIdx); if (/[/\\]/.test(afterWildcard)) { - return null; + // 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" @@ -168,7 +174,7 @@ export function generateBwrapArgs( // 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.rules ?? {}, homeDir); + const explicitTmpPerm = findBestRule("/tmp", config.rules ?? {}, 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 @@ -190,7 +196,7 @@ export function generateBwrapArgs( return (pa?.length ?? 0) - (pb?.length ?? 0); }); for (const [pattern, perm] of ruleEntries) { - const p = patternToPath(pattern, homeDir); + const p = patternToPath(pattern, homeDir, perm); if (!p || p === "/") { continue; } // root already handled above @@ -237,7 +243,7 @@ export function generateBwrapArgs( return (pa?.length ?? 0) - (pb?.length ?? 0); }); for (const [pattern, perm] of overrideEntries) { - const p = patternToPath(pattern, homeDir); + const p = patternToPath(pattern, homeDir, perm); if (!p || p === "/") { continue; } diff --git a/src/infra/exec-sandbox-seatbelt.test.ts b/src/infra/exec-sandbox-seatbelt.test.ts index 4b7a2296529..02bd5299784 100644 --- a/src/infra/exec-sandbox-seatbelt.test.ts +++ b/src/infra/exec-sandbox-seatbelt.test.ts @@ -270,9 +270,26 @@ describe("wrapCommandWithSeatbelt", () => { }); describe("generateSeatbeltProfile — mid-path wildcard guard", () => { - skipOnWindows("skips mid-path wildcard rules to avoid over-granting parent directory", () => { - // /home/*/workspace/** would truncate to /home and grant all of /home — must be skipped. - const profile = generateSeatbeltProfile({ rules: { "/home/*/workspace/**": "rwx" } }, HOME); + 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({ rules: { "/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({ rules: { "/home/*/workspace/**": "---" } }, HOME); expect(profile).not.toContain('(subpath "/home")'); }); diff --git a/src/infra/exec-sandbox-seatbelt.ts b/src/infra/exec-sandbox-seatbelt.ts index 9c1aa267092..fd4cc94792d 100644 --- a/src/infra/exec-sandbox-seatbelt.ts +++ b/src/infra/exec-sandbox-seatbelt.ts @@ -92,7 +92,12 @@ function expandSbplAliases(pattern: string): string[] { return [pattern]; } -function patternToSbplMatcher(pattern: string, homeDir: string): string | null { +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("~") @@ -109,17 +114,25 @@ function patternToSbplMatcher(pattern: string, homeDir: string): string | null { // If the original pattern had wildcards, use subpath (recursive match). // Otherwise use literal (exact match). if (/[*?]/.test(expanded)) { - // Guard against mid-path wildcards (e.g. /home/*/workspace/**): stripping from - // the first * would produce /home and silently grant access to all of /home. - // bwrap skips these patterns too — return null so callers can skip emission. const wildcardIdx = expanded.search(/[*?[]/); const afterWildcard = expanded.slice(wildcardIdx + 1); if (/[/\\]/.test(afterWildcard)) { - return null; + // 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 sbplSubpath(base); + return { matcher: sbplSubpath(base), approximate: false }; } - return sbplLiteral(base); + return { matcher: sbplLiteral(base), approximate: false }; } function permToOps(perm: PermStr): string[] { @@ -208,9 +221,9 @@ export function generateSeatbeltProfile( // 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: "---". - // Use "/tmp/." so glob rules like "/tmp/**" match correctly — findBestRule - // on "/tmp" alone would miss "/**"-suffixed patterns that only match descendants. - const tmpPerm = findBestRule("/tmp/.", config.rules ?? {}, homeDir) ?? "---"; + // findBestRule probes both the path and path+"/" internally, so "/tmp" correctly + // matches glob rules like "/tmp/**" without needing the "/tmp/." workaround. + const tmpPerm = findBestRule("/tmp", config.rules ?? {}, homeDir) ?? "---"; // 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") { @@ -248,15 +261,20 @@ export function generateSeatbeltProfile( lines.push("; User-defined path rules (shortest → longest; more specific wins)"); for (const [pattern, perm] of ruleEntries) { for (const expanded of expandSbplAliases(pattern)) { - const matcher = patternToSbplMatcher(expanded, homeDir); - if (!matcher) { + const result = patternToSbplMatcher(expanded, homeDir, perm); + if (!result) { continue; - } // skip mid-path wildcards — prefix would be too broad - // First allow the permitted ops, then deny the rest for this path. - for (const op of permToOps(perm)) { + } + 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)) { + for (const op of deniedOps(perm).filter(filterExec)) { lines.push(`(deny ${op} ${matcher})`); } } @@ -274,15 +292,17 @@ export function generateSeatbeltProfile( 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 matcher = patternToSbplMatcher(expanded, homeDir); - if (!matcher) { + const result = patternToSbplMatcher(expanded, homeDir, perm); + if (!result) { continue; } - for (const op of permToOps(perm)) { + 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)) { + for (const op of deniedOps(perm).filter(filterExec)) { lines.push(`(deny ${op} ${matcher})`); } } From c92c9c218120af5ba15b020c33ea4fae7dbe3a5f Mon Sep 17 00:00:00 2001 From: subrih Date: Sat, 14 Mar 2026 08:21:45 -0700 Subject: [PATCH 25/29] =?UTF-8?q?refactor(access-policy):=20rename=20rules?= =?UTF-8?q?=E2=86=92policy,=20agents['*']=20as=20universal=20base,=20docs?= =?UTF-8?q?=20rewrite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/tools/access-policy.md | 106 +++++++-- src/agents/bash-tools.exec-runtime.ts | 3 +- .../pi-tools.read.edit-permission.test.ts | 6 +- src/config/types.tools.ts | 18 +- src/infra/access-policy-file.test.ts | 129 +++++------ src/infra/access-policy-file.ts | 75 +++---- src/infra/access-policy.test.ts | 204 ++++++++++-------- src/infra/access-policy.ts | 131 +++++++---- src/infra/exec-sandbox-bwrap.test.ts | 62 +++--- src/infra/exec-sandbox-bwrap.ts | 6 +- src/infra/exec-sandbox-seatbelt.test.ts | 44 ++-- src/infra/exec-sandbox-seatbelt.ts | 6 +- 12 files changed, 467 insertions(+), 323 deletions(-) diff --git a/docs/tools/access-policy.md b/docs/tools/access-policy.md index 90fd7f4d8de..6ad4020fe56 100644 --- a/docs/tools/access-policy.md +++ b/docs/tools/access-policy.md @@ -23,25 +23,25 @@ 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 (a warning is logged). No restart is required when the file changes; it is read fresh on each agent turn. +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, - "base": { - "rules": { - "/**": "r--", - "/tmp/": "rwx", - "~/": "rw-", - "~/dev/": "rwx", - "~/.ssh/**": "---", - "~/.aws/**": "---" - } - }, "agents": { - "myagent": { "rules": { "~/private/": "rw-" } } + "*": { + "policy": { + "/**": "r--", + "/tmp/": "rwx", + "~/": "rw-", + "~/dev/": "rwx", + "~/.ssh/**": "---", + "~/.aws/**": "---" + } + }, + "myagent": { "policy": { "~/private/": "rw-" } } } } ``` @@ -69,13 +69,13 @@ Use `"---"` to explicitly deny all access to a path — this is the deny mechani ### Precedence -1. **`rules`** — longest matching glob wins (most specific pattern takes priority). +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 -"rules": { +"policy": { "/**": "r--", "~/.ssh/**": "---" } @@ -86,12 +86,68 @@ To deny a specific path, add a `"---"` rule that is more specific than any allow ## Layers ``` -base → agents["*"] → agents["myagent"] +agents["*"] → agents["myagent"] ``` -- **`base`** — applies to all agents. -- **`agents["*"]`** — wildcard block applied to every agent after `base`, before the agent-specific block. Useful for org-wide rules. -- **`agents`** — per-agent overrides. Each agent block is merged on top of `base` (and `agents["*"]` if present): rules are shallow-merged (agent wins on collision). +- **`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": "" + } + } + }, + "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 @@ -107,29 +163,31 @@ 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 **enforcement is disabled** until the file is fixed: +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] Permissions enforcement is DISABLED until the file is fixed. +[access-policy] Failing closed (default: "---") until the file is fixed. ``` Common mistakes caught by the validator: -- `rules` or `scripts` placed at the top level instead of under `base` +- `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 `base` or agent blocks — these fields were removed; use `"---"` rules instead +- `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] rules["~/dev/openclaw"] is a directory — rule auto-expanded to "~/dev/openclaw/**" so it covers all contents. +[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. @@ -146,6 +204,8 @@ For cross-agent MCP tool delegation (an orchestrator invoking a tool on behalf o **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 diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 7e425ab4202..91e53968001 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -390,8 +390,9 @@ export async function runExecProcess(opts: { // 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 — it holds shared rules, not a per-script entry. const hasScriptOverride = Object.keys(_scripts).some( - (k) => path.normalize(resolveScriptKey(k)) === path.normalize(argv0), + (k) => k !== "policy" && path.normalize(resolveScriptKey(k)) === path.normalize(argv0), ); if (!hasScriptOverride && checkAccessPolicy(argv0, "exec", effectivePermissions) === "deny") { throw new Error(`exec denied by access policy: ${argv0}`); diff --git a/src/agents/pi-tools.read.edit-permission.test.ts b/src/agents/pi-tools.read.edit-permission.test.ts index a5a0c632642..495d452ea67 100644 --- a/src/agents/pi-tools.read.edit-permission.test.ts +++ b/src/agents/pi-tools.read.edit-permission.test.ts @@ -55,7 +55,7 @@ describe("createHostWorkspaceEditTool edit read-permission check", () => { // "-w-" policy: write allowed, read denied. // Edit must NOT be allowed to read the file even if write is permitted. const permissions: AccessPolicyConfig = { - rules: { [`${tmpDir}/**`]: "-w-" }, + policy: { [`${tmpDir}/**`]: "-w-" }, }; createHostWorkspaceEditTool(tmpDir, { workspaceOnly: false, permissions }); expect(mocks.operations).toBeDefined(); @@ -72,7 +72,7 @@ describe("createHostWorkspaceEditTool edit read-permission check", () => { await fs.writeFile(filePath, "content", "utf8"); const permissions: AccessPolicyConfig = { - rules: { [`${tmpDir}/**`]: "rw-" }, + policy: { [`${tmpDir}/**`]: "rw-" }, }; createHostWorkspaceEditTool(tmpDir, { workspaceOnly: false, permissions }); expect(mocks.operations).toBeDefined(); @@ -90,7 +90,7 @@ describe("createHostWorkspaceEditTool edit read-permission check", () => { // "r--" policy: read allowed, write denied. const permissions: AccessPolicyConfig = { - rules: { [`${tmpDir}/**`]: "r--" }, + policy: { [`${tmpDir}/**`]: "r--" }, }; createHostWorkspaceEditTool(tmpDir, { workspaceOnly: false, permissions }); expect(mocks.operations).toBeDefined(); diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 41e6b933af6..be51080216b 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -8,8 +8,8 @@ export type PermStr = string; /** Per-script policy entry — allows narrower permissions for a specific script binary. */ export type ScriptPolicyEntry = { - /** Restrict/expand rules for this script. Merged over the base policy rules. */ - rules?: Record; + /** Extra path rules for this script, merged over the shared script policy. */ + policy?: Record; /** SHA-256 hex of the script file for integrity checking (best-effort, not atomic). */ sha256?: string; }; @@ -23,9 +23,17 @@ export type ScriptPolicyEntry = { */ export type AccessPolicyConfig = { /** Glob-pattern rules: path → permission string. Longest prefix wins. */ - rules?: Record; - /** Per-script argv0 policy overrides keyed by resolved binary path. */ - scripts?: Record; + policy?: Record; + /** + * 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; + [path: string]: ScriptPolicyEntry | Record | undefined; + }; }; export type MediaUnderstandingScopeMatch = { diff --git a/src/infra/access-policy-file.test.ts b/src/infra/access-policy-file.test.ts index 807883b39c0..7ade68f01f3 100644 --- a/src/infra/access-policy-file.test.ts +++ b/src/infra/access-policy-file.test.ts @@ -56,28 +56,28 @@ describe("mergeAccessPolicy", () => { }); it("returns base when override is undefined", () => { - const base = { rules: { "/**": "r--" as const } }; + const base = { policy: { "/**": "r--" as const } }; expect(mergeAccessPolicy(base, undefined)).toEqual(base); }); it("returns override when base is undefined", () => { - const override = { rules: { "/**": "rwx" as const } }; + const override = { policy: { "/**": "rwx" as const } }; expect(mergeAccessPolicy(undefined, override)).toEqual(override); }); it("rules are shallow-merged, override key wins on collision", () => { const result = mergeAccessPolicy( - { rules: { "/**": "r--", "~/**": "rw-" } }, - { rules: { "~/**": "rwx", "~/dev/**": "rwx" } }, + { policy: { "/**": "r--", "~/**": "rw-" } }, + { policy: { "~/**": "rwx", "~/dev/**": "rwx" } }, ); - expect(result?.rules?.["/**"]).toBe("r--"); // base survives - expect(result?.rules?.["~/**"]).toBe("rwx"); // override wins - expect(result?.rules?.["~/dev/**"]).toBe("rwx"); // override adds + 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?.rules).toBeUndefined(); + expect(result?.policy).toBeUndefined(); }); it("scripts deep-merge: base sha256 is preserved when override supplies same script key", () => { @@ -87,7 +87,7 @@ describe("mergeAccessPolicy", () => { scripts: { "/usr/local/bin/deploy.sh": { sha256: "abc123", - rules: { "~/deploy/**": "rwx" as const }, + policy: { "~/deploy/**": "rwx" as const }, }, }, }; @@ -95,28 +95,33 @@ describe("mergeAccessPolicy", () => { scripts: { "/usr/local/bin/deploy.sh": { // Agent block supplies same key — must NOT be able to drop sha256. - rules: { "~/deploy/**": "r--" as const }, // narrower override — fine + policy: { "~/deploy/**": "r--" as const }, // narrower override — fine }, }, }; const result = mergeAccessPolicy(base, override); - const merged = result?.scripts?.["/usr/local/bin/deploy.sh"]; + 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"); - // rules: override key wins on collision. - expect(merged?.rules?.["~/deploy/**"]).toBe("r--"); + // 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": { rules: { "/tmp/**": "rwx" as const } } }, + 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. - expect(result?.scripts?.["/bin/new.sh"]?.rules?.["/tmp/**"]).toBe("rwx"); + const newScript = result?.scripts?.["/bin/new.sh"] as + | import("../config/types.tools.js").ScriptPolicyEntry + | undefined; + expect(newScript?.policy?.["/tmp/**"]).toBe("rwx"); }); }); @@ -150,12 +155,12 @@ describe("loadAccessPolicyFile", () => { spy.mockRestore(); }); - it("returns BROKEN_POLICY_FILE and logs error when base is not an object", () => { + 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: ["r--"] }); + writeFile({ version: 1, base: { policy: { "/**": "r--" } } }); const result = loadAccessPolicyFile(); expect(result).toBe(BROKEN_POLICY_FILE); - expect(spy).toHaveBeenCalledWith(expect.stringContaining('"base" must be an object')); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('unexpected top-level key "base"')); spy.mockRestore(); }); @@ -168,13 +173,13 @@ describe("loadAccessPolicyFile", () => { spy.mockRestore(); }); - it("returns BROKEN_POLICY_FILE and logs error when a top-level key like 'rules' is misplaced", () => { + 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: rules at top level instead of under base - writeFile({ version: 1, rules: { "/**": "r--" } }); + // 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 "rules"')); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('unexpected top-level key "policy"')); spy.mockRestore(); }); @@ -199,8 +204,10 @@ describe("loadAccessPolicyFile", () => { it("returns parsed file when valid", () => { const content: AccessPolicyFile = { version: 1, - base: { rules: { "/**": "r--", "~/.ssh/**": "---" } }, - agents: { subri: { rules: { "~/dev/**": "rwx" } } }, + agents: { + "*": { policy: { "/**": "r--", "~/.ssh/**": "---" } }, + subri: { policy: { "~/dev/**": "rwx" } }, + }, }; writeFile(content); const result = loadAccessPolicyFile(); @@ -210,8 +217,8 @@ describe("loadAccessPolicyFile", () => { throw new Error("unexpected"); } expect(result.version).toBe(1); - expect(result.base?.rules?.["/**"]).toBe("r--"); - expect(result.agents?.subri?.rules?.["~/dev/**"]).toBe("rwx"); + expect(result.agents?.["*"]?.policy?.["/**"]).toBe("r--"); + expect(result.agents?.subri?.policy?.["~/dev/**"]).toBe("rwx"); }); }); @@ -221,7 +228,7 @@ describe("loadAccessPolicyFile", () => { describe("loadAccessPolicyFile — mtime cache", () => { it("returns cached result on second call without re-reading the file", () => { - writeFile({ version: 1, base: { rules: { "/**": "r--" } } }); + writeFile({ version: 1, agents: { "*": { policy: { "/**": "r--" } } } }); const spy = vi.spyOn(fs, "readFileSync"); loadAccessPolicyFile(); // populate cache loadAccessPolicyFile(); // should hit cache @@ -231,11 +238,11 @@ describe("loadAccessPolicyFile — mtime cache", () => { }); it("re-reads when mtime changes (file updated)", () => { - writeFile({ version: 1, base: { rules: { "/**": "r--" } } }); + 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, base: { rules: { "/**": "rwx" } } }); + writeFile({ version: 1, agents: { "*": { policy: { "/**": "rwx" } } } }); const future = Date.now() / 1000 + 1; fs.utimesSync(FP_FILE, future, future); const result = loadAccessPolicyFile(); @@ -243,11 +250,11 @@ describe("loadAccessPolicyFile — mtime cache", () => { if (result === null || result === BROKEN_POLICY_FILE) { throw new Error("unexpected"); } - expect(result.base?.rules?.["/**"]).toBe("rwx"); + expect(result.agents?.["*"]?.policy?.["/**"]).toBe("rwx"); }); it("clears cache when file is deleted", () => { - writeFile({ version: 1, base: { default: "r--" } }); + writeFile({ version: 1, agents: { "*": { policy: { "/**": "r--" } } } }); loadAccessPolicyFile(); // populate cache fs.unlinkSync(FP_FILE); expect(loadAccessPolicyFile()).toBeNull(); @@ -287,7 +294,7 @@ describe("resolveAccessPolicyForAgent", () => { it("does not warn when config file exists and is valid", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - writeFile({ version: 1, base: { rules: { "/**": "r--" } } }); + writeFile({ version: 1, agents: { "*": { policy: { "/**": "r--" } } } }); resolveAccessPolicyForAgent("subri"); expect(warnSpy).not.toHaveBeenCalled(); warnSpy.mockRestore(); @@ -296,7 +303,7 @@ describe("resolveAccessPolicyForAgent", () => { 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, rules: { "/**": "r--" } }); // misplaced key — triggers error + writeFile({ version: 1, policy: { "/**": "r--" } }); // misplaced key — triggers error const result = resolveAccessPolicyForAgent("subri"); expect(warnSpy).not.toHaveBeenCalled(); expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("Failing closed")); @@ -308,7 +315,7 @@ describe("resolveAccessPolicyForAgent", () => { 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, rules: { "/**": "r--" } }); // misplaced key — broken + 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. @@ -327,59 +334,59 @@ describe("resolveAccessPolicyForAgent", () => { it("returns base when no agent block exists", () => { writeFile({ version: 1, - base: { rules: { "/**": "r--", [`~/.ssh/**`]: "---" } }, + agents: { "*": { policy: { "/**": "r--", [`~/.ssh/**`]: "---" } } }, }); const result = resolveAccessPolicyForAgent("subri"); - expect(result?.rules?.["/**"]).toBe("r--"); - expect(result?.rules?.["~/.ssh/**"]).toBe("---"); + expect(result?.policy?.["/**"]).toBe("r--"); + expect(result?.policy?.["~/.ssh/**"]).toBe("---"); }); it("merges base + named agent", () => { writeFile({ version: 1, - base: { rules: { "/**": "r--", [`~/.ssh/**`]: "---" } }, - agents: { subri: { rules: { "~/dev/**": "rwx" } } }, + agents: { + "*": { policy: { "/**": "r--", [`~/.ssh/**`]: "---" } }, + subri: { policy: { "~/dev/**": "rwx" } }, + }, }); const result = resolveAccessPolicyForAgent("subri"); - // rules: merged, agent rule wins on collision - expect(result?.rules?.["/**"]).toBe("r--"); - expect(result?.rules?.["~/dev/**"]).toBe("rwx"); + // policy: merged, agent rule wins on collision + expect(result?.policy?.["/**"]).toBe("r--"); + expect(result?.policy?.["~/dev/**"]).toBe("rwx"); // base "---" rule preserved - expect(result?.rules?.["~/.ssh/**"]).toBe("---"); + expect(result?.policy?.["~/.ssh/**"]).toBe("---"); }); it("wildcard agent applies before named agent", () => { writeFile({ version: 1, - base: {}, agents: { - "*": { rules: { "/usr/bin/**": "r-x" } }, - subri: { rules: { "~/dev/**": "rwx" } }, + "*": { policy: { "/usr/bin/**": "r-x" } }, + subri: { policy: { "~/dev/**": "rwx" } }, }, }); const result = resolveAccessPolicyForAgent("subri"); - expect(result?.rules?.["/usr/bin/**"]).toBe("r-x"); // from wildcard - expect(result?.rules?.["~/dev/**"]).toBe("rwx"); // from named agent + 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, - base: {}, - agents: { "*": { rules: { [`~/.ssh/**`]: "---" } } }, + agents: { "*": { policy: { [`~/.ssh/**`]: "---" } } }, }); const result = resolveAccessPolicyForAgent("other-agent"); - expect(result?.rules?.["~/.ssh/**"]).toBe("---"); + expect(result?.policy?.["~/.ssh/**"]).toBe("---"); }); it("wildcard key itself is not treated as a named agent", () => { writeFile({ version: 1, - agents: { "*": { rules: { [`~/.ssh/**`]: "---" } } }, + agents: { "*": { policy: { [`~/.ssh/**`]: "---" } } }, }); // Requesting agentId "*" should not double-apply wildcard as named const result = resolveAccessPolicyForAgent("*"); - expect(result?.rules?.["~/.ssh/**"]).toBe("---"); + expect(result?.policy?.["~/.ssh/**"]).toBe("---"); }); it("returns undefined when file is empty (no base, no agents)", () => { @@ -393,7 +400,7 @@ describe("resolveAccessPolicyForAgent", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); writeFile({ version: 1, - base: { rules: { "/**": "BAD" } }, + agents: { "*": { policy: { "/**": "BAD" } } }, }); resolveAccessPolicyForAgent("subri"); expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("BAD")); @@ -409,7 +416,7 @@ describe("resolveAccessPolicyForAgent", () => { // 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, base: { rules: { [dir]: "r--" } } }); + 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); @@ -419,7 +426,7 @@ describe("resolveAccessPolicyForAgent", () => { it("prints 'Bad permission strings' footer when a real perm-string error is present", () => { const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - writeFile({ version: 1, base: { rules: { "/**": "BAD" } } }); + 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); @@ -429,11 +436,13 @@ describe("resolveAccessPolicyForAgent", () => { it("narrowing rules from base and agent are all preserved in merged result", () => { writeFile({ version: 1, - base: { rules: { [`~/.ssh/**`]: "---" } }, - agents: { paranoid: { rules: { [`~/.aws/**`]: "---" } } }, + agents: { + "*": { policy: { [`~/.ssh/**`]: "---" } }, + paranoid: { policy: { [`~/.aws/**`]: "---" } }, + }, }); const result = resolveAccessPolicyForAgent("paranoid"); - expect(result?.rules?.["~/.ssh/**"]).toBe("---"); - expect(result?.rules?.["~/.aws/**"]).toBe("---"); + expect(result?.policy?.["~/.ssh/**"]).toBe("---"); + expect(result?.policy?.["~/.aws/**"]).toBe("---"); }); }); diff --git a/src/infra/access-policy-file.ts b/src/infra/access-policy-file.ts index f7b1e4d80eb..ee19e703ba0 100644 --- a/src/infra/access-policy-file.ts +++ b/src/infra/access-policy-file.ts @@ -6,16 +6,17 @@ import { validateAccessPolicyConfig } from "./access-policy.js"; export type AccessPolicyFile = { version: 1; - base?: AccessPolicyConfig; /** - * Per-agent overrides keyed by agent ID, or "*" for a wildcard that applies - * to every agent before the named agent block is merged in. + * 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): - * base → agents["*"] → agents[agentId] + * agents["*"] → agents[agentId] * * Within each layer: - * - rules: shallow-merge, override key wins on collision + * - policy: shallow-merge, override key wins on collision * - scripts: deep-merge per key; base sha256 is preserved */ agents?: Record; @@ -46,31 +47,44 @@ export function mergeAccessPolicy( if (!override) { return base; } - const rules = { ...base.rules, ...override.rules }; + 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 = { ...base.scripts }; for (const [key, overrideEntry] of Object.entries(override.scripts ?? {})) { - const baseEntry = base.scripts?.[key]; + if (key === "policy") { + // "policy" holds shared rules (Record) — 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; + const overrideScriptEntry = + overrideEntry as import("../config/types.tools.js").ScriptPolicyEntry; if (!baseEntry) { - mergedScripts[key] = overrideEntry; + 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 } : {}), - // rules: shallow-merge, override key wins on collision. - ...(Object.keys({ ...baseEntry.rules, ...overrideEntry.rules }).length > 0 - ? { rules: { ...baseEntry.rules, ...overrideEntry.rules } } + // 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.rules = rules; + result.policy = rules; } if (scripts) { result.scripts = scripts; @@ -86,17 +100,10 @@ function validateAccessPolicyFileStructure(filePath: string, parsed: unknown): s const errors: string[] = []; const p = parsed as Record; - if ( - p["base"] !== undefined && - (typeof p["base"] !== "object" || p["base"] === null || Array.isArray(p["base"])) - ) { - errors.push(`${filePath}: "base" must be an object`); - } // 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; - const KNOWN_CONFIG_KEYS = new Set(["rules", "scripts"]); function checkRemovedKeys(block: Record, context: string): void { for (const key of REMOVED_KEYS) { @@ -106,15 +113,6 @@ function validateAccessPolicyFileStructure(filePath: string, parsed: unknown): s ); } } - for (const key of Object.keys(block)) { - if (!KNOWN_CONFIG_KEYS.has(key)) { - // Only warn for keys that look like removed/misplaced fields, not arbitrary agent data. - if (REMOVED_KEYS.includes(key as (typeof REMOVED_KEYS)[number])) { - continue; - } // already reported above - // Unknown keys that are not known config keys — warn but don't fail-close for forward compat. - } - } } if (p["agents"] !== undefined) { @@ -131,16 +129,12 @@ function validateAccessPolicyFileStructure(filePath: string, parsed: unknown): s } } - if (typeof p["base"] === "object" && p["base"] !== null && !Array.isArray(p["base"])) { - checkRemovedKeys(p["base"] as Record, `base`); - } - - // Catch common mistake: AccessPolicyConfig fields accidentally at top level - // (e.g. user puts "rules" or "scripts" directly instead of under "base"). - for (const key of ["rules", "scripts"] as const) { + // 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 "base"?`, + `${filePath}: unexpected top-level key "${key}" — did you mean to put it under agents["*"]?`, ); } } @@ -260,7 +254,7 @@ export function _resetNotFoundWarnedForTest(): void { /** * Resolve the effective AccessPolicyConfig for a given agent. * - * Merge order: base → agents["*"] → agents[agentId] + * 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 @@ -282,11 +276,8 @@ export function resolveAccessPolicyForAgent(agentId?: string): AccessPolicyConfi return undefined; } - let merged = mergeAccessPolicy(undefined, file.base); - const wildcard = file.agents?.["*"]; - if (wildcard) { - merged = mergeAccessPolicy(merged, wildcard); - } + // 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) { diff --git a/src/infra/access-policy.test.ts b/src/infra/access-policy.test.ts index b41a7db0240..e72b6c4918b 100644 --- a/src/infra/access-policy.test.ts +++ b/src/infra/access-policy.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it } from "vitest"; -import type { AccessPolicyConfig } from "../config/types.tools.js"; +import type { AccessPolicyConfig, ScriptPolicyEntry } from "../config/types.tools.js"; import { _resetAutoExpandedWarnedForTest, _resetMidPathWildcardWarnedForTest, @@ -34,7 +34,7 @@ describe("validateAccessPolicyConfig", () => { it("returns no errors for a valid config", () => { expect( validateAccessPolicyConfig({ - rules: { "/**": "r--", [`${HOME}/**`]: "rwx", [`${HOME}/.ssh/**`]: "---" }, + policy: { "/**": "r--", [`${HOME}/**`]: "rwx", [`${HOME}/.ssh/**`]: "---" }, }), ).toEqual([]); }); @@ -44,19 +44,19 @@ describe("validateAccessPolicyConfig", () => { }); it("rejects invalid rule perm value", () => { - const errs = validateAccessPolicyConfig({ rules: { "/**": "rx" } }); + const errs = validateAccessPolicyConfig({ policy: { "/**": "rx" } }); expect(errs).toHaveLength(1); - expect(errs[0]).toMatch(/rules/); + expect(errs[0]).toMatch(/policy/); }); it("rejects rule perm value with wrong char in w position", () => { - const errs = validateAccessPolicyConfig({ rules: { "/**": "r1x" } }); + const errs = validateAccessPolicyConfig({ policy: { "/**": "r1x" } }); expect(errs).toHaveLength(1); - expect(errs[0]).toMatch(/rules/); + expect(errs[0]).toMatch(/policy/); }); it("reports an error when a rule perm value is invalid", () => { - const errs = validateAccessPolicyConfig({ rules: { "/**": "xyz" } }); + const errs = validateAccessPolicyConfig({ policy: { "/**": "xyz" } }); expect(errs.length).toBeGreaterThanOrEqual(1); }); @@ -64,19 +64,19 @@ describe("validateAccessPolicyConfig", () => { // A "---" rule on a specific file path must block reads at the tool layer. const file = process.execPath; const config: AccessPolicyConfig = { - rules: { "/**": "rwx", [file]: "---" }, + policy: { "/**": "rwx", [file]: "---" }, }; validateAccessPolicyConfig(config); // applies normalization in-place expect(checkAccessPolicy(file, "read", config)).toBe("deny"); }); - it("validates scripts[].rules perm strings and emits diagnostics for bad ones", () => { - // A typo like "rwX" in a script's rules must produce a diagnostic, not silently + it("validates scripts[].policy perm strings and emits diagnostics for bad ones", () => { + // A typo like "rwX" in a script's policy must produce a diagnostic, not silently // fail closed (which would deny exec with no operator-visible error). const config: AccessPolicyConfig = { scripts: { "/usr/local/bin/deploy.sh": { - rules: { "~/deploy/**": "rwX" }, // invalid: uppercase X + policy: { "~/deploy/**": "rwX" }, // invalid: uppercase X }, }, }; @@ -85,21 +85,21 @@ describe("validateAccessPolicyConfig", () => { }); it("accepts valid rule perm strings", () => { - expect(validateAccessPolicyConfig({ rules: { "/**": "rwx" } })).toEqual([]); - expect(validateAccessPolicyConfig({ rules: { "/**": "---" } })).toEqual([]); - expect(validateAccessPolicyConfig({ rules: { "/**": "r-x" } })).toEqual([]); + expect(validateAccessPolicyConfig({ policy: { "/**": "rwx" } })).toEqual([]); + expect(validateAccessPolicyConfig({ policy: { "/**": "---" } })).toEqual([]); + expect(validateAccessPolicyConfig({ policy: { "/**": "r-x" } })).toEqual([]); }); it("auto-expands a bare path that points to a real directory", () => { // os.tmpdir() is guaranteed to exist and be a directory on every platform. const dir = os.tmpdir(); - const config = { rules: { [dir]: "r--" as const } }; + const config: AccessPolicyConfig = { policy: { [dir]: "r--" as const } }; const errs = validateAccessPolicyConfig(config); expect(errs).toHaveLength(1); expect(errs[0]).toMatch(/auto-expanded/); // Rule should be rewritten in place with /** suffix. - expect(config.rules[`${dir}/**`]).toBe("r--"); - expect(config.rules[dir]).toBeUndefined(); + expect(config.policy?.[`${dir}/**`]).toBe("r--"); + expect(config.policy?.[dir]).toBeUndefined(); }); it("auto-expand does not overwrite an existing explicit glob rule", () => { @@ -108,61 +108,85 @@ describe("validateAccessPolicyConfig", () => { // from "---" to "rwx" — a security regression. const dir = os.tmpdir(); const config: AccessPolicyConfig = { - rules: { [dir]: "rwx", [`${dir}/**`]: "---" }, + policy: { [dir]: "rwx", [`${dir}/**`]: "---" }, }; validateAccessPolicyConfig(config); // Explicit "---" rule must be preserved. - expect(config.rules?.[`${dir}/**`]).toBe("---"); + expect(config.policy?.[`${dir}/**`]).toBe("---"); }); it("auto-expands when a ~ path expands to a real directory", () => { // "~" expands to os.homedir() which always exists and is a directory. - const config: AccessPolicyConfig = { rules: { "~": "r--" } }; + const config: AccessPolicyConfig = { policy: { "~": "r--" } }; const errs = validateAccessPolicyConfig(config); expect(errs).toHaveLength(1); expect(errs[0]).toMatch(/auto-expanded/); // Rule key should be rewritten with /** suffix. - expect(config.rules?.["~/**"]).toBe("r--"); - expect(config.rules?.["~"]).toBeUndefined(); + expect(config.policy?.["~/**"]).toBe("r--"); + expect(config.policy?.["~"]).toBeUndefined(); }); it("emits the diagnostic only once per process for the same pattern", () => { const dir = os.tmpdir(); // First call — should warn. - const first = validateAccessPolicyConfig({ rules: { [dir]: "r--" as const } }); + const first = validateAccessPolicyConfig({ policy: { [dir]: "r--" as const } }); expect(first).toHaveLength(1); // Second call with the same bare pattern — already warned, silent. - const second = validateAccessPolicyConfig({ rules: { [dir]: "r--" as const } }); + const second = validateAccessPolicyConfig({ policy: { [dir]: "r--" as const } }); expect(second).toHaveLength(0); }); it("does not warn for glob patterns or trailing-/ rules", () => { const dir = os.tmpdir(); - expect(validateAccessPolicyConfig({ rules: { [`${dir}/**`]: "r--" } })).toEqual([]); - expect(validateAccessPolicyConfig({ rules: { [`${dir}/`]: "r--" } })).toEqual([]); - expect(validateAccessPolicyConfig({ rules: { "/tmp/**": "rwx" } })).toEqual([]); + expect(validateAccessPolicyConfig({ policy: { [`${dir}/**`]: "r--" } })).toEqual([]); + expect(validateAccessPolicyConfig({ policy: { [`${dir}/`]: "r--" } })).toEqual([]); + expect(validateAccessPolicyConfig({ policy: { "/tmp/**": "rwx" } })).toEqual([]); }); it("does not warn for bare file paths (stat confirms it is a file)", () => { // process.execPath is the running node/bun binary — always a real file, never a dir. - expect(validateAccessPolicyConfig({ rules: { [process.execPath]: "r--" } })).toEqual([]); + expect(validateAccessPolicyConfig({ policy: { [process.execPath]: "r--" } })).toEqual([]); }); it("does not warn for paths that do not exist (ENOENT silently ignored)", () => { expect( validateAccessPolicyConfig({ - rules: { "/nonexistent/path/that/cannot/exist-xyzzy": "r--" }, + policy: { "/nonexistent/path/that/cannot/exist-xyzzy": "r--" }, }), ).toEqual([]); }); + it('auto-expands bare directory in scripts["policy"] shared rules', () => { + const dir = os.tmpdir(); + const config: AccessPolicyConfig = { + scripts: { policy: { [dir]: "rw-" as const } }, + }; + const errs = validateAccessPolicyConfig(config); + expect(errs.some((e) => e.includes("auto-expanded"))).toBe(true); + const sharedPolicy = config.scripts?.["policy"]; + expect(sharedPolicy?.[`${dir}/**`]).toBe("rw-"); + expect(sharedPolicy?.[dir]).toBeUndefined(); + }); + + it("auto-expands bare directory in per-script policy entry", () => { + const dir = os.tmpdir(); + const config: AccessPolicyConfig = { + scripts: { "/deploy.sh": { policy: { [dir]: "rwx" as const } } }, + }; + const errs = validateAccessPolicyConfig(config); + expect(errs.some((e) => e.includes("auto-expanded"))).toBe(true); + const entry = config.scripts?.["/deploy.sh"] as ScriptPolicyEntry | undefined; + expect(entry?.policy?.[`${dir}/**`]).toBe("rwx"); + expect(entry?.policy?.[dir]).toBeUndefined(); + }); + it("emits a one-time diagnostic for mid-path wildcard rules (OS-level enforcement skipped)", () => { _resetMidPathWildcardWarnedForTest(); // "/home/*/secrets/**" has a wildcard in a non-final segment — bwrap and // Seatbelt cannot derive a concrete mount path so they skip it silently. // validateAccessPolicyConfig must surface this so operators know. const errs = validateAccessPolicyConfig({ - rules: { "/home/*/secrets/**": "---" }, + policy: { "/home/*/secrets/**": "---" }, }); expect(errs).toHaveLength(1); expect(errs[0]).toMatch(/mid-path wildcard/); @@ -171,7 +195,7 @@ describe("validateAccessPolicyConfig", () => { it("deduplicates mid-path wildcard rule diagnostics across calls", () => { _resetMidPathWildcardWarnedForTest(); - const config = { rules: { "/home/*/secrets/**": "---" } }; + const config = { policy: { "/home/*/secrets/**": "---" } }; const first = validateAccessPolicyConfig(config); const second = validateAccessPolicyConfig(config); expect(first.filter((e) => e.includes("mid-path wildcard"))).toHaveLength(1); @@ -181,7 +205,7 @@ describe("validateAccessPolicyConfig", () => { it("non-deny mid-path wildcard emits approximate-prefix diagnostic (not cannot-apply)", () => { _resetMidPathWildcardWarnedForTest(); const errs = validateAccessPolicyConfig({ - rules: { "~/.openclaw/agents/subri/workspace/skills/**/*.sh": "r-x" }, + policy: { "~/.openclaw/agents/subri/workspace/skills/**/*.sh": "r-x" }, }); expect(errs).toHaveLength(1); expect(errs[0]).toMatch(/mid-path wildcard/); @@ -193,7 +217,7 @@ describe("validateAccessPolicyConfig", () => { _resetMidPathWildcardWarnedForTest(); // "/home/user/**" — wildcard is in the final segment, no path separator follows. const errs = validateAccessPolicyConfig({ - rules: { "/home/user/**": "r--", "~/**": "rwx", "/tmp/**": "---" }, + policy: { "/home/user/**": "r--", "~/**": "rwx", "/tmp/**": "---" }, }); expect(errs.filter((e) => e.includes("mid-path wildcard"))).toHaveLength(0); }); @@ -206,17 +230,17 @@ describe("validateAccessPolicyConfig", () => { describe("checkAccessPolicy — malformed permission characters fail closed", () => { it("treats a typo like 'r1-' as deny for write (only exact 'w' grants write)", () => { // "r1-": index 1 is "1", not "w" — must deny write, not allow it. - const config = { rules: { "/tmp/**": "r1-" as unknown as "r--" } }; + const config = { policy: { "/tmp/**": "r1-" as unknown as "r--" } }; expect(checkAccessPolicy("/tmp/foo.txt", "write", config)).toBe("deny"); }); it("treats 'R--' (uppercase) as deny for read (only lowercase 'r' grants read)", () => { - const config = { rules: { "/tmp/**": "R--" as unknown as "r--" } }; + const config = { policy: { "/tmp/**": "R--" as unknown as "r--" } }; expect(checkAccessPolicy("/tmp/foo.txt", "read", config)).toBe("deny"); }); it("treats 'rWx' (uppercase W) as deny for write", () => { - const config = { rules: { "/tmp/**": "rWx" as unknown as "rwx" } }; + const config = { policy: { "/tmp/**": "rWx" as unknown as "rwx" } }; expect(checkAccessPolicy("/tmp/foo.txt", "write", config)).toBe("deny"); }); }); @@ -227,13 +251,13 @@ describe("checkAccessPolicy — malformed permission characters fail closed", () describe("checkAccessPolicy — trailing slash shorthand", () => { it('"/tmp/" is equivalent to "/tmp/**"', () => { - const config: AccessPolicyConfig = { rules: { "/tmp/": "rwx" } }; + const config: AccessPolicyConfig = { policy: { "/tmp/": "rwx" } }; expect(checkAccessPolicy("/tmp/foo.txt", "write", config)).toBe("allow"); expect(checkAccessPolicy("/tmp/a/b/c", "write", config)).toBe("allow"); }); it('"~/" is equivalent to "~/**"', () => { - const config: AccessPolicyConfig = { rules: { "~/": "rw-" } }; + const config: AccessPolicyConfig = { policy: { "~/": "rw-" } }; expect(checkAccessPolicy(`${HOME}/foo.txt`, "read", config)).toBe("allow"); expect(checkAccessPolicy(`${HOME}/foo.txt`, "write", config)).toBe("allow"); expect(checkAccessPolicy(`${HOME}/foo.txt`, "exec", config)).toBe("deny"); @@ -241,14 +265,14 @@ describe("checkAccessPolicy — trailing slash shorthand", () => { it('"---" rule with trailing slash blocks subtree', () => { const config: AccessPolicyConfig = { - rules: { "/**": "rwx", [`${HOME}/.ssh/`]: "---" }, + policy: { "/**": "rwx", [`${HOME}/.ssh/`]: "---" }, }; expect(checkAccessPolicy(`${HOME}/.ssh/id_rsa`, "read", config)).toBe("deny"); }); it("trailing slash and /** produce identical results", () => { - const withSlash: AccessPolicyConfig = { rules: { "/tmp/": "rwx" } }; - const withGlob: AccessPolicyConfig = { rules: { "/tmp/**": "rwx" } }; + const withSlash: AccessPolicyConfig = { policy: { "/tmp/": "rwx" } }; + const withGlob: AccessPolicyConfig = { policy: { "/tmp/**": "rwx" } }; const paths = ["/tmp/a", "/tmp/a/b", "/tmp/a/b/c.txt"]; for (const p of paths) { expect(checkAccessPolicy(p, "write", withSlash)).toBe( @@ -261,7 +285,7 @@ describe("checkAccessPolicy — trailing slash shorthand", () => { // Rule "~/.openclaw/heartbeat/" should allow write on the bare directory // path ~/.openclaw/heartbeat (no trailing component), not just its contents. const config: AccessPolicyConfig = { - rules: { "/**": "r--", [`${HOME}/.openclaw/heartbeat/`]: "rw-" }, + policy: { "/**": "r--", [`${HOME}/.openclaw/heartbeat/`]: "rw-" }, }; expect(checkAccessPolicy(`${HOME}/.openclaw/heartbeat`, "write", config)).toBe("allow"); expect(checkAccessPolicy(`${HOME}/.openclaw/heartbeat/test.txt`, "write", config)).toBe( @@ -271,7 +295,7 @@ describe("checkAccessPolicy — trailing slash shorthand", () => { it('"---" trailing-slash rule blocks the directory itself and its contents', () => { const config: AccessPolicyConfig = { - rules: { "/**": "rwx", [`${HOME}/.ssh/`]: "---" }, + policy: { "/**": "rwx", [`${HOME}/.ssh/`]: "---" }, }; // Both the directory and its contents should be denied. expect(checkAccessPolicy(`${HOME}/.ssh`, "read", config)).toBe("deny"); @@ -287,7 +311,7 @@ describe.skipIf(process.platform !== "darwin")( "checkAccessPolicy — macOS /private alias normalization", () => { const config: AccessPolicyConfig = { - rules: { + policy: { "/tmp/**": "rwx", "/var/**": "r--", "/etc/**": "r--", @@ -312,7 +336,7 @@ describe.skipIf(process.platform !== "darwin")( it('"---" rule for /tmp/** also blocks /private/tmp/**', () => { const denyConfig: AccessPolicyConfig = { - rules: { "/**": "rwx", "/tmp/**": "---" }, + policy: { "/**": "rwx", "/tmp/**": "---" }, }; expect(checkAccessPolicy("/private/tmp/evil.sh", "exec", denyConfig)).toBe("deny"); }); @@ -320,7 +344,7 @@ describe.skipIf(process.platform !== "darwin")( it("/private/tmp/** deny rule blocks /tmp/** target", () => { // Rule written with /private/tmp must still match the normalized /tmp target. const denyConfig: AccessPolicyConfig = { - rules: { "/**": "rwx", "/private/tmp/**": "---" }, + policy: { "/**": "rwx", "/private/tmp/**": "---" }, }; expect(checkAccessPolicy("/tmp/evil.sh", "read", denyConfig)).toBe("deny"); }); @@ -328,7 +352,7 @@ describe.skipIf(process.platform !== "darwin")( it("/private/tmp/** rule matches /tmp/** target", () => { // Rule written with /private/* prefix must match a /tmp/* target path. const cfg: AccessPolicyConfig = { - rules: { "/private/tmp/**": "rwx" }, + policy: { "/private/tmp/**": "rwx" }, }; expect(checkAccessPolicy("/tmp/foo.txt", "write", cfg)).toBe("allow"); }); @@ -394,7 +418,7 @@ describe("findBestRule", () => { describe('checkAccessPolicy — "---" rules act as deny', () => { it('"---" rule blocks all ops, even when a broader rule would allow', () => { const config: AccessPolicyConfig = { - rules: { "/**": "rwx", [`${HOME}/.ssh/**`]: "---" }, + policy: { "/**": "rwx", [`${HOME}/.ssh/**`]: "---" }, }; expect(checkAccessPolicy(`${HOME}/.ssh/id_rsa`, "read", config)).toBe("deny"); expect(checkAccessPolicy(`${HOME}/.ssh/id_rsa`, "write", config)).toBe("deny"); @@ -405,7 +429,7 @@ describe('checkAccessPolicy — "---" rules act as deny', () => { '"---" rule does not affect paths outside its glob', () => { const config: AccessPolicyConfig = { - rules: { "/**": "rwx", [`${HOME}/.ssh/**`]: "---" }, + policy: { "/**": "rwx", [`${HOME}/.ssh/**`]: "---" }, }; expect(checkAccessPolicy(`${HOME}/workspace/foo.py`, "read", config)).toBe("allow"); }, @@ -413,7 +437,7 @@ describe('checkAccessPolicy — "---" rules act as deny', () => { it("multiple narrowing rules block distinct subtrees", () => { const config: AccessPolicyConfig = { - rules: { "/**": "rwx", [`${HOME}/.ssh/**`]: "---", [`${HOME}/.gnupg/**`]: "---" }, + policy: { "/**": "rwx", [`${HOME}/.ssh/**`]: "---", [`${HOME}/.gnupg/**`]: "---" }, }; expect(checkAccessPolicy(`${HOME}/.gnupg/secring.gpg`, "read", config)).toBe("deny"); }); @@ -425,28 +449,28 @@ describe('checkAccessPolicy — "---" rules act as deny', () => { describe("checkAccessPolicy — rules", () => { it("allows read when r bit is set", () => { - const config: AccessPolicyConfig = { rules: { "/**": "r--" } }; + const config: AccessPolicyConfig = { policy: { "/**": "r--" } }; expect(checkAccessPolicy("/etc/passwd", "read", config)).toBe("allow"); }); it("denies write when w bit is absent", () => { - const config: AccessPolicyConfig = { rules: { "/**": "r--" } }; + const config: AccessPolicyConfig = { policy: { "/**": "r--" } }; expect(checkAccessPolicy("/etc/passwd", "write", config)).toBe("deny"); }); it("denies exec when x bit is absent", () => { - const config: AccessPolicyConfig = { rules: { "/usr/bin/**": "r--" } }; + const config: AccessPolicyConfig = { policy: { "/usr/bin/**": "r--" } }; expect(checkAccessPolicy("/usr/bin/grep", "exec", config)).toBe("deny"); }); it("allows exec when x bit is set", () => { - const config: AccessPolicyConfig = { rules: { "/usr/bin/**": "r-x" } }; + const config: AccessPolicyConfig = { policy: { "/usr/bin/**": "r-x" } }; expect(checkAccessPolicy("/usr/bin/grep", "exec", config)).toBe("allow"); }); it("longer rule overrides shorter for the same path", () => { const config: AccessPolicyConfig = { - rules: { + policy: { "/**": "r--", [`${HOME}/**`]: "rwx", }, @@ -459,7 +483,7 @@ describe("checkAccessPolicy — rules", () => { it("specific sub-path rule can restrict a broader allow", () => { const config: AccessPolicyConfig = { - rules: { + policy: { [`${HOME}/**`]: "rwx", [`${HOME}/.config/**`]: "r--", }, @@ -472,7 +496,7 @@ describe("checkAccessPolicy — rules", () => { // Without the expanded-length fix, "~/.ssh/**" (9 raw chars) would lose to // `${HOME}/**` when HOME is long, letting rwx override the intended --- deny. const config: AccessPolicyConfig = { - rules: { + policy: { [`${HOME}/**`]: "rwx", "~/.ssh/**": "---", }, @@ -489,7 +513,7 @@ describe("checkAccessPolicy — rules", () => { describe("checkAccessPolicy — implicit fallback to ---", () => { it("denies all ops when no rule matches (implicit --- fallback)", () => { const config: AccessPolicyConfig = { - rules: { [`${HOME}/**`]: "rwx" }, + policy: { [`${HOME}/**`]: "rwx" }, }; expect(checkAccessPolicy("/etc/passwd", "read", config)).toBe("deny"); expect(checkAccessPolicy("/etc/passwd", "write", config)).toBe("deny"); @@ -498,7 +522,7 @@ describe("checkAccessPolicy — implicit fallback to ---", () => { it('"/**" rule acts as catch-all for unmatched paths', () => { const config: AccessPolicyConfig = { - rules: { [`${HOME}/**`]: "rwx", "/**": "r--" }, + policy: { [`${HOME}/**`]: "rwx", "/**": "r--" }, }; expect(checkAccessPolicy("/etc/passwd", "read", config)).toBe("allow"); expect(checkAccessPolicy("/etc/passwd", "write", config)).toBe("deny"); @@ -506,7 +530,7 @@ describe("checkAccessPolicy — implicit fallback to ---", () => { it("empty rules deny everything via implicit fallback", () => { const config: AccessPolicyConfig = { - rules: { [`${HOME}/workspace/**`]: "rw-" }, + policy: { [`${HOME}/workspace/**`]: "rw-" }, }; expect(checkAccessPolicy("/tmp/foo", "read", config)).toBe("deny"); expect(checkAccessPolicy("/tmp/foo", "write", config)).toBe("deny"); @@ -521,7 +545,7 @@ describe("checkAccessPolicy — implicit fallback to ---", () => { describe("checkAccessPolicy — precedence integration", () => { it("narrowing rule beats broader allow — all in play", () => { const config: AccessPolicyConfig = { - rules: { + policy: { "/**": "r--", [`${HOME}/**`]: "rwx", [`${HOME}/.ssh/**`]: "---", @@ -559,7 +583,7 @@ describe("checkAccessPolicy — symlink resolved-path scenarios", () => { // ~/workspace/link → ~/.ssh/id_rsa (symlink in allowed dir to denied-subpath) // Caller passes the resolved path; the "---" rule wins. const config: AccessPolicyConfig = { - rules: { [`${HOME}/workspace/**`]: "rw-", [`${HOME}/.ssh/**`]: "---" }, + policy: { [`${HOME}/workspace/**`]: "rw-", [`${HOME}/.ssh/**`]: "---" }, }; expect(checkAccessPolicy(`${HOME}/.ssh/id_rsa`, "read", config, HOME)).toBe("deny"); }); @@ -568,7 +592,7 @@ describe("checkAccessPolicy — symlink resolved-path scenarios", () => { // ~/workspace/link → ~/workspace/secret/file // workspace is rw-, but the secret subdir is r--. Resolved path hits r--. const config: AccessPolicyConfig = { - rules: { + policy: { [`${HOME}/workspace/**`]: "rw-", [`${HOME}/workspace/secret/**`]: "r--", }, @@ -585,7 +609,7 @@ describe("checkAccessPolicy — symlink resolved-path scenarios", () => { it("symlink source path in allowed dir is allowed; resolved denied target is denied", () => { // This illustrates that the policy must be checked on the resolved path. const config: AccessPolicyConfig = { - rules: { [`${HOME}/workspace/**`]: "rw-", [`${HOME}/.aws/**`]: "---" }, + policy: { [`${HOME}/workspace/**`]: "rw-", [`${HOME}/.aws/**`]: "---" }, }; // Source path (the symlink) — allowed expect(checkAccessPolicy(`${HOME}/workspace/creds`, "read", config, HOME)).toBe("allow"); @@ -811,6 +835,16 @@ describe("resolveArgv0", () => { expect(result).toMatch(/sh$/); }); + itUnix("strips quoted argv0 with internal spaces when looking through env", () => { + // `"/usr/bin env" /bin/sh` — argv0 contains a space inside quotes. + // The bare /^\S+\s*/ regex stopped at the first space, leaving a corrupted afterEnv. + // The fix strips the full quoted token before processing env options/args. + // We use a path we know exists so realpathSync succeeds. + const result = resolveArgv0('"/usr/bin/env" /bin/sh -c echo'); + expect(result).not.toBeNull(); + expect(result).toMatch(/sh$/); + }); + it("returns null for deeply nested env -S to prevent stack overflow", () => { // Build a deeply nested "env -S 'env -S ...' " string beyond the depth cap (8). let cmd = "/bin/sh"; @@ -857,7 +891,7 @@ describe("resolveScriptKey", () => { describe("applyScriptPolicyOverride", () => { it("returns base policy unchanged when no scripts block", () => { - const base: AccessPolicyConfig = { rules: { "/**": "r--" } }; + const base: AccessPolicyConfig = { policy: { "/**": "r--" } }; const { policy, hashMismatch } = applyScriptPolicyOverride(base, "/any/path"); expect(hashMismatch).toBeUndefined(); expect(policy).toBe(base); @@ -865,8 +899,8 @@ describe("applyScriptPolicyOverride", () => { it("returns base policy unchanged when argv0 not in scripts", () => { const base: AccessPolicyConfig = { - rules: { "/**": "r--" }, - scripts: { "/other/script.sh": { rules: { "/tmp/**": "rwx" } } }, + policy: { "/**": "r--" }, + scripts: { "/other/script.sh": { policy: { "/tmp/**": "rwx" } } }, }; const { policy, hashMismatch } = applyScriptPolicyOverride(base, "/my/script.sh"); expect(hashMismatch).toBeUndefined(); @@ -885,9 +919,9 @@ describe("applyScriptPolicyOverride", () => { try { const resolvedReal = fs.realpathSync(symlinkScript); const base: AccessPolicyConfig = { - rules: { "/**": "r--" }, + policy: { "/**": "r--" }, // Key is the symlink path; resolvedArgv0 will be the real path. - scripts: { [symlinkScript]: { rules: { "/tmp/**": "rwx" } } }, + scripts: { [symlinkScript]: { policy: { "/tmp/**": "rwx" } } }, }; const { overrideRules, hashMismatch } = applyScriptPolicyOverride(base, resolvedReal); expect(hashMismatch).toBeUndefined(); @@ -900,8 +934,8 @@ describe("applyScriptPolicyOverride", () => { it("returns override rules separately so seatbelt emits them after base rules", () => { const base: AccessPolicyConfig = { - rules: { "/**": "r--" }, - scripts: { "/my/script.sh": { rules: { [`${HOME}/.openclaw/credentials/`]: "r--" } } }, + policy: { "/**": "r--" }, + scripts: { "/my/script.sh": { policy: { [`${HOME}/.openclaw/credentials/`]: "r--" } } }, }; const { policy, overrideRules, hashMismatch } = applyScriptPolicyOverride( base, @@ -909,8 +943,8 @@ describe("applyScriptPolicyOverride", () => { ); expect(hashMismatch).toBeUndefined(); // Base rules unchanged in policy - expect(policy.rules?.["/**"]).toBe("r--"); - expect(policy.rules?.[`${HOME}/.openclaw/credentials/`]).toBeUndefined(); + expect(policy.policy?.["/**"]).toBe("r--"); + expect(policy.policy?.[`${HOME}/.openclaw/credentials/`]).toBeUndefined(); // Override rules returned separately — caller emits them last in seatbelt profile expect(overrideRules?.[`${HOME}/.openclaw/credentials/`]).toBe("r--"); expect(policy.scripts).toBeUndefined(); @@ -918,21 +952,21 @@ describe("applyScriptPolicyOverride", () => { it("override rules returned separately — base policy rule unchanged", () => { const base: AccessPolicyConfig = { - rules: { [`${HOME}/workspace/**`]: "r--" }, - scripts: { "/trusted.sh": { rules: { [`${HOME}/workspace/**`]: "rwx" } } }, + policy: { [`${HOME}/workspace/**`]: "r--" }, + scripts: { "/trusted.sh": { policy: { [`${HOME}/workspace/**`]: "rwx" } } }, }; const { policy, overrideRules } = applyScriptPolicyOverride(base, "/trusted.sh"); - expect(policy.rules?.[`${HOME}/workspace/**`]).toBe("r--"); + expect(policy.policy?.[`${HOME}/workspace/**`]).toBe("r--"); expect(overrideRules?.[`${HOME}/workspace/**`]).toBe("rwx"); }); it("narrowing override returned separately", () => { const base: AccessPolicyConfig = { - rules: { "/tmp/**": "rwx" }, - scripts: { "/cautious.sh": { rules: { "/tmp/**": "r--" } } }, + policy: { "/tmp/**": "rwx" }, + scripts: { "/cautious.sh": { policy: { "/tmp/**": "r--" } } }, }; const { policy, overrideRules } = applyScriptPolicyOverride(base, "/cautious.sh"); - expect(policy.rules?.["/tmp/**"]).toBe("rwx"); + expect(policy.policy?.["/tmp/**"]).toBe("rwx"); expect(overrideRules?.["/tmp/**"]).toBe("r--"); }); @@ -947,7 +981,7 @@ describe("applyScriptPolicyOverride", () => { try { const base: AccessPolicyConfig = { scripts: { - [scriptPath]: { sha256: "deadbeef".padEnd(64, "0"), rules: { "/tmp/**": "rwx" } }, + [scriptPath]: { sha256: "deadbeef".padEnd(64, "0"), policy: { "/tmp/**": "rwx" } }, }, }; const { policy, hashMismatch } = applyScriptPolicyOverride(base, realScriptPath); @@ -963,8 +997,8 @@ describe("applyScriptPolicyOverride", () => { // A direct object lookup misses tilde keys; ~ must be expanded before comparing. const absPath = path.join(os.homedir(), "bin", "deploy.sh"); const base: AccessPolicyConfig = { - rules: { "/**": "rwx" }, - scripts: { "~/bin/deploy.sh": { rules: { "/secret/**": "---" } } }, + policy: { "/**": "rwx" }, + scripts: { "~/bin/deploy.sh": { policy: { "/secret/**": "---" } } }, }; const { overrideRules, hashMismatch } = applyScriptPolicyOverride(base, absPath); expect(hashMismatch).toBeUndefined(); @@ -980,8 +1014,8 @@ describe("applyScriptPolicyOverride", () => { const realScriptPath = fs.realpathSync(scriptPath); try { const base: AccessPolicyConfig = { - rules: { "/**": "r--" }, - scripts: { [scriptPath]: { sha256: hash, rules: { "/tmp/**": "rwx" } } }, + policy: { "/**": "r--" }, + scripts: { [scriptPath]: { sha256: hash, policy: { "/tmp/**": "rwx" } } }, }; const { policy, overrideRules, hashMismatch } = applyScriptPolicyOverride( base, @@ -989,7 +1023,7 @@ describe("applyScriptPolicyOverride", () => { ); expect(hashMismatch).toBeUndefined(); expect(overrideRules?.["/tmp/**"]).toBe("rwx"); - expect(policy.rules?.["/tmp/**"]).toBeUndefined(); + expect(policy.policy?.["/tmp/**"]).toBeUndefined(); expect(policy.scripts).toBeUndefined(); } finally { fs.rmSync(tmpDir, { recursive: true }); diff --git a/src/infra/access-policy.ts b/src/infra/access-policy.ts index e4ffce84ac8..419fce3d89f 100644 --- a/src/infra/access-policy.ts +++ b/src/infra/access-policy.ts @@ -40,83 +40,114 @@ function hasMidPathWildcard(pattern: string): boolean { 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, + 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.rules in place (e.g. auto-expanding bare directory paths). + * May mutate config.policy in place (e.g. auto-expanding bare directory paths). */ export function validateAccessPolicyConfig(config: AccessPolicyConfig): string[] { const errors: string[] = []; - if (config.rules) { - for (const [pattern, perm] of Object.entries(config.rules)) { + if (config.policy) { + for (const [pattern, perm] of Object.entries(config.policy)) { if (!pattern) { - errors.push("access-policy.rules: rule key must be a non-empty glob 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.rules["${pattern}"] "${perm}" is invalid: must be exactly 3 chars (e.g. "rwx", "r--", "---")`, + `access-policy.policy["${pattern}"] "${perm}" is invalid: must be exactly 3 chars (e.g. "rwx", "r--", "---")`, ); } - if (hasMidPathWildcard(pattern) && !_midPathWildcardWarned.has(`rules:${pattern}`)) { - _midPathWildcardWarned.add(`rules:${pattern}`); + 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.rules["${pattern}"] contains a mid-path wildcard with "---" — OS-level (bwrap/Seatbelt) enforcement cannot apply; tool-layer enforcement is still active.`, + `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.rules["${pattern}"] contains a mid-path wildcard — OS-level enforcement uses prefix match (file-type filter is tool-layer only).`, + `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. - // Any stat failure means the agent faces the same error (ENOENT/EACCES), - // so the rule is a no-op and no action is needed. - if (pattern && !pattern.endsWith("/") && !/[*?[]/.test(pattern)) { - 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 config.rules)) { - config.rules[fixed] = perm; - } - delete config.rules[pattern]; - if (!_autoExpandedWarned.has(pattern)) { - _autoExpandedWarned.add(pattern); - errors.push( - `access-policy.rules["${pattern}"] is a directory — rule auto-expanded to "${fixed}" so it covers all contents.`, - ); - } - } - } catch { - // Path inaccessible or missing — no action needed. - } - } + autoExpandBareDir(config.policy, pattern, perm, errors); } } if (config.scripts) { + // scripts["policy"] is a shared Record — 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--", "---")`, + ); + } + autoExpandBareDir(sharedPolicy, pattern, perm, errors); + } + } for (const [scriptPath, entry] of Object.entries(config.scripts)) { - if (entry.rules) { - for (const [pattern, perm] of Object.entries(entry.rules)) { + if (scriptPath === "policy") { + continue; // handled above + } + const scriptEntry = entry as import("../config/types.tools.js").ScriptPolicyEntry | undefined; + if (scriptEntry?.policy) { + for (const [pattern, perm] of Object.entries(scriptEntry.policy)) { if (!PERM_STR_RE.test(perm)) { errors.push( - `access-policy.scripts["${scriptPath}"].rules["${pattern}"] "${perm}" is invalid: must be exactly 3 chars (e.g. "rwx", "r--", "---")`, + `access-policy.scripts["${scriptPath}"].policy["${pattern}"] "${perm}" is invalid: must be exactly 3 chars (e.g. "rwx", "r--", "---")`, ); } + autoExpandBareDir(scriptEntry.policy, pattern, perm, errors); } } } @@ -272,7 +303,7 @@ export function checkAccessPolicy( // 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.rules ?? {})) { + 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) { @@ -425,7 +456,16 @@ export function resolveArgv0(command: string, cwd?: string, _depth = 0): string // (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. - let afterEnv = commandRest.replace(/^\S+\s*/, ""); + // 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 @@ -528,9 +568,10 @@ export function applyScriptPolicyOverride( // normalized the same way or the lookup silently misses, skipping sha256 verification. const scripts = policy.scripts; const override = scripts - ? Object.entries(scripts).find( - ([k]) => path.normalize(resolveScriptKey(k)) === path.normalize(resolvedArgv0), - )?.[1] + ? (Object.entries(scripts).find( + ([k]) => + k !== "policy" && path.normalize(resolveScriptKey(k)) === path.normalize(resolvedArgv0), + )?.[1] as import("../config/types.tools.js").ScriptPolicyEntry | undefined) : undefined; if (!override) { return { policy }; @@ -572,6 +613,6 @@ export function applyScriptPolicyOverride( return { policy: merged, overrideRules: - override.rules && Object.keys(override.rules).length > 0 ? override.rules : undefined, + override.policy && Object.keys(override.policy).length > 0 ? override.policy : undefined, }; } diff --git a/src/infra/exec-sandbox-bwrap.test.ts b/src/infra/exec-sandbox-bwrap.test.ts index 0d1d173b77c..6486ba0e123 100644 --- a/src/infra/exec-sandbox-bwrap.test.ts +++ b/src/infra/exec-sandbox-bwrap.test.ts @@ -15,7 +15,7 @@ const HOME = os.homedir(); // 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 = { rules: { "/**": "r--" } }; + const config: AccessPolicyConfig = { policy: { "/**": "r--" } }; const args = generateBwrapArgs(config, HOME); expect(args.slice(0, 3)).toEqual(["--ro-bind", "/", "/"]); }); @@ -31,14 +31,14 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { }); it("ends with --", () => { - const config: AccessPolicyConfig = { rules: { "/**": "r--" } }; + 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 = { - rules: { "/**": "r--", [`${HOME}/.ssh/**`]: "---", [`${HOME}/.gnupg/**`]: "---" }, + 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); @@ -48,7 +48,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { it('expands ~ in "---" rules using homeDir', () => { const config: AccessPolicyConfig = { - rules: { "/**": "r--", "~/.ssh/**": "---" }, + policy: { "/**": "r--", "~/.ssh/**": "---" }, }; const args = generateBwrapArgs(config, HOME); const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean); @@ -57,7 +57,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { it("adds --bind for paths with w bit in rules", () => { const config: AccessPolicyConfig = { - rules: { "/**": "r--", [`${HOME}/workspace/**`]: "rw-" }, + policy: { "/**": "r--", [`${HOME}/workspace/**`]: "rw-" }, }; const args = generateBwrapArgs(config, HOME); const bindPairs: string[] = []; @@ -71,7 +71,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { it("does not add --bind for read-only rules on permissive base", () => { const config: AccessPolicyConfig = { - rules: { "/**": "r--", "/usr/bin/**": "r--" }, + 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 /) @@ -86,7 +86,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { it('"---" rule for sensitive path appears in args regardless of broader rule', () => { const config: AccessPolicyConfig = { - rules: { "/**": "r--", [`${HOME}/**`]: "rwx", [`${HOME}/.ssh/**`]: "---" }, + 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); @@ -100,14 +100,14 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { 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({ rules: { "/**": "r--" } }, HOME); + 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 = { rules: { "/**": "r--" } }; + 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"); @@ -124,7 +124,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { 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 = { rules: { "/**": "r--", "/tmp/**": "r--" } }; + 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"); @@ -135,7 +135,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { // 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 = { rules: { "/**": "r--", "/tmp/**": "rw-" } }; + 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 @@ -147,7 +147,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { 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 = { rules: { [`${HOME}/workspace/**`]: "rwx" } }; + 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"); @@ -157,7 +157,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { // With "/**":"r--", --ro-bind / / makes everything readable. A narrowing // rule like "/secret/**": "---" must overlay --tmpfs so the path is hidden. const config: AccessPolicyConfig = { - rules: { "/**": "r--", [`${HOME}/secret/**`]: "---" }, + policy: { "/**": "r--", [`${HOME}/secret/**`]: "---" }, }; const args = generateBwrapArgs(config, HOME); const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean); @@ -173,7 +173,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { // process.execPath is always an existing file — use it as the test target. const filePath = process.execPath; const config: AccessPolicyConfig = { - rules: { "/**": "r--", [filePath]: "---" }, + policy: { "/**": "r--", [filePath]: "---" }, }; const args = generateBwrapArgs(config, HOME); const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean); @@ -184,7 +184,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { 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 = { - rules: { "/**": "r--", [`${HOME}/scripts/**`]: "--x" }, + policy: { "/**": "r--", [`${HOME}/scripts/**`]: "--x" }, }; const args = generateBwrapArgs(config, HOME); const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean); @@ -197,7 +197,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { // for a "---" rule would silently grant read access to paths that should // be fully blocked. const config: AccessPolicyConfig = { - rules: { + policy: { [`${HOME}/workspace/**`]: "rwx", // allowed: should produce --bind-try [`${HOME}/workspace/private/**`]: "---", // denied: must NOT produce any mount }, @@ -216,7 +216,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { 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 = { - rules: { [`${HOME}/scripts/**`]: "--x" }, + policy: { [`${HOME}/scripts/**`]: "--x" }, }; const args = generateBwrapArgs(config, HOME); const roBound = args @@ -231,7 +231,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { // 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 = { - rules: { [`${HOME}/logs/**`]: "-w-" }, + policy: { [`${HOME}/logs/**`]: "-w-" }, }; const args = generateBwrapArgs(config, HOME); const bindMounts = args @@ -245,7 +245,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { // A "-w-" rule upgrades to rw for that path — reads are not newly leaked // since the base already allowed them. const config: AccessPolicyConfig = { - rules: { "/**": "r--", [`${HOME}/output/**`]: "-w-" }, + policy: { "/**": "r--", [`${HOME}/output/**`]: "-w-" }, }; const args = generateBwrapArgs(config, HOME); const bindMounts = args @@ -259,7 +259,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { // would hide the entire home directory. Must be skipped. const fakeHome = "/home/testuser"; const config: AccessPolicyConfig = { - rules: { "/**": "r--", "/home/*/.config/**": "---" }, + policy: { "/**": "r--", "/home/*/.config/**": "---" }, }; const args = generateBwrapArgs(config, fakeHome); const allMountTargets = args @@ -275,7 +275,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { // 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 = { - rules: { "/scripts/**/*.sh": "r-x" }, + policy: { "/scripts/**/*.sh": "r-x" }, }; const args = generateBwrapArgs(config, "/home/user"); const allMountTargets = args @@ -290,7 +290,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { // "/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 = { - rules: { "/**": "r--", "/var/log/secret*": "---" }, + policy: { "/**": "r--", "/var/log/secret*": "---" }, }; const args = generateBwrapArgs(config, HOME); const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean); @@ -304,7 +304,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { const config: AccessPolicyConfig = { // Deliberately insert secret first so Object.entries() would yield it first // without sorting — proving the sort is what fixes the order. - rules: { + policy: { [`${HOME}/dev/secret/**`]: "r--", [`${HOME}/dev/**`]: "rw-", }, @@ -330,7 +330,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { // 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 = { - rules: { [`${HOME}/workspace/**`]: "rwx" }, + policy: { [`${HOME}/workspace/**`]: "rwx" }, }; const overrides = { [`${HOME}/logs/**`]: "-w-" as const }; const args = generateBwrapArgs(config, HOME, overrides); @@ -345,7 +345,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { 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 = { rules: { "/**": "r--", "/etc/hosts/**": "---" } }; + 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"); @@ -365,7 +365,7 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { it('still emits --tmpfs for "---" rule that resolves to a directory', () => { // Non-existent paths are treated as directories (forward-protection). const config: AccessPolicyConfig = { - rules: { "/**": "r--", [`${HOME}/.nonexistent-dir/**`]: "---" }, + policy: { "/**": "r--", [`${HOME}/.nonexistent-dir/**`]: "---" }, }; const args = generateBwrapArgs(config, HOME); const tmpfsMounts = args.map((a, i) => (a === "--tmpfs" ? args[i + 1] : null)).filter(Boolean); @@ -375,8 +375,8 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { 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({ rules: { "/tmp/": "rw-" } }, HOME); - const withGlob = generateBwrapArgs({ rules: { "/tmp/**": "rw-" } }, HOME); + 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"); @@ -386,17 +386,17 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { describe("wrapCommandWithBwrap", () => { it("starts with bwrap", () => { - const result = wrapCommandWithBwrap("ls /tmp", { rules: { "/**": "r--" } }, HOME); + const result = wrapCommandWithBwrap("ls /tmp", { policy: { "/**": "r--" } }, HOME); expect(result).toMatch(/^bwrap /); }); it("contains -- separator before the command", () => { - const result = wrapCommandWithBwrap("ls /tmp", { rules: { "/**": "r--" } }, HOME); + 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", { rules: { "/**": "r--" } }, HOME); + const result = wrapCommandWithBwrap("cat /etc/hosts", { policy: { "/**": "r--" } }, HOME); expect(result).toContain("/bin/sh -c"); expect(result).toContain("cat /etc/hosts"); }); diff --git a/src/infra/exec-sandbox-bwrap.ts b/src/infra/exec-sandbox-bwrap.ts index bb87783d7dd..6d5e49887d3 100644 --- a/src/infra/exec-sandbox-bwrap.ts +++ b/src/infra/exec-sandbox-bwrap.ts @@ -143,7 +143,7 @@ export function generateBwrapArgs( ): string[] { const args: string[] = []; // Determine base stance from the "/**" catch-all rule (replaces the removed `default` field). - const catchAllPerm = findBestRule("/**", config.rules ?? {}, homeDir) ?? "---"; + const catchAllPerm = findBestRule("/**", config.policy ?? {}, homeDir) ?? "---"; const defaultAllowsRead = catchAllPerm[0] === "r"; if (defaultAllowsRead) { @@ -174,7 +174,7 @@ export function generateBwrapArgs( // 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.rules ?? {}, homeDir); + 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 @@ -190,7 +190,7 @@ export function generateBwrapArgs( // 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.rules ?? {}).toSorted(([a], [b]) => { + 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); diff --git a/src/infra/exec-sandbox-seatbelt.test.ts b/src/infra/exec-sandbox-seatbelt.test.ts index 02bd5299784..a3202abdc93 100644 --- a/src/infra/exec-sandbox-seatbelt.test.ts +++ b/src/infra/exec-sandbox-seatbelt.test.ts @@ -23,7 +23,7 @@ describe("generateSeatbeltProfile", () => { }); it("uses (allow default) when /** rule has any permission", () => { - const profile = generateSeatbeltProfile({ rules: { "/**": "r--" } }, HOME); + const profile = generateSeatbeltProfile({ policy: { "/**": "r--" } }, HOME); expect(profile).toContain("(allow default)"); expect(profile).not.toContain("(deny default)"); }); @@ -37,7 +37,7 @@ describe("generateSeatbeltProfile", () => { skipOnWindows("--- rule emits deny file-read*, file-write*, process-exec* for that path", () => { const config: AccessPolicyConfig = { - rules: { "/**": "rwx", [`${HOME}/.ssh/**`]: "---" }, + policy: { "/**": "rwx", [`${HOME}/.ssh/**`]: "---" }, }; const profile = generateSeatbeltProfile(config, HOME); expect(profile).toContain(`(deny file-read*`); @@ -48,7 +48,7 @@ describe("generateSeatbeltProfile", () => { skipOnWindows("expands ~ in --- rules using provided homeDir", () => { const config: AccessPolicyConfig = { - rules: { "/**": "rwx", "~/.ssh/**": "---" }, + policy: { "/**": "rwx", "~/.ssh/**": "---" }, }; const profile = generateSeatbeltProfile(config, HOME); expect(profile).toContain(HOME + "/.ssh"); @@ -57,7 +57,7 @@ describe("generateSeatbeltProfile", () => { skipOnWindows("expands ~ in rules using provided homeDir", () => { const config: AccessPolicyConfig = { - rules: { "~/**": "rw-" }, + policy: { "~/**": "rw-" }, }; const profile = generateSeatbeltProfile(config, HOME); expect(profile).toContain(HOME); @@ -65,7 +65,7 @@ describe("generateSeatbeltProfile", () => { it("rw- rule emits allow read+write, deny exec for that path", () => { const config: AccessPolicyConfig = { - rules: { [`${HOME}/workspace/**`]: "rw-" }, + policy: { [`${HOME}/workspace/**`]: "rw-" }, }; const profile = generateSeatbeltProfile(config, HOME); expect(profile).toContain(`(allow file-read*`); @@ -75,7 +75,7 @@ describe("generateSeatbeltProfile", () => { it("r-x rule emits allow read+exec, deny write for that path", () => { const config: AccessPolicyConfig = { - rules: { "/usr/bin/**": "r-x" }, + policy: { "/usr/bin/**": "r-x" }, }; const profile = generateSeatbeltProfile(config, HOME); const rulesSection = profile.split("; User-defined path rules")[1] ?? ""; @@ -87,7 +87,7 @@ describe("generateSeatbeltProfile", () => { 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 = { - rules: { [`${HOME}/**`]: "rwx", [`${HOME}/.ssh/**`]: "---" }, + policy: { [`${HOME}/**`]: "rwx", [`${HOME}/.ssh/**`]: "---" }, }; const profile = generateSeatbeltProfile(config, HOME); const rulesIdx = profile.indexOf("; User-defined path rules"); @@ -106,7 +106,7 @@ describe("generateSeatbeltProfile", () => { 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({ rules: { "/**": "r--" } }, HOME); + const profile = generateSeatbeltProfile({ policy: { "/**": "r--" } }, HOME); expect(profile).toContain("(allow process-exec*"); expect(profile).toContain("/bin"); expect(profile).toContain("/usr/bin"); @@ -114,7 +114,7 @@ describe("generateSeatbeltProfile", () => { 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({ rules: { "/**": "rwx" } }, HOME); + const profile = generateSeatbeltProfile({ policy: { "/**": "rwx" } }, HOME); expect(profile).toContain("(allow default)"); expect(profile).not.toContain("System baseline exec"); }); @@ -123,7 +123,7 @@ describe("generateSeatbeltProfile", () => { // 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 = { - rules: { [`${HOME}/workspace/**`]: "rw-" }, + policy: { [`${HOME}/workspace/**`]: "rw-" }, }; const overrideRules: Record = { [`${HOME}/workspace/locked/**`]: "r--" }; const profile = generateSeatbeltProfile(config, HOME, overrideRules); @@ -139,31 +139,31 @@ describe("generateSeatbeltProfile", () => { }); it("includes /private/tmp baseline when a rule grants read access to /tmp", () => { - const profile = generateSeatbeltProfile({ rules: { "/tmp/**": "rw-" } }, HOME); + 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({ rules: { "/tmp/**": "r--" } }, HOME); + 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({ rules: { "/tmp/**": "-w-" } }, HOME); + 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({ rules: { "/tmp/**": "--x" } }, HOME); + 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({ rules: { "/tmp/**": "r-x" } }, HOME); + 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"\)/); @@ -185,7 +185,7 @@ describe("generateSeatbeltProfile", () => { // 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 = { - rules: { + policy: { [`${HOME}/workspace/**`]: "rw-", [`${HOME}/.ssh/**`]: "---", }, @@ -204,7 +204,7 @@ describe("generateSeatbeltProfile", () => { "restrictive rule on subdir appears after broader rw rule — covers symlink to restricted subtree", () => { const config: AccessPolicyConfig = { - rules: { + policy: { [`${HOME}/workspace/**`]: "rw-", [`${HOME}/workspace/secret/**`]: "r--", }, @@ -220,7 +220,7 @@ describe("generateSeatbeltProfile", () => { it("glob patterns are stripped to their longest concrete prefix", () => { const config: AccessPolicyConfig = { - rules: { "/Users/kaveri/.ssh/**": "---", "/**": "rwx" }, + policy: { "/Users/kaveri/.ssh/**": "---", "/**": "rwx" }, }; const profile = generateSeatbeltProfile(config, "/Users/kaveri"); expect(profile).not.toContain("**"); @@ -277,7 +277,7 @@ describe("generateSeatbeltProfile — mid-path wildcard guard", () => { // 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({ rules: { "/home/*/workspace/**": "r-x" } }, HOME); + 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")'); @@ -289,12 +289,12 @@ describe("generateSeatbeltProfile — mid-path wildcard guard", () => { 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({ rules: { "/home/*/workspace/**": "---" } }, HOME); + 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({ rules: { "/tmp/**": "rwx" } }, HOME); + const profile = generateSeatbeltProfile({ policy: { "/tmp/**": "rwx" } }, HOME); expect(profile).toContain('(subpath "/tmp")'); }); @@ -302,7 +302,7 @@ describe("generateSeatbeltProfile — mid-path wildcard guard", () => { // 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({ rules: { "/tmp/file?.txt": "r--" } }, HOME); + 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")'); }); diff --git a/src/infra/exec-sandbox-seatbelt.ts b/src/infra/exec-sandbox-seatbelt.ts index fd4cc94792d..c48ce7f53b7 100644 --- a/src/infra/exec-sandbox-seatbelt.ts +++ b/src/infra/exec-sandbox-seatbelt.ts @@ -184,7 +184,7 @@ export function generateSeatbeltProfile( lines.push(""); // Determine base stance from the "/**" catch-all rule (replaces the removed `default` field). - const catchAllPerm = findBestRule("/**", config.rules ?? {}, homeDir) ?? "---"; + const catchAllPerm = findBestRule("/**", config.policy ?? {}, homeDir) ?? "---"; const defaultPerm = catchAllPerm; // alias for readability below const defaultAllowsAnything = catchAllPerm[0] === "r" || catchAllPerm[1] === "w" || catchAllPerm[2] === "x"; @@ -223,7 +223,7 @@ export function generateSeatbeltProfile( // 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 tmpPerm = findBestRule("/tmp", config.rules ?? {}, homeDir) ?? "---"; + const tmpPerm = findBestRule("/tmp", config.policy ?? {}, homeDir) ?? "---"; // 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") { @@ -252,7 +252,7 @@ export function generateSeatbeltProfile( // 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.rules ?? {}).toSorted( + const ruleEntries = Object.entries(config.policy ?? {}).toSorted( ([a], [b]) => expandTilde(a).length - expandTilde(b).length, ); From eb40d49d447c83863a1ac921d8fc67dd2aecbb0c Mon Sep 17 00:00:00 2001 From: subrih Date: Sat, 14 Mar 2026 08:43:36 -0700 Subject: [PATCH 26/29] fix(access-policy): merge scripts[policy], isDir guard in bwrap overrides, permAllows format check, script entry shape check --- src/infra/access-policy.test.ts | 70 ++++++++++++++++++++++++++++ src/infra/access-policy.ts | 30 +++++++++--- src/infra/exec-sandbox-bwrap.test.ts | 26 +++++++++++ src/infra/exec-sandbox-bwrap.ts | 13 +++++- 4 files changed, 132 insertions(+), 7 deletions(-) diff --git a/src/infra/access-policy.test.ts b/src/infra/access-policy.test.ts index e72b6c4918b..7caa1778ab2 100644 --- a/src/infra/access-policy.test.ts +++ b/src/infra/access-policy.test.ts @@ -243,6 +243,18 @@ describe("checkAccessPolicy — malformed permission characters fail closed", () const config = { policy: { "/tmp/**": "rWx" as unknown as "rwx" } }; expect(checkAccessPolicy("/tmp/foo.txt", "write", config)).toBe("deny"); }); + + it("treats 'aw-' (invalid first char) as deny for write even though index 1 is 'w'", () => { + // defense-in-depth: full format must be [r-][w-][x-]; 'a' at index 0 fails the regex + // so the entire string is rejected rather than accidentally granting write. + const config = { policy: { "/tmp/**": "aw-" as unknown as "r--" } }; + expect(checkAccessPolicy("/tmp/foo.txt", "write", config)).toBe("deny"); + }); + + it("treats a 4-char string as deny (wrong length)", () => { + const config = { policy: { "/tmp/**": "rwx!" as unknown as "rwx" } }; + expect(checkAccessPolicy("/tmp/foo.txt", "exec", config)).toBe("deny"); + }); }); // --------------------------------------------------------------------------- @@ -1029,4 +1041,62 @@ describe("applyScriptPolicyOverride", () => { fs.rmSync(tmpDir, { recursive: true }); } }); + + it("merges scripts['policy'] into overrideRules when a script matches", () => { + // scripts["policy"] is the shared base for all named script entries. + // It must appear in overrideRules so the tool layer and OS sandbox enforce it. + const base: AccessPolicyConfig = { + policy: { "/**": "r--" }, + scripts: { + policy: { [`${HOME}/.secrets/token`]: "r--" }, + "/my/script.sh": { policy: { "/tmp/**": "rwx" } }, + }, + }; + const { overrideRules } = applyScriptPolicyOverride(base, "/my/script.sh"); + expect(overrideRules?.[`${HOME}/.secrets/token`]).toBe("r--"); + expect(overrideRules?.["/tmp/**"]).toBe("rwx"); + }); + + it("per-script policy wins over scripts['policy'] on conflict", () => { + const base: AccessPolicyConfig = { + policy: { "/**": "r--" }, + scripts: { + policy: { "/tmp/**": "r--" }, + "/my/script.sh": { policy: { "/tmp/**": "rwx" } }, + }, + }; + const { overrideRules } = applyScriptPolicyOverride(base, "/my/script.sh"); + expect(overrideRules?.["/tmp/**"]).toBe("rwx"); + }); + + it("includes scripts['policy'] even when per-script entry has no policy key", () => { + // A script entry with only sha256 and no policy still gets scripts["policy"] applied. + const base: AccessPolicyConfig = { + policy: { "/**": "r--" }, + scripts: { + policy: { [`${HOME}/.secrets/token`]: "r--" }, + "/my/script.sh": {}, + }, + }; + const { overrideRules } = applyScriptPolicyOverride(base, "/my/script.sh"); + expect(overrideRules?.[`${HOME}/.secrets/token`]).toBe("r--"); + }); + + it("returns base policy unchanged when script entry is a non-object truthy value", () => { + // A malformed entry like `true` or `"oops"` must not be treated as a valid override. + // Without the shape check, a truthy primitive would skip sha256 and mark hasScriptOverride=true. + const base: AccessPolicyConfig = { + policy: { "/**": "r--" }, + scripts: { + "/my/script.sh": true as unknown as import("../config/types.tools.js").ScriptPolicyEntry, + }, + }; + const { policy, overrideRules, hashMismatch } = applyScriptPolicyOverride( + base, + "/my/script.sh", + ); + expect(overrideRules).toBeUndefined(); + expect(hashMismatch).toBeUndefined(); + expect(policy).toBe(base); + }); }); diff --git a/src/infra/access-policy.ts b/src/infra/access-policy.ts index 419fce3d89f..02a89ab4c31 100644 --- a/src/infra/access-policy.ts +++ b/src/infra/access-policy.ts @@ -220,6 +220,11 @@ const OP_GRANT_CHAR: Record = { 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). @@ -227,7 +232,7 @@ const OP_GRANT_CHAR: Record = { * including typos fails closed rather than accidentally granting access. */ function permAllows(perm: PermStr | undefined, op: FsOp): boolean { - if (!perm) { + if (!perm || !VALID_PERM_RE.test(perm)) { return false; } return perm[OP_INDEX[op]] === OP_GRANT_CHAR[op]; @@ -567,12 +572,18 @@ export function applyScriptPolicyOverride( // 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 override = scripts - ? (Object.entries(scripts).find( + const rawOverride = scripts + ? Object.entries(scripts).find( ([k]) => k !== "policy" && path.normalize(resolveScriptKey(k)) === path.normalize(resolvedArgv0), - )?.[1] as import("../config/types.tools.js").ScriptPolicyEntry | undefined) + )?.[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 }; } @@ -610,9 +621,16 @@ export function applyScriptPolicyOverride( // 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 = { + ...sharedPolicy, + ...override.policy, + }; return { policy: merged, - overrideRules: - override.policy && Object.keys(override.policy).length > 0 ? override.policy : undefined, + overrideRules: Object.keys(mergedOverride).length > 0 ? mergedOverride : undefined, }; } diff --git a/src/infra/exec-sandbox-bwrap.test.ts b/src/infra/exec-sandbox-bwrap.test.ts index 6486ba0e123..8d4d02617ae 100644 --- a/src/infra/exec-sandbox-bwrap.test.ts +++ b/src/infra/exec-sandbox-bwrap.test.ts @@ -325,6 +325,32 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { 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. diff --git a/src/infra/exec-sandbox-bwrap.ts b/src/infra/exec-sandbox-bwrap.ts index 6d5e49887d3..d8462a1c6d0 100644 --- a/src/infra/exec-sandbox-bwrap.ts +++ b/src/infra/exec-sandbox-bwrap.ts @@ -255,7 +255,18 @@ export function generateBwrapArgs( } else if (perm[0] === "r") { args.push("--ro-bind-try", p, p); } else { - args.push("--tmpfs", p); + // 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); + } } } } From 65946937a02aac47fa5f5423cbd756fa19b8ae53 Mon Sep 17 00:00:00 2001 From: subrih Date: Sat, 14 Mar 2026 09:33:05 -0700 Subject: [PATCH 27/29] fix: validation completeness and consumer invariant consistency across all enforcement layers - permAllowsWrite (bwrap), permToOps/deniedOps (seatbelt): guard all positional perm accesses with VALID_PERM_RE - catchAllPerm/tmpPerm (seatbelt): validate rawPerm before positional access; fail closed to '---' - hasScriptOverride (exec-runtime): check entry shape (non-null object, not array) before setting bypass flag - scripts["policy"] merged into overrideRules in applyScriptPolicyOverride (was silently dropped) - mergeAccessPolicy: reject non-object script entries before propagating - validateAccessPolicyFileStructure: recurse into per-script entries to catch removed deny/default fields - validateAccessPolicyConfig: reject non-object entries, validate sha256 format, emit mid-path wildcard diagnostics for scripts["policy"] AND per-script policy blocks (previously only config.policy) - env-prefix regex: handle escaped quotes in double-quoted values ((?:[^"\\]|\\.)*) - _resetBwrapAvailableCacheForTest: export added for test isolation - Tests added for all of the above --- src/agents/bash-tools.exec-runtime.ts | 14 ++++- src/infra/access-policy-file.ts | 35 +++++++++++- src/infra/access-policy.test.ts | 82 +++++++++++++++++++++++++++ src/infra/access-policy.ts | 47 +++++++++++++-- src/infra/exec-sandbox-bwrap.test.ts | 14 +++++ src/infra/exec-sandbox-bwrap.ts | 13 ++++- src/infra/exec-sandbox-seatbelt.ts | 19 ++++++- 7 files changed, 211 insertions(+), 13 deletions(-) diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 91e53968001..243af461794 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -390,9 +390,17 @@ export async function runExecProcess(opts: { // 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 — it holds shared rules, not a per-script entry. - const hasScriptOverride = Object.keys(_scripts).some( - (k) => k !== "policy" && path.normalize(resolveScriptKey(k)) === path.normalize(argv0), + // 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}`); diff --git a/src/infra/access-policy-file.ts b/src/infra/access-policy-file.ts index ee19e703ba0..9630703dbad 100644 --- a/src/infra/access-policy-file.ts +++ b/src/infra/access-policy-file.ts @@ -66,6 +66,15 @@ export function mergeAccessPolicy( 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) { @@ -123,7 +132,27 @@ function validateAccessPolicyFileStructure(filePath: string, parsed: unknown): s if (typeof block !== "object" || block === null || Array.isArray(block)) { errors.push(`${filePath}: agents["${agentId}"] must be an object`); } else { - checkRemovedKeys(block as Record, `agents["${agentId}"]`); + const agentBlock = block as Record; + 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, + )) { + if ( + scriptKey !== "policy" && + scriptEntry != null && + typeof scriptEntry === "object" && + !Array.isArray(scriptEntry) + ) { + checkRemovedKeys( + scriptEntry as Record, + `agents["${agentId}"].scripts["${scriptKey}"]`, + ); + } + } + } } } } @@ -298,7 +327,9 @@ export function resolveAccessPolicyForAgent(agentId?: string): AccessPolicyConfi // 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).`); + console.error( + `[access-policy] Bad permission strings are treated as "---" (deny all) at the tool layer and OS sandbox layer.`, + ); } } } diff --git a/src/infra/access-policy.test.ts b/src/infra/access-policy.test.ts index 7caa1778ab2..7c610950ce6 100644 --- a/src/infra/access-policy.test.ts +++ b/src/infra/access-policy.test.ts @@ -70,6 +70,73 @@ describe("validateAccessPolicyConfig", () => { expect(checkAccessPolicy(file, "read", config)).toBe("deny"); }); + it("rejects non-object script entries (e.g. a bare string or boolean)", () => { + // A primitive entry like "/deploy.sh": "rwx" or "/deploy.sh": true would bypass + // the exec gate — validateAccessPolicyConfig must reject it at load time. + const config: AccessPolicyConfig = { + scripts: { + "/deploy.sh": "rwx" as unknown as import("../config/types.tools.js").ScriptPolicyEntry, + }, + }; + const errs = validateAccessPolicyConfig(config); + expect(errs.some((e) => e.includes("/deploy.sh") && e.includes("must be an object"))).toBe( + true, + ); + }); + + it("rejects a sha256 value with wrong length", () => { + const config: AccessPolicyConfig = { + scripts: { + "/deploy.sh": { sha256: "abc123" }, + }, + }; + const errs = validateAccessPolicyConfig(config); + expect(errs.some((e) => e.includes("sha256") && e.includes("64-character"))).toBe(true); + }); + + it("rejects a sha256 value with non-hex characters", () => { + const config: AccessPolicyConfig = { + scripts: { + "/deploy.sh": { sha256: "z".repeat(64) }, + }, + }; + const errs = validateAccessPolicyConfig(config); + expect(errs.some((e) => e.includes("sha256") && e.includes("64-character"))).toBe(true); + }); + + it("accepts a valid 64-char hex sha256", () => { + const config: AccessPolicyConfig = { + scripts: { + "/deploy.sh": { sha256: "a".repeat(64) }, + }, + }; + expect(validateAccessPolicyConfig(config)).toEqual([]); + }); + + it("emits mid-path wildcard diagnostic for scripts['policy'] entries", () => { + const config: AccessPolicyConfig = { + scripts: { + policy: { "/home/*/workspace/**": "r--" }, + }, + }; + const errs = validateAccessPolicyConfig(config); + expect( + errs.some((e) => e.includes("mid-path wildcard") && e.includes('scripts["policy"]')), + ).toBe(true); + }); + + it("emits mid-path wildcard diagnostic for per-script policy entries", () => { + const config: AccessPolicyConfig = { + scripts: { + "/deploy.sh": { policy: { "/home/*/workspace/**": "r--" } }, + }, + }; + const errs = validateAccessPolicyConfig(config); + expect(errs.some((e) => e.includes("mid-path wildcard") && e.includes("/deploy.sh"))).toBe( + true, + ); + }); + it("validates scripts[].policy perm strings and emits diagnostics for bad ones", () => { // A typo like "rwX" in a script's policy must produce a diagnostic, not silently // fail closed (which would deny exec with no operator-visible error). @@ -717,6 +784,21 @@ describe("resolveArgv0", () => { expect(result).toMatch(/sh$/); }); + it("handles env assignment with escaped quote inside double-quoted value", () => { + // MYVAR="a\"b" /usr/bin/python script.py — the \" inside the value must not + // truncate the match, which would leave `b"` as the next token and misidentify + // it as argv0 instead of /usr/bin/python. + const result = resolveArgv0('MYVAR="a\\"b" /bin/sh -c echo'); + expect(result).not.toBeNull(); + expect(result).toMatch(/sh$/); + }); + + it("handles multiple env assignments with escaped quotes in values", () => { + const result = resolveArgv0('A="x\\"y" B="p\\"q" /bin/sh -c echo'); + expect(result).not.toBeNull(); + expect(result).toMatch(/sh$/); + }); + // The following tests use /bin/sh and Unix env behaviour — skip on Windows where // /bin/sh doesn't exist and env resolves to env.EXE with different semantics. const itUnix = it.skipIf(process.platform === "win32"); diff --git a/src/infra/access-policy.ts b/src/infra/access-policy.ts index 02a89ab4c31..7f5831d05fa 100644 --- a/src/infra/access-policy.ts +++ b/src/infra/access-policy.ts @@ -132,6 +132,15 @@ export function validateAccessPolicyConfig(config: AccessPolicyConfig): string[] `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}`); + 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); } } @@ -139,14 +148,41 @@ export function validateAccessPolicyConfig(config: AccessPolicyConfig): string[] if (scriptPath === "policy") { continue; // handled above } - const scriptEntry = entry as import("../config/types.tools.js").ScriptPolicyEntry | undefined; - if (scriptEntry?.policy) { + // 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}`); + 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); } } @@ -396,11 +432,14 @@ export function resolveArgv0(command: string, cwd?: string, _depth = 0): string // 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. - const envPrefixRe = /^[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s*/; + // 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*/); + const pathM = rest.match(/^PATH=(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+))\s*/); if (pathM) { const val = pathM[1] ?? pathM[2] ?? pathM[3] ?? ""; if (!val.includes("$")) { diff --git a/src/infra/exec-sandbox-bwrap.test.ts b/src/infra/exec-sandbox-bwrap.test.ts index 8d4d02617ae..639ac353b7a 100644 --- a/src/infra/exec-sandbox-bwrap.test.ts +++ b/src/infra/exec-sandbox-bwrap.test.ts @@ -2,9 +2,11 @@ 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"; @@ -428,6 +430,18 @@ describe("wrapCommandWithBwrap", () => { }); }); +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(() => {}); diff --git a/src/infra/exec-sandbox-bwrap.ts b/src/infra/exec-sandbox-bwrap.ts index d8462a1c6d0..fb21437413b 100644 --- a/src/infra/exec-sandbox-bwrap.ts +++ b/src/infra/exec-sandbox-bwrap.ts @@ -36,6 +36,10 @@ const _bwrapFileDenyWarnedPaths = new Set(); 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; @@ -116,8 +120,11 @@ function patternToPath(pattern: string, homeDir: string, perm?: PermStr): string 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 perm[1] === "w"; + return VALID_PERM_RE.test(perm) && perm[1] === "w"; } /** @@ -143,7 +150,9 @@ export function generateBwrapArgs( ): string[] { const args: string[] = []; // Determine base stance from the "/**" catch-all rule (replaces the removed `default` field). - const catchAllPerm = findBestRule("/**", config.policy ?? {}, homeDir) ?? "---"; + 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) { diff --git a/src/infra/exec-sandbox-seatbelt.ts b/src/infra/exec-sandbox-seatbelt.ts index c48ce7f53b7..bbe7f871ccc 100644 --- a/src/infra/exec-sandbox-seatbelt.ts +++ b/src/infra/exec-sandbox-seatbelt.ts @@ -135,7 +135,13 @@ function patternToSbplMatcher(pattern: string, homeDir: string, perm?: PermStr): 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); @@ -150,6 +156,10 @@ function permToOps(perm: PermStr): string[] { } 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); @@ -184,7 +194,9 @@ export function generateSeatbeltProfile( lines.push(""); // Determine base stance from the "/**" catch-all rule (replaces the removed `default` field). - const catchAllPerm = findBestRule("/**", config.policy ?? {}, homeDir) ?? "---"; + 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"; @@ -223,7 +235,10 @@ export function generateSeatbeltProfile( // 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 tmpPerm = findBestRule("/tmp", config.policy ?? {}, homeDir) ?? "---"; + 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") { From 8848bb82e9eb0808a84cd7fe9857a6142fbd75f0 Mon Sep 17 00:00:00 2001 From: subrih Date: Sat, 14 Mar 2026 13:03:37 -0700 Subject: [PATCH 28/29] fix: cross-layer enforcement gaps and cache mutation (vettri review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bwrap: '---' rules on SYSTEM_RO_BIND_PATHS (/etc /usr /bin /lib /sbin /opt) now emit --tmpfs in restrictive mode — previously the deny branch was gated to permissive mode only, leaving syscalls inside the sandbox able to read /etc/passwd etc. despite policy - seatbelt: bracket globs [abc] now detected as wildcards (/[*?[]/ and strip regex updated); previously emitted as SBPL literals matching only a file literally named '[abc]' - access-policy-file: mergeAccessPolicy fast-path (!base) returns shallow copy instead of reference — autoExpandBareDir was mutating the cached agents['*'].policy in-place, corrupting all subsequent resolveAccessPolicyForAgent calls in the same process - access-policy: sha256 comparison normalizes to lowercase (.toLowerCase()) — validation regex accepts uppercase (/i) but crypto.digest always returns lowercase, causing uppercase sha256 in config to silently deny exec at runtime with no useful error - Tests added for all four findings --- src/infra/access-policy-file.test.ts | 32 +++++++++++++++ src/infra/access-policy-file.ts | 23 +++++++++-- src/infra/access-policy.test.ts | 54 +++++++++++++++++++++++++ src/infra/access-policy.ts | 29 +++++++++---- src/infra/exec-sandbox-bwrap.test.ts | 53 ++++++++++++++++++++++++ src/infra/exec-sandbox-bwrap.ts | 22 ++++++---- src/infra/exec-sandbox-seatbelt.test.ts | 14 +++++++ src/infra/exec-sandbox-seatbelt.ts | 9 +++-- 8 files changed, 216 insertions(+), 20 deletions(-) diff --git a/src/infra/access-policy-file.test.ts b/src/infra/access-policy-file.test.ts index 7ade68f01f3..b1db78f86d9 100644 --- a/src/infra/access-policy-file.test.ts +++ b/src/infra/access-policy-file.test.ts @@ -65,6 +65,22 @@ describe("mergeAccessPolicy", () => { 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("rules are shallow-merged, override key wins on collision", () => { const result = mergeAccessPolicy( { policy: { "/**": "r--", "~/**": "rw-" } }, @@ -201,6 +217,22 @@ describe("loadAccessPolicyFile", () => { spy.mockRestore(); }); + it('returns BROKEN_POLICY_FILE when scripts["policy"] is a primitive (not an object)', () => { + // scripts["policy"] must be a Record; 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 } } }, + }); + 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, diff --git a/src/infra/access-policy-file.ts b/src/infra/access-policy-file.ts index 9630703dbad..4878f2a5beb 100644 --- a/src/infra/access-policy-file.ts +++ b/src/infra/access-policy-file.ts @@ -42,7 +42,15 @@ export function mergeAccessPolicy( return undefined; } if (!base) { - return override; + // 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. + return { + ...override, + policy: override.policy ? { ...override.policy } : undefined, + scripts: override.scripts ? { ...override.scripts } : undefined, + }; } if (!override) { return base; @@ -140,8 +148,17 @@ function validateAccessPolicyFileStructure(filePath: string, parsed: unknown): s for (const [scriptKey, scriptEntry] of Object.entries( scripts as Record, )) { - if ( - scriptKey !== "policy" && + if (scriptKey === "policy") { + // scripts["policy"] must be an object (Record), not a primitive. + if ( + scriptEntry != null && + (typeof scriptEntry !== "object" || Array.isArray(scriptEntry)) + ) { + errors.push( + `${filePath}: agents["${agentId}"].scripts["policy"] must be an object (Record), got ${JSON.stringify(scriptEntry)}`, + ); + } + } else if ( scriptEntry != null && typeof scriptEntry === "object" && !Array.isArray(scriptEntry) diff --git a/src/infra/access-policy.test.ts b/src/infra/access-policy.test.ts index 7c610950ce6..ea8d88fce04 100644 --- a/src/infra/access-policy.test.ts +++ b/src/infra/access-policy.test.ts @@ -125,6 +125,23 @@ describe("validateAccessPolicyConfig", () => { ).toBe(true); }); + it('emits "---"-specific mid-path wildcard diagnostic for scripts["policy"] deny rules', () => { + // "---" with a mid-path wildcard cannot be enforced at the OS layer — + // the diagnostic must say "OS-level enforcement cannot apply", not the generic prefix-match message. + const config: AccessPolicyConfig = { + scripts: { + policy: { "/home/*/secrets/**": "---" }, + }, + }; + const errs = validateAccessPolicyConfig(config); + expect( + errs.some( + (e) => + e.includes("OS-level") && e.includes("cannot apply") && e.includes('scripts["policy"]'), + ), + ).toBe(true); + }); + it("emits mid-path wildcard diagnostic for per-script policy entries", () => { const config: AccessPolicyConfig = { scripts: { @@ -137,6 +154,21 @@ describe("validateAccessPolicyConfig", () => { ); }); + it('emits "---"-specific mid-path wildcard diagnostic for per-script deny rules', () => { + // Same as scripts["policy"] — per-script "---" mid-path must get the stronger warning. + const config: AccessPolicyConfig = { + scripts: { + "/deploy.sh": { policy: { "/home/*/secrets/**": "---" } }, + }, + }; + const errs = validateAccessPolicyConfig(config); + expect( + errs.some( + (e) => e.includes("OS-level") && e.includes("cannot apply") && e.includes("/deploy.sh"), + ), + ).toBe(true); + }); + it("validates scripts[].policy perm strings and emits diagnostics for bad ones", () => { // A typo like "rwX" in a script's policy must produce a diagnostic, not silently // fail closed (which would deny exec with no operator-visible error). @@ -1124,6 +1156,28 @@ describe("applyScriptPolicyOverride", () => { } }); + it("uppercase sha256 in config matches (case-normalized at comparison)", () => { + // Validation regex uses /i so uppercase passes; crypto.digest("hex") returns lowercase. + // Without .toLowerCase() at comparison, uppercase sha256 always fails at runtime — silent + // misconfiguration that denies exec with no useful error. + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ap-test-")); + const scriptPath = path.join(tmpDir, "script.sh"); + const content = "#!/bin/sh\necho hi\n"; + fs.writeFileSync(scriptPath, content); + const hashLower = crypto.createHash("sha256").update(Buffer.from(content)).digest("hex"); + const hashUpper = hashLower.toUpperCase(); + const realScriptPath = fs.realpathSync(scriptPath); + try { + const base: AccessPolicyConfig = { + scripts: { [scriptPath]: { sha256: hashUpper, policy: { "/tmp/**": "rwx" } } }, + }; + const { hashMismatch } = applyScriptPolicyOverride(base, realScriptPath); + expect(hashMismatch).toBeUndefined(); + } finally { + fs.rmSync(tmpDir, { recursive: true }); + } + }); + it("merges scripts['policy'] into overrideRules when a script matches", () => { // scripts["policy"] is the shared base for all named script entries. // It must appear in overrideRules so the tool layer and OS sandbox enforce it. diff --git a/src/infra/access-policy.ts b/src/infra/access-policy.ts index 7f5831d05fa..9ebc7cec471 100644 --- a/src/infra/access-policy.ts +++ b/src/infra/access-policy.ts @@ -137,9 +137,15 @@ export function validateAccessPolicyConfig(config: AccessPolicyConfig): string[] !_midPathWildcardWarned.has(`scripts:policy:${pattern}`) ) { _midPathWildcardWarned.add(`scripts:policy:${pattern}`); - 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).`, - ); + 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); } @@ -179,9 +185,15 @@ export function validateAccessPolicyConfig(config: AccessPolicyConfig): string[] !_midPathWildcardWarned.has(`scripts:${scriptPath}:${pattern}`) ) { _midPathWildcardWarned.add(`scripts:${scriptPath}:${pattern}`); - 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).`, - ); + 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); } @@ -648,7 +660,10 @@ export function applyScriptPolicyOverride( } catch { return { policy, hashMismatch: true }; } - if (actualHash !== override.sha256) { + // 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 }; } } diff --git a/src/infra/exec-sandbox-bwrap.test.ts b/src/infra/exec-sandbox-bwrap.test.ts index 639ac353b7a..0e347a5180d 100644 --- a/src/infra/exec-sandbox-bwrap.test.ts +++ b/src/infra/exec-sandbox-bwrap.test.ts @@ -193,6 +193,24 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { 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 @@ -410,6 +428,41 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { 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", () => { diff --git a/src/infra/exec-sandbox-bwrap.ts b/src/infra/exec-sandbox-bwrap.ts index fb21437413b..226ec4a009c 100644 --- a/src/infra/exec-sandbox-bwrap.ts +++ b/src/infra/exec-sandbox-bwrap.ts @@ -215,16 +215,22 @@ export function generateBwrapArgs( // 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 (catchAllPerm[0] !== "r" && perm[0] === "r") { + } 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 (catchAllPerm[0] === "r" && perm[0] !== "r") { - // Permissive base + narrowing rule (no read bit): overlay with tmpfs so the - // path is hidden even though --ro-bind / / made it readable by default. - // This mirrors what deny[] does — without this, "---" rules under a permissive - // default are silently ignored at the bwrap layer. + } 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). @@ -241,6 +247,7 @@ export function generateBwrapArgs( } } // 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 @@ -261,7 +268,8 @@ export function generateBwrapArgs( // 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 (perm[0] === "r") { + } 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. diff --git a/src/infra/exec-sandbox-seatbelt.test.ts b/src/infra/exec-sandbox-seatbelt.test.ts index a3202abdc93..8b00b4dd955 100644 --- a/src/infra/exec-sandbox-seatbelt.test.ts +++ b/src/infra/exec-sandbox-seatbelt.test.ts @@ -226,6 +226,20 @@ describe("generateSeatbeltProfile", () => { 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", () => { diff --git a/src/infra/exec-sandbox-seatbelt.ts b/src/infra/exec-sandbox-seatbelt.ts index bbe7f871ccc..d77a0bc3176 100644 --- a/src/infra/exec-sandbox-seatbelt.ts +++ b/src/infra/exec-sandbox-seatbelt.ts @@ -108,12 +108,15 @@ function patternToSbplMatcher(pattern: string, homeDir: string, perm?: PermStr): // 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. - const withoutWild = expanded.replace(/[/\\]?[*?].*$/, ""); + // 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). - // Otherwise use literal (exact match). - if (/[*?]/.test(expanded)) { + // 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)) { From 3c941eae23be6c33a3a15910b6156e38b4f27b94 Mon Sep 17 00:00:00 2001 From: subrih Date: Sat, 14 Mar 2026 16:20:02 -0700 Subject: [PATCH 29/29] fix: deep-copy nested scripts policy objects in mergeAccessPolicy, use SEATBELT_WRITE_OPS - mergeAccessPolicy !base fast-path: scripts["policy"] and per-script entry.policy were shallow-copied, leaving them as references into the cached _fileCache object. autoExpandBareDir mutations would propagate back into the cache, violating the invariant established by the policy-copy fix. Now deep-copied via Object.fromEntries map. - exec-sandbox-seatbelt: replace hardcoded "file-write*" with SEATBELT_WRITE_OPS constant in the /tmp write allowance branch, consistent with all other allowance lines in the file. - Tests added for nested scripts deep-copy invariant. --- src/infra/access-policy-file.test.ts | 28 ++++++++++++++++++++++++++++ src/infra/access-policy-file.ts | 23 ++++++++++++++++++++++- src/infra/exec-sandbox-seatbelt.ts | 2 +- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/infra/access-policy-file.test.ts b/src/infra/access-policy-file.test.ts index b1db78f86d9..273241e5d55 100644 --- a/src/infra/access-policy-file.test.ts +++ b/src/infra/access-policy-file.test.ts @@ -81,6 +81,34 @@ describe("mergeAccessPolicy", () => { 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 } + | 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-" } }, diff --git a/src/infra/access-policy-file.ts b/src/infra/access-policy-file.ts index 4878f2a5beb..ff91897d241 100644 --- a/src/infra/access-policy-file.ts +++ b/src/infra/access-policy-file.ts @@ -46,10 +46,31 @@ export function mergeAccessPolicy( // 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 — 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: override.scripts ? { ...override.scripts } : undefined, + scripts: scriptsCopy, }; } if (!override) { diff --git a/src/infra/exec-sandbox-seatbelt.ts b/src/infra/exec-sandbox-seatbelt.ts index d77a0bc3176..9c0f4477911 100644 --- a/src/infra/exec-sandbox-seatbelt.ts +++ b/src/infra/exec-sandbox-seatbelt.ts @@ -248,7 +248,7 @@ export function generateSeatbeltProfile( lines.push(`(allow ${SEATBELT_READ_OPS} (subpath "/private/tmp"))`); } if (tmpPerm[1] === "w") { - lines.push(`(allow file-write* (subpath "/private/tmp"))`); + lines.push(`(allow ${SEATBELT_WRITE_OPS} (subpath "/private/tmp"))`); } if (tmpPerm[2] === "x") { lines.push(`(allow ${SEATBELT_EXEC_OPS} (subpath "/private/tmp"))`);