fix(access-policy): mid-path wildcard OS enforcement, structural validation, doc cleanup

This commit is contained in:
subrih 2026-03-14 07:11:17 -07:00
parent cd2d9c3b8d
commit 77beb444bc
8 changed files with 187 additions and 54 deletions

View File

@ -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.

View File

@ -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<string, unknown>, 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<string, unknown>)) {
if (typeof block !== "object" || block === null || Array.isArray(block)) {
errors.push(`${filePath}: agents["${agentId}"] must be an object`);
} else {
checkRemovedKeys(block as Record<string, unknown>, `agents["${agentId}"]`);
}
}
}
}
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) {

View File

@ -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.

View File

@ -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 {

View File

@ -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.

View File

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

View File

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

View File

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