diff --git a/docs/tools/access-policy.md b/docs/tools/access-policy.md index fb1504f0f54..90fd7f4d8de 100644 --- a/docs/tools/access-policy.md +++ b/docs/tools/access-policy.md @@ -35,10 +35,10 @@ The file is **optional** — if absent, all operations pass through unchanged (a "/**": "r--", "/tmp/": "rwx", "~/": "rw-", - "~/dev/": "rwx" - }, - "deny": ["~/.ssh/", "~/.aws/", "~/.openclaw/credentials/"], - "default": "---" + "~/dev/": "rwx", + "~/.ssh/**": "---", + "~/.aws/**": "---" + } }, "agents": { "myagent": { "rules": { "~/private/": "rw-" } } @@ -58,6 +58,8 @@ Each rule value is a three-character string — one character per operation: Examples: `"rwx"` (full access), `"r--"` (read only), `"r-x"` (read + exec), `"---"` (deny all). +Use `"---"` to explicitly deny all access to a path — this is the deny mechanism. A rule with `"---"` always blocks regardless of broader rules, as long as it is the longest (most specific) matching pattern. + ### Pattern syntax - Patterns are path globs: `*` matches within a segment, `**` matches any depth. @@ -67,9 +69,19 @@ Examples: `"rwx"` (full access), `"r--"` (read only), `"r-x"` (read + exec), `"- ### Precedence -1. **`deny`** — always blocks, regardless of rules. Additive across layers — cannot be removed by agent overrides. -2. **`rules`** — longest matching glob wins (most specific pattern takes priority). -3. **`default`** — catch-all for unmatched paths. Omitting it is equivalent to `"---"`. +1. **`rules`** — longest matching glob wins (most specific pattern takes priority). +2. **Implicit fallback** — `"---"` (deny all) when no rule matches. Use `"/**": "r--"` (or any perm) as an explicit catch-all. + +To deny a specific path, add a `"---"` rule that is more specific than any allow rule covering that path: + +```json +"rules": { + "/**": "r--", + "~/.ssh/**": "---" +} +``` + +`~/.ssh/**` is longer than `/**` so it wins for any path under `~/.ssh/`. ## Layers @@ -77,9 +89,9 @@ Examples: `"rwx"` (full access), `"r--"` (read only), `"r-x"` (read + exec), `"- base → agents["*"] → agents["myagent"] ``` -- **`base`** — applies to all agents. Deny entries here can never be overridden. +- **`base`** — applies to all agents. - **`agents["*"]`** — wildcard block applied to every agent after `base`, before the agent-specific block. Useful for org-wide rules. -- **`agents`** — per-agent overrides. Each agent block is merged on top of `base` (and `agents["*"]` if present): deny is additive, rules are shallow-merged (agent wins on collision), default is agent-wins if set. +- **`agents`** — per-agent overrides. Each agent block is merged on top of `base` (and `agents["*"]` if present): rules are shallow-merged (agent wins on collision). ## Enforcement @@ -104,9 +116,9 @@ If the file exists but cannot be parsed, or contains structural errors (wrong ne Common mistakes caught by the validator: -- `rules`, `deny`, or `default` placed at the top level instead of under `base` +- `rules` or `scripts` placed at the top level instead of under `base` - Permission strings that are not exactly 3 characters (`"rwx"`, `"r--"`, `"---"`, etc.) -- Empty deny entries +- `deny` or `default` keys inside `base` or agent blocks — these fields were removed; use `"---"` rules instead ### Bare directory paths @@ -128,9 +140,11 @@ For cross-agent MCP tool delegation (an orchestrator invoking a tool on behalf o **Metadata leak via directory listing.** `find`, `ls`, and shell globs use `readdir()` to enumerate directory contents, which is allowed. When content access is then denied at `open()`, the filenames are already visible in the error output. Content is protected; filenames are not. This is inherent to how OS-level enforcement works at the syscall level. -**Interpreter bypass of exec bit.** The `x` bit gates `execve()` on the file itself. Running `bash script.sh` executes bash (permitted), which reads the script as text (read permitted if `r` is set). The exec bit on the script is irrelevant for interpreter-based invocations. To prevent execution of a script entirely, place it in the deny list (no read access). +**Interpreter bypass of exec bit.** The `x` bit gates `execve()` on the file itself. Running `bash script.sh` executes bash (permitted), which reads the script as text (read permitted if `r` is set). The exec bit on the script is irrelevant for interpreter-based invocations. To prevent execution of a script entirely, deny read access to it (`"---"`). -**File-specific `deny[]` entries on Linux (bwrap).** On Linux, `deny[]` entries are enforced at the OS layer using `bwrap --tmpfs` overlays, which only work on directories. When a `deny[]` entry resolves to an existing file (e.g. `deny: ["~/.netrc"]`), the OS-level mount is skipped — bwrap cannot overlay a file with a tmpfs. Tool-layer enforcement still blocks read/write/edit calls for that file. However, exec commands running inside the sandbox can still access the file directly (e.g. `cat ~/.netrc`). A warning is emitted to stderr when this gap is active. To enforce at the OS layer on Linux, deny the parent directory instead (e.g. `deny: ["~/.aws/"]` rather than `deny: ["~/.aws/credentials"]`). On macOS, seatbelt handles file-level denials correctly with `(deny file-read* (literal ...))`. +**File-level `"---"` rules on Linux (bwrap).** On Linux, `"---"` rules are enforced at the OS layer using `bwrap --tmpfs` overlays, which only work on directories. When a `"---"` rule resolves to an existing file (e.g. `"~/.netrc": "---"`), the OS-level mount is skipped — bwrap cannot overlay a file with a tmpfs. Tool-layer enforcement still blocks read/write/edit calls for that file. However, exec commands running inside the sandbox can still access the file directly (e.g. `cat ~/.netrc`). A warning is emitted to stderr when this gap is active. To enforce at the OS layer on Linux, deny the parent directory instead (e.g. `"~/.aws/**": "---"` rather than `"~/.aws/credentials": "---"`). On macOS, seatbelt handles file-level denials correctly with `(deny file-read* (literal ...))`. + +**Mid-path wildcard patterns and OS-level exec enforcement.** Patterns with a wildcard in a non-final segment — such as `skills/**/*.sh` or `logs/*/app.log` — cannot be expressed as OS-level subpath matchers. bwrap and Seatbelt do not understand glob syntax; they work with concrete directory prefixes. For non-deny rules, OpenClaw emits the longest concrete prefix (`skills/`) as an approximate OS-level rule for read and write access, which is bounded and safe. The exec bit is intentionally omitted from the OS approximation: granting exec on the entire prefix directory would allow any binary under that directory to be executed by subprocesses, not just files matching the original pattern. Exec for mid-path wildcard patterns is enforced by the tool layer only. To get OS-level exec enforcement, use a trailing-`**` pattern such as `skills/**` (which covers the directory precisely, with the file-type filter applying at the tool layer only). **No approval flow.** Access policy is fail-closed: a denied operation returns an error immediately. There is no `ask`/`on-miss` mode equivalent to exec approvals. If an agent hits a denied path, it receives a permission error and must handle it. Interactive approval for filesystem access is planned as a follow-up feature. diff --git a/src/infra/access-policy-file.ts b/src/infra/access-policy-file.ts index 915b9880d86..f7b1e4d80eb 100644 --- a/src/infra/access-policy-file.ts +++ b/src/infra/access-policy-file.ts @@ -92,6 +92,31 @@ function validateAccessPolicyFileStructure(filePath: string, parsed: unknown): s ) { errors.push(`${filePath}: "base" must be an object`); } + // Removed fields: "deny" and "default" were dropped in favour of "---" rules. + // A user who configures these fields would receive no protection because the + // fields are silently discarded. Reject them explicitly so the file fails-closed. + const REMOVED_KEYS = ["deny", "default"] as const; + const KNOWN_CONFIG_KEYS = new Set(["rules", "scripts"]); + + function checkRemovedKeys(block: Record, context: string): void { + for (const key of REMOVED_KEYS) { + if (block[key] !== undefined) { + errors.push( + `${filePath}: ${context} "${key}" is no longer supported — use "---" rules instead (e.g. "~/.ssh/**": "---"). Failing closed until removed.`, + ); + } + } + for (const key of Object.keys(block)) { + if (!KNOWN_CONFIG_KEYS.has(key)) { + // Only warn for keys that look like removed/misplaced fields, not arbitrary agent data. + if (REMOVED_KEYS.includes(key as (typeof REMOVED_KEYS)[number])) { + continue; + } // already reported above + // Unknown keys that are not known config keys — warn but don't fail-close for forward compat. + } + } + } + if (p["agents"] !== undefined) { if (typeof p["agents"] !== "object" || p["agents"] === null || Array.isArray(p["agents"])) { errors.push(`${filePath}: "agents" must be an object`); @@ -99,11 +124,17 @@ function validateAccessPolicyFileStructure(filePath: string, parsed: unknown): s for (const [agentId, block] of Object.entries(p["agents"] as Record)) { if (typeof block !== "object" || block === null || Array.isArray(block)) { errors.push(`${filePath}: agents["${agentId}"] must be an object`); + } else { + checkRemovedKeys(block as Record, `agents["${agentId}"]`); } } } } + if (typeof p["base"] === "object" && p["base"] !== null && !Array.isArray(p["base"])) { + checkRemovedKeys(p["base"] as Record, `base`); + } + // Catch common mistake: AccessPolicyConfig fields accidentally at top level // (e.g. user puts "rules" or "scripts" directly instead of under "base"). for (const key of ["rules", "scripts"] as const) { diff --git a/src/infra/access-policy.test.ts b/src/infra/access-policy.test.ts index 20d0a55643f..b41a7db0240 100644 --- a/src/infra/access-policy.test.ts +++ b/src/infra/access-policy.test.ts @@ -178,6 +178,17 @@ describe("validateAccessPolicyConfig", () => { expect(second.filter((e) => e.includes("mid-path wildcard"))).toHaveLength(0); }); + it("non-deny mid-path wildcard emits approximate-prefix diagnostic (not cannot-apply)", () => { + _resetMidPathWildcardWarnedForTest(); + const errs = validateAccessPolicyConfig({ + rules: { "~/.openclaw/agents/subri/workspace/skills/**/*.sh": "r-x" }, + }); + expect(errs).toHaveLength(1); + expect(errs[0]).toMatch(/mid-path wildcard/); + expect(errs[0]).toMatch(/prefix match/); + expect(errs[0]).not.toMatch(/cannot apply/); + }); + it("does NOT emit mid-path wildcard diagnostic for final-segment wildcards", () => { _resetMidPathWildcardWarnedForTest(); // "/home/user/**" — wildcard is in the final segment, no path separator follows. diff --git a/src/infra/access-policy.ts b/src/infra/access-policy.ts index e6fc9adf25e..e4ffce84ac8 100644 --- a/src/infra/access-policy.ts +++ b/src/infra/access-policy.ts @@ -60,9 +60,20 @@ export function validateAccessPolicyConfig(config: AccessPolicyConfig): string[] } if (hasMidPathWildcard(pattern) && !_midPathWildcardWarned.has(`rules:${pattern}`)) { _midPathWildcardWarned.add(`rules:${pattern}`); - errors.push( - `access-policy.rules["${pattern}"] contains a mid-path wildcard — OS-level (bwrap/Seatbelt) enforcement cannot apply; tool-layer enforcement is still active.`, - ); + if (perm === "---") { + // Deny-all on a mid-path wildcard prefix would be too broad at the OS layer + // (e.g. "secrets/**/*.env: ---" → deny all of secrets/). Skip OS emission entirely. + errors.push( + `access-policy.rules["${pattern}"] contains a mid-path wildcard with "---" — OS-level (bwrap/Seatbelt) enforcement cannot apply; tool-layer enforcement is still active.`, + ); + } else { + // For non-deny rules the OS layer uses the longest concrete prefix as an + // approximate mount/subpath target. The file-type filter (e.g. *.sh) is enforced + // precisely by the tool layer only. + errors.push( + `access-policy.rules["${pattern}"] contains a mid-path wildcard — OS-level enforcement uses prefix match (file-type filter is tool-layer only).`, + ); + } } // If a bare path (no glob metacharacters, no trailing /) points to a real // directory it would match only the directory entry itself, not its @@ -478,7 +489,10 @@ export function resolveArgv0(command: string, cwd?: string, _depth = 0): string * resolveArgv0 returns the real path "/usr/bin/python3.12". */ export function resolveScriptKey(k: string): string { - const expanded = k.startsWith("~") ? k.replace(/^~(?=$|[/\\])/, os.homedir()) : k; + // path.normalize converts forward slashes to OS-native separators on Windows so that + // a tilde key like "~/bin/script.sh" compares correctly against a resolved argv0 + // that uses backslashes on Windows. + const expanded = k.startsWith("~") ? path.normalize(k.replace(/^~(?=$|[/\\])/, os.homedir())) : k; if (!path.isAbsolute(expanded)) { return expanded; } @@ -533,6 +547,11 @@ export function applyScriptPolicyOverride( if (override.sha256) { let actualHash: string; try { + // Policy-engine internal read: intentionally bypasses checkAccessPolicy. + // The policy engine must verify the script's integrity before deciding + // whether to grant the script's extra permissions — checking the policy + // first would be circular. This read is safe: it never exposes content + // to the agent; it only computes a hash for comparison. const contents = fs.readFileSync(resolvedArgv0); actualHash = crypto.createHash("sha256").update(contents).digest("hex"); } catch { diff --git a/src/infra/exec-sandbox-bwrap.test.ts b/src/infra/exec-sandbox-bwrap.test.ts index bc47b2e659f..0d1d173b77c 100644 --- a/src/infra/exec-sandbox-bwrap.test.ts +++ b/src/infra/exec-sandbox-bwrap.test.ts @@ -254,9 +254,9 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { expect(bindMounts).toContain(`${HOME}/output`); }); - it("skips mid-path wildcard patterns — truncated prefix would be too broad", () => { - // "/home/*/.ssh/**" truncates to "/home" — far too broad for a bwrap mount. - // The pattern must be silently ignored rather than binding /home. + it("skips mid-path wildcard --- patterns — deny-all on truncated prefix would be too broad", () => { + // "/home/*/.config/**" with "---" truncates to "/home" — applying --tmpfs to /home + // would hide the entire home directory. Must be skipped. const fakeHome = "/home/testuser"; const config: AccessPolicyConfig = { rules: { "/**": "r--", "/home/*/.config/**": "---" }, @@ -267,10 +267,25 @@ describe.skipIf(process.platform !== "linux")("generateBwrapArgs", () => { ["--tmpfs", "--bind-try", "--ro-bind-try"].includes(args[i - 1] ?? "") ? a : null, ) .filter(Boolean); - // "/home" must NOT appear as a mount target — it's the over-broad truncation. expect(allMountTargets).not.toContain("/home"); }); + it("non-deny mid-path wildcard emits prefix as approximate mount target", () => { + // "scripts/**/*.sh": "r-x" — mid-path wildcard, non-deny perm. + // OS layer uses the concrete prefix (/scripts) as an approximate ro-bind-try target; + // the tool layer enforces the *.sh filter precisely. + const config: AccessPolicyConfig = { + rules: { "/scripts/**/*.sh": "r-x" }, + }; + const args = generateBwrapArgs(config, "/home/user"); + const allMountTargets = args + .map((a, i) => + ["--tmpfs", "--bind-try", "--ro-bind-try"].includes(args[i - 1] ?? "") ? a : null, + ) + .filter(Boolean); + expect(allMountTargets).toContain("/scripts"); + }); + it("suffix-glob rule uses parent directory as mount target, not literal prefix", () => { // "/var/log/secret*" must mount "/var/log", NOT the literal prefix "/var/log/secret" // which is not a directory and leaves entries like "/var/log/secret.old" unprotected. diff --git a/src/infra/exec-sandbox-bwrap.ts b/src/infra/exec-sandbox-bwrap.ts index 09a276b1223..bb87783d7dd 100644 --- a/src/infra/exec-sandbox-bwrap.ts +++ b/src/infra/exec-sandbox-bwrap.ts @@ -82,11 +82,12 @@ function expandPattern(pattern: string, homeDir: string): string { * e.g. "/Users/kaveri/**" → "/Users/kaveri" * "/tmp/foo" → "/tmp/foo" * - * Returns null when a wildcard appears in a non-final segment (e.g. "/home/*\/.ssh/**") - * because the truncated prefix ("/home") would be far too broad for a bwrap mount - * and the caller must skip it entirely. + * For mid-path wildcards (e.g. "skills/**\/*.sh"), returns the concrete prefix + * when perm is not "---" — the prefix is an intentional approximation for bwrap + * mounts; the tool layer enforces the file-type filter precisely. For "---" perms + * returns null so callers skip emission (a deny-all on the prefix would be too broad). */ -function patternToPath(pattern: string, homeDir: string): string | null { +function patternToPath(pattern: string, homeDir: string, perm?: PermStr): string | null { const expanded = expandPattern(pattern, homeDir); // Find the first wildcard character in the path. const wildcardIdx = expanded.search(/[*?[]/); @@ -95,11 +96,16 @@ function patternToPath(pattern: string, homeDir: string): string | null { return expanded || "/"; } // Check whether there is a path separator AFTER the first wildcard. - // If so, the wildcard is in a non-final segment (e.g. /home/*/foo) and the - // concrete prefix (/home) is too broad to be a safe mount target. + // If so, the wildcard is in a non-final segment (e.g. skills/**/*.sh). const afterWildcard = expanded.slice(wildcardIdx); if (/[/\\]/.test(afterWildcard)) { - return null; + // Mid-path wildcard: for "---" perm a deny-all on the prefix is too broad — skip. + // For other perms, use the prefix as an approximate mount target; the tool layer + // enforces the file-type filter precisely. + if (!perm || perm === "---") { + return null; + } + // Fall through to use the concrete prefix below. } // Wildcard is only in the final segment — use the parent directory. // e.g. "/var/log/secret*" → last sep before "*" is at 8 → "/var/log" @@ -168,7 +174,7 @@ export function generateBwrapArgs( // In restrictive mode (default:"---"), /tmp is intentionally omitted so rules // control tmpfs access explicitly (e.g. "/tmp/**":"rwx" is handled by the rules loop). if (defaultAllowsRead) { - const explicitTmpPerm = findBestRule("/tmp/.", config.rules ?? {}, homeDir); + const explicitTmpPerm = findBestRule("/tmp", config.rules ?? {}, homeDir); if (explicitTmpPerm === null) { // Only emit --tmpfs /tmp when there is no explicit rule for /tmp. // When an explicit write rule exists, the rules loop below emits --bind-try /tmp /tmp @@ -190,7 +196,7 @@ export function generateBwrapArgs( return (pa?.length ?? 0) - (pb?.length ?? 0); }); for (const [pattern, perm] of ruleEntries) { - const p = patternToPath(pattern, homeDir); + const p = patternToPath(pattern, homeDir, perm); if (!p || p === "/") { continue; } // root already handled above @@ -237,7 +243,7 @@ export function generateBwrapArgs( return (pa?.length ?? 0) - (pb?.length ?? 0); }); for (const [pattern, perm] of overrideEntries) { - const p = patternToPath(pattern, homeDir); + const p = patternToPath(pattern, homeDir, perm); if (!p || p === "/") { continue; } diff --git a/src/infra/exec-sandbox-seatbelt.test.ts b/src/infra/exec-sandbox-seatbelt.test.ts index 4b7a2296529..02bd5299784 100644 --- a/src/infra/exec-sandbox-seatbelt.test.ts +++ b/src/infra/exec-sandbox-seatbelt.test.ts @@ -270,9 +270,26 @@ describe("wrapCommandWithSeatbelt", () => { }); describe("generateSeatbeltProfile — mid-path wildcard guard", () => { - skipOnWindows("skips mid-path wildcard rules to avoid over-granting parent directory", () => { - // /home/*/workspace/** would truncate to /home and grant all of /home — must be skipped. - const profile = generateSeatbeltProfile({ rules: { "/home/*/workspace/**": "rwx" } }, HOME); + skipOnWindows( + "non-deny mid-path wildcard emits prefix subpath for r/w but NOT exec (option B)", + () => { + // skills/**/*.sh: r-x — mid-path wildcard; OS prefix is /home. + // Read is emitted (bounded over-grant). Exec is omitted — granting exec on the + // entire prefix would allow arbitrary binaries to run, not just *.sh files. + // Exec falls through to ancestor rule; tool layer enforces it precisely. + const profile = generateSeatbeltProfile({ rules: { "/home/*/workspace/**": "r-x" } }, HOME); + const rulesSection = profile.split("; User-defined path rules")[1] ?? ""; + expect(rulesSection).toContain("(allow file-read*"); + expect(rulesSection).toContain('(subpath "/home")'); + expect(rulesSection).not.toContain("(allow process-exec*"); + // exec deny also omitted — falls through to ancestor + expect(rulesSection).not.toContain("(deny process-exec*"); + }, + ); + + skipOnWindows("--- mid-path wildcard is skipped (deny-all on prefix would be too broad)", () => { + // A deny-all on the /home prefix would block the entire home directory — too broad. + const profile = generateSeatbeltProfile({ rules: { "/home/*/workspace/**": "---" } }, HOME); expect(profile).not.toContain('(subpath "/home")'); }); diff --git a/src/infra/exec-sandbox-seatbelt.ts b/src/infra/exec-sandbox-seatbelt.ts index 9c1aa267092..fd4cc94792d 100644 --- a/src/infra/exec-sandbox-seatbelt.ts +++ b/src/infra/exec-sandbox-seatbelt.ts @@ -92,7 +92,12 @@ function expandSbplAliases(pattern: string): string[] { return [pattern]; } -function patternToSbplMatcher(pattern: string, homeDir: string): string | null { +type SbplMatchResult = + | { matcher: string; approximate: false } + | { matcher: string; approximate: true } // mid-path wildcard — exec bit must be skipped + | null; + +function patternToSbplMatcher(pattern: string, homeDir: string, perm?: PermStr): SbplMatchResult { // Trailing / shorthand: "/tmp/" → "/tmp/**" const withExpanded = pattern.endsWith("/") ? pattern + "**" : pattern; const expanded = withExpanded.startsWith("~") @@ -109,17 +114,25 @@ function patternToSbplMatcher(pattern: string, homeDir: string): string | null { // If the original pattern had wildcards, use subpath (recursive match). // Otherwise use literal (exact match). if (/[*?]/.test(expanded)) { - // Guard against mid-path wildcards (e.g. /home/*/workspace/**): stripping from - // the first * would produce /home and silently grant access to all of /home. - // bwrap skips these patterns too — return null so callers can skip emission. const wildcardIdx = expanded.search(/[*?[]/); const afterWildcard = expanded.slice(wildcardIdx + 1); if (/[/\\]/.test(afterWildcard)) { - return null; + // Mid-path wildcard (e.g. skills/**/*.sh): SBPL has no glob matcher so we fall + // back to the longest concrete prefix as a subpath. + // "---" → skip entirely: deny-all on the prefix is too broad. + // Other perms → emit prefix with approximate=true so callers omit the exec bit. + // Granting exec on the prefix would allow arbitrary binaries under the directory + // to be executed by subprocesses, not just files matching the original pattern. + // Read/write on the prefix are acceptable approximations; exec is not. + // The exec bit for mid-path patterns is enforced by the tool layer only. + if (!perm || perm === "---") { + return null; + } + return { matcher: sbplSubpath(base), approximate: true }; } - return sbplSubpath(base); + return { matcher: sbplSubpath(base), approximate: false }; } - return sbplLiteral(base); + return { matcher: sbplLiteral(base), approximate: false }; } function permToOps(perm: PermStr): string[] { @@ -208,9 +221,9 @@ export function generateSeatbeltProfile( // Allow /tmp only when the policy permits it — mirrors the bwrap logic that // skips --tmpfs /tmp in restrictive mode. Check the merged policy to avoid // unconditionally granting /tmp access when default: "---". - // Use "/tmp/." so glob rules like "/tmp/**" match correctly — findBestRule - // on "/tmp" alone would miss "/**"-suffixed patterns that only match descendants. - const tmpPerm = findBestRule("/tmp/.", config.rules ?? {}, homeDir) ?? "---"; + // findBestRule probes both the path and path+"/" internally, so "/tmp" correctly + // matches glob rules like "/tmp/**" without needing the "/tmp/." workaround. + const tmpPerm = findBestRule("/tmp", config.rules ?? {}, homeDir) ?? "---"; // Emit read and write allowances independently so a read-only policy like // "/tmp/**": "r--" does not accidentally grant write access to /tmp. if (tmpPerm[0] === "r") { @@ -248,15 +261,20 @@ export function generateSeatbeltProfile( lines.push("; User-defined path rules (shortest → longest; more specific wins)"); for (const [pattern, perm] of ruleEntries) { for (const expanded of expandSbplAliases(pattern)) { - const matcher = patternToSbplMatcher(expanded, homeDir); - if (!matcher) { + const result = patternToSbplMatcher(expanded, homeDir, perm); + if (!result) { continue; - } // skip mid-path wildcards — prefix would be too broad - // First allow the permitted ops, then deny the rest for this path. - for (const op of permToOps(perm)) { + } + const { matcher, approximate } = result; + // Mid-path wildcard approximation: omit exec allow/deny entirely. + // Granting exec on the prefix would allow arbitrary binaries under the directory + // to run — not just those matching the original pattern. Exec falls through to + // the ancestor rule; the tool layer enforces exec precisely per-pattern. + const filterExec = approximate ? (op: string) => op !== SEATBELT_EXEC_OPS : () => true; + for (const op of permToOps(perm).filter(filterExec)) { lines.push(`(allow ${op} ${matcher})`); } - for (const op of deniedOps(perm)) { + for (const op of deniedOps(perm).filter(filterExec)) { lines.push(`(deny ${op} ${matcher})`); } } @@ -274,15 +292,17 @@ export function generateSeatbeltProfile( lines.push("; Script-override grants/restrictions — emitted last, win over deny list"); for (const [pattern, perm] of overrideEntries) { for (const expanded of expandSbplAliases(pattern)) { - const matcher = patternToSbplMatcher(expanded, homeDir); - if (!matcher) { + const result = patternToSbplMatcher(expanded, homeDir, perm); + if (!result) { continue; } - for (const op of permToOps(perm)) { + const { matcher, approximate } = result; + const filterExec = approximate ? (op: string) => op !== SEATBELT_EXEC_OPS : () => true; + for (const op of permToOps(perm).filter(filterExec)) { lines.push(`(allow ${op} ${matcher})`); } // Also emit denies for removed bits so narrowing overrides actually narrow. - for (const op of deniedOps(perm)) { + for (const op of deniedOps(perm).filter(filterExec)) { lines.push(`(deny ${op} ${matcher})`); } }