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, );