mirror of https://github.com/openclaw/openclaw.git
refactor(access-policy): rename rules→policy, agents['*'] as universal base, docs rewrite
This commit is contained in:
parent
77beb444bc
commit
c92c9c2181
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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("---");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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")');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue