refactor(access-policy): rename rules→policy, agents['*'] as universal base, docs rewrite

This commit is contained in:
subrih 2026-03-14 08:21:45 -07:00
parent 77beb444bc
commit c92c9c2181
12 changed files with 467 additions and 323 deletions

View File

@ -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": "<hex>"
}
}
},
"veda": {
"policy": { "~/.openclaw/agents/veda/workspace/**": "rwx" },
"scripts": {
"~/bin/veda-tool.sh": {
"policy": { "/opt/data/**": "r--" }
}
}
}
}
}
```
### `scripts["policy"]`
A flat `{ path: perm }` map of shared path rules. These rules are merged as the **base** for every per-script entry in the same `scripts` block, before the per-script `policy` is applied.
**Important:** `scripts["policy"]` only takes effect when an exec call matches one of the named script keys. If the `scripts` block has `scripts["policy"]` but no actual script entries, those rules are never applied.
### Per-script entries
Each key is the resolved absolute path to a script (tilde is expanded, symlinks are followed at match time).
- **`policy`** — path rules that add to or narrow the base policy for this script only. Override rules are emitted _after_ base rules in the OS sandbox profile so a script grant can reach inside a broadly denied subtree (last-match-wins semantics).
- **`sha256`** — optional SHA-256 hex of the script file. When set, exec is denied if the hash does not match. Best-effort integrity check — there is a small TOCTOU window between the hash read and kernel exec.
### Script override flow
When an exec call matches a script key:
1. `scripts["policy"]` shared rules are merged as a base.
2. The matching script's `policy` is merged on top (override key wins).
3. The resulting path rules are emitted _after_ the agent's main `policy` rules in the OS sandbox profile.
The `scripts` block is stripped from the policy after the match so it does not bleed into unrelated tool calls in the same agent turn.
## Enforcement
@ -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

View File

@ -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}`);

View File

@ -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();

View File

@ -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<string, PermStr>;
/** Extra path rules for this script, merged over the shared script policy. */
policy?: Record<string, PermStr>;
/** SHA-256 hex of the script file for integrity checking (best-effort, not atomic). */
sha256?: string;
};
@ -23,9 +23,17 @@ export type ScriptPolicyEntry = {
*/
export type AccessPolicyConfig = {
/** Glob-pattern rules: path → permission string. Longest prefix wins. */
rules?: Record<string, PermStr>;
/** Per-script argv0 policy overrides keyed by resolved binary path. */
scripts?: Record<string, ScriptPolicyEntry>;
policy?: Record<string, PermStr>;
/**
* Per-script argv0 policy overrides keyed by resolved binary path.
* Reserved key "policy" holds shared rules applied to every script before
* per-script policy is merged in.
*/
scripts?: {
/** Shared rules applied to all scripts; per-script policy wins on collision. */
policy?: Record<string, PermStr>;
[path: string]: ScriptPolicyEntry | Record<string, PermStr> | undefined;
};
};
export type MediaUnderstandingScopeMatch = {

View File

@ -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("---");
});
});

View File

@ -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<string, AccessPolicyConfig>;
@ -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<AccessPolicyConfig["scripts"]> = { ...base.scripts };
for (const [key, overrideEntry] of Object.entries(override.scripts ?? {})) {
const baseEntry = base.scripts?.[key];
if (key === "policy") {
// "policy" holds shared rules (Record<string, PermStr>) — merge as flat rules,
// override key wins. No sha256 preservation applies here.
mergedScripts["policy"] = { ...base.scripts?.["policy"], ...overrideEntry } as Record<
string,
string
>;
continue;
}
const baseEntry = base.scripts?.[key] as
| import("../config/types.tools.js").ScriptPolicyEntry
| undefined;
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<string, unknown>;
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<string, unknown>, 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<string, unknown>, `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) {

View File

@ -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 });

View File

@ -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<string, PermStr>,
pattern: string,
perm: PermStr,
errors: string[],
): void {
if (!pattern || pattern.endsWith("/") || /[*?[]/.test(pattern)) {
return;
}
const expanded = pattern.startsWith("~") ? pattern.replace(/^~(?=$|\/)/, os.homedir()) : pattern;
try {
if (fs.statSync(expanded).isDirectory()) {
const fixed = `${pattern}/**`;
// Only write the expanded key if no explicit glob for this path already
// exists — overwriting an existing "/**" rule would silently widen access
// (e.g. {"/tmp":"rwx","/tmp/**":"---"} would become {"/tmp/**":"rwx"}).
if (!(fixed in rules)) {
rules[fixed] = perm;
}
delete rules[pattern];
if (!_autoExpandedWarned.has(pattern)) {
_autoExpandedWarned.add(pattern);
errors.push(
`access-policy.policy["${pattern}"] is a directory — rule auto-expanded to "${fixed}" so it covers all contents.`,
);
}
}
} catch {
// Path inaccessible or missing — no action needed.
}
}
/**
* Validates and normalizes an AccessPolicyConfig for well-formedness.
* Returns an array of human-readable diagnostic strings; empty = valid.
* May mutate config.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<string, PermStr> — validate as flat rules.
const sharedPolicy = config.scripts["policy"];
if (sharedPolicy) {
for (const [pattern, perm] of Object.entries(sharedPolicy)) {
if (!PERM_STR_RE.test(perm)) {
errors.push(
`access-policy.scripts["policy"]["${pattern}"] "${perm}" is invalid: must be exactly 3 chars (e.g. "rwx", "r--", "---")`,
);
}
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,
};
}

View File

@ -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");
});

View File

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

View File

@ -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<string, string> = { [`${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")');
});

View File

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