mirror of https://github.com/openclaw/openclaw.git
fix(access-policy): mid-path wildcard OS enforcement, structural validation, doc cleanup
This commit is contained in:
parent
cd2d9c3b8d
commit
77beb444bc
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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})`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue