auto-permission-system 3.6.0 → 3.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +10 -2
  2. package/extensions/guard.ts +17 -35
  3. package/extensions/permission-mode.ts +9 -5
  4. package/extensions/sandbox.ts +5 -18
  5. package/package.json +1 -1
  6. package/sandbox.example.json +10 -0
  7. package/skills/auto-permission-system/SKILL.md +17 -2
  8. package/src/core/grep-filter.ts +12 -12
  9. package/src/core/index.ts +4 -1
  10. package/src/core/policy/classify.ts +22 -10
  11. package/src/core/policy/commands.ts +17 -15
  12. package/src/core/policy/default-file.ts +8 -9
  13. package/src/core/policy/defaults.ts +9 -0
  14. package/src/core/policy/domains.ts +5 -5
  15. package/src/core/policy/fence.ts +44 -37
  16. package/src/core/policy/merge.ts +12 -12
  17. package/src/core/policy/mode.ts +17 -8
  18. package/src/core/policy/overrides.ts +56 -0
  19. package/src/core/policy/paths.ts +16 -0
  20. package/src/core/policy/patterns.ts +87 -51
  21. package/src/core/policy/{files.ts → policy-file.ts} +11 -6
  22. package/src/core/policy/subagent.ts +11 -0
  23. package/src/core/trust.ts +155 -92
  24. package/src/core/user-named.ts +36 -32
  25. package/src/detect/filename-gate/classify.ts +0 -9
  26. package/src/detect/mcp-gate/constants.ts +2 -0
  27. package/src/detect/mcp-gate/evaluate.ts +36 -22
  28. package/src/detect/mcp-gate/index.ts +2 -0
  29. package/src/detect/mcp-gate/signals.ts +2 -13
  30. package/src/detect/mcp-gate/verbs.ts +31 -9
  31. package/src/detect/sensitive-file-scanner/assignment.ts +3 -6
  32. package/src/detect/sensitive-file-scanner/content-scan.ts +0 -4
  33. package/src/detect/sensitive-file-scanner/file-scan.ts +0 -10
  34. package/src/detect/sensitive-file-scanner/jwt.ts +4 -5
  35. package/src/detect/sensitive-file-scanner/redaction.ts +0 -6
  36. package/src/detect/sensitive-file-scanner/value-analysis.ts +4 -15
  37. package/src/detect/tool-output-scanner/evaluate.ts +0 -1
  38. package/src/detect/tool-output-scanner/scan.ts +0 -2
  39. package/src/l1-sandbox/bash-ops.ts +192 -103
  40. package/src/l1-sandbox/config.ts +42 -68
  41. package/src/l1-sandbox/manager.ts +69 -37
  42. package/src/l1-sandbox/network-ask.ts +61 -52
  43. package/src/l2-guard/matching.ts +66 -94
  44. package/src/l2-guard/policy.ts +36 -40
  45. package/src/l2-guard/prompts.ts +61 -65
  46. package/src/l2-guard/subagent.ts +11 -11
  47. package/src/shared/audit.ts +0 -1
  48. package/src/shared/canonicalize.ts +29 -0
  49. package/src/shared/yolo.ts +0 -1
  50. package/src/ui/ask-flow.ts +160 -82
  51. package/src/ui/ask-selector.ts +28 -10
  52. package/src/ui/ask.ts +157 -126
@@ -17,10 +17,10 @@ export function hostnameOf(url: string): string | null {
17
17
  }
18
18
 
19
19
  export function domainMatches(host: string, pattern: string): boolean {
20
- const p = pattern.toLowerCase();
21
- if (p.startsWith("*.")) {
22
- const suffix = p.slice(1); // ".npmjs.org"
23
- return host === p.slice(2) || host.endsWith(suffix);
20
+ const normalizedPattern = pattern.toLowerCase();
21
+ if (normalizedPattern.startsWith("*.")) {
22
+ const suffix = normalizedPattern.slice(1);
23
+ return host === normalizedPattern.slice(2) || host.endsWith(suffix);
24
24
  }
25
- return host === p;
25
+ return host === normalizedPattern;
26
26
  }
@@ -1,9 +1,12 @@
1
+ /**
2
+ * Layer 1's pre-flight path scan (ADR-015) and the `filesystem` block handed
3
+ * to sandbox-runtime. Pure: no pi, no OS side effects.
4
+ */
5
+
1
6
  import { existsSync } from "node:fs";
2
7
  import { dirname } from "node:path";
3
8
  import { matchesPolicyPattern, normalizeAbs, toSandboxPatterns } from "./patterns";
4
- import { type FilesystemPolicy, outsideProjectMode, outsideProjectReadDenied } from "./classify";
5
-
6
- // ---------- Layer 1 pre-flight ask (ADR-015) ----------
9
+ import { ReadPosture, outsideProjectMode, outsideProjectReadDenied, type FilesystemPolicy } from "./classify";
7
10
 
8
11
  /**
9
12
  * Commands whose positional arguments are read targets. Layer 1's pre-flight
@@ -19,16 +22,16 @@ const READ_COMMANDS = new Set([
19
22
 
20
23
  /** Split a shell command on the operators that start a new simple command. */
21
24
  export function shellSegments(command: string): string[] {
22
- return command.split(/\s*(?:;|&&|\|\||\||\n)\s*/).map((s) => s.trim()).filter(Boolean);
25
+ return command.split(/\s*(?:;|&&|\|\||\||\n)\s*/).map((segment) => segment.trim()).filter(Boolean);
23
26
  }
24
27
 
25
28
  /** Tokenize one segment, dropping the quotes around fully quoted words. */
26
29
  export function shellTokens(segment: string): string[] {
27
- const out: string[] = [];
28
- const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
29
- let m: RegExpExecArray | null;
30
- while ((m = re.exec(segment))) out.push(m[1] ?? m[2] ?? m[3] ?? "");
31
- return out;
30
+ const tokens: string[] = [];
31
+ const tokenPattern = /"([^"]*)"|'([^']*)'|(\S+)/g;
32
+ let match: RegExpExecArray | null;
33
+ while ((match = tokenPattern.exec(segment))) tokens.push(match[1] ?? match[2] ?? match[3] ?? "");
34
+ return tokens;
32
35
  }
33
36
 
34
37
  /** Expand a leading `~`; other tokens are returned as-is. */
@@ -40,9 +43,9 @@ function expandHomeToken(token: string, home: string): string {
40
43
 
41
44
  /**
42
45
  * Absolute paths named by read-like segments of a bash command. Best-effort:
43
- * only read-like heads, only tokens that exist on disk. Bare names count (`.env`
44
- * has no slash); the caller's pattern match decides. Obfuscated reads are not
45
- * returned — the OS fence covers those.
46
+ * only read-like heads, only tokens that exist on disk. Bare names count
47
+ * (`.env` has no slash); the caller's pattern match decides. Obfuscated reads
48
+ * are not returned — the OS fence covers those.
46
49
  */
47
50
  function readPathCandidates(
48
51
  command: string,
@@ -56,21 +59,21 @@ function readPathCandidates(
56
59
  const head = (tokens[0] ?? "").split("/").pop() ?? "";
57
60
  if (!READ_COMMANDS.has(head)) continue;
58
61
  for (const raw of tokens.slice(1)) {
59
- const t = raw.trim();
60
- if (!t || t.startsWith("-") || /^[a-z][a-z0-9+.-]*:\/\//i.test(t)) continue;
61
- const expanded = expandHomeToken(t, home);
62
- const abs = expanded.startsWith("/")
62
+ const token = raw.trim();
63
+ if (!token || token.startsWith("-") || /^[a-z][a-z0-9+.-]*:\/\//i.test(token)) continue;
64
+ const expanded = expandHomeToken(token, home);
65
+ const absolute = expanded.startsWith("/")
63
66
  ? normalizeAbs(expanded)
64
67
  : normalizeAbs(`${cwd.replace(/\/+$/, "")}/${expanded}`);
65
- if (exists(abs)) found.add(abs);
68
+ if (exists(absolute)) found.add(absolute);
66
69
  }
67
70
  }
68
71
  return [...found];
69
72
  }
70
73
 
71
74
  /** Never a hard `denyRead` match, whatever the caller asks about. */
72
- function notDenied(abs: string, cwd: string, home: string, fs: FilesystemPolicy): boolean {
73
- return !fs.denyRead.some((pat) => matchesPolicyPattern(abs, pat, cwd, home));
75
+ function notDenied(absPath: string, cwd: string, home: string, filesystem: FilesystemPolicy): boolean {
76
+ return !filesystem.denyRead.some((pattern) => matchesPolicyPattern(absPath, pattern, cwd, home));
74
77
  }
75
78
 
76
79
  /**
@@ -81,11 +84,13 @@ export function outsideProjectReadCandidates(
81
84
  command: string,
82
85
  cwd: string,
83
86
  home: string,
84
- fs: FilesystemPolicy,
87
+ filesystem: FilesystemPolicy,
85
88
  exists: (path: string) => boolean = existsSync,
89
+ resolvePath: (absPath: string) => string = (absPath) => absPath,
86
90
  ): string[] {
87
- return readPathCandidates(command, cwd, home, exists).filter(
88
- (abs) => notDenied(abs, cwd, home, fs) && outsideProjectReadDenied(abs, cwd, home, fs),
91
+ const candidates = new Set(readPathCandidates(command, cwd, home, exists).map(resolvePath));
92
+ return [...candidates].filter(
93
+ (absPath) => notDenied(absPath, cwd, home, filesystem) && outsideProjectReadDenied(absPath, cwd, home, filesystem),
89
94
  );
90
95
  }
91
96
 
@@ -94,13 +99,15 @@ export function askReadCandidates(
94
99
  command: string,
95
100
  cwd: string,
96
101
  home: string,
97
- fs: FilesystemPolicy,
102
+ filesystem: FilesystemPolicy,
98
103
  exists: (path: string) => boolean = existsSync,
104
+ resolvePath: (absPath: string) => string = (absPath) => absPath,
99
105
  ): string[] {
100
- const ask = fs.askRead ?? [];
101
- if (!ask.length) return [];
102
- return readPathCandidates(command, cwd, home, exists).filter(
103
- (abs) => notDenied(abs, cwd, home, fs) && ask.some((pat) => matchesPolicyPattern(abs, pat, cwd, home)),
106
+ const askReadPatterns = filesystem.askRead ?? [];
107
+ if (!askReadPatterns.length) return [];
108
+ const candidates = new Set(readPathCandidates(command, cwd, home, exists).map(resolvePath));
109
+ return [...candidates].filter(
110
+ (absPath) => notDenied(absPath, cwd, home, filesystem) && askReadPatterns.some((pattern) => matchesPolicyPattern(absPath, pattern, cwd, home)),
104
111
  );
105
112
  }
106
113
 
@@ -117,20 +124,20 @@ export function askReadCandidates(
117
124
  * `outsideProject` never reached Layer 1 at all and `bash` read anything.
118
125
  */
119
126
  export function sandboxFilesystem(
120
- fs: FilesystemPolicy,
121
- opts: { cwd: string; home: string },
127
+ filesystem: FilesystemPolicy,
128
+ options: { cwd: string; home: string },
122
129
  ): { denyRead: string[]; allowRead: string[]; allowWrite: string[]; denyWrite: string[] } {
123
- const denyRead = toSandboxPatterns(fs.denyRead);
124
- const allowRead = toSandboxPatterns(fs.allowRead ?? []);
125
- if (outsideProjectMode(fs) !== "allow") {
126
- for (const fence of [opts.home, dirname(opts.cwd)]) {
130
+ const denyRead = toSandboxPatterns(filesystem.denyRead);
131
+ const allowRead = toSandboxPatterns(filesystem.allowRead ?? []);
132
+ if (outsideProjectMode(filesystem) !== ReadPosture.Allow) {
133
+ for (const fence of [options.home, dirname(options.cwd)]) {
127
134
  if (fence && fence !== "/" && !denyRead.includes(fence)) denyRead.push(fence);
128
135
  }
129
- const grants = [opts.cwd, ...fs.allowWrite, ...(fs.outsideProject?.allowRead ?? [])];
136
+ const grants = [options.cwd, ...filesystem.allowWrite, ...(filesystem.outsideProject?.allowRead ?? [])];
130
137
  for (const grant of grants) {
131
- const p = toSandboxPatterns([grant])[0];
132
- if (!allowRead.includes(p)) allowRead.push(p);
138
+ const pattern = toSandboxPatterns([grant])[0];
139
+ if (!allowRead.includes(pattern)) allowRead.push(pattern);
133
140
  }
134
141
  }
135
- return { denyRead, allowRead, allowWrite: [...fs.allowWrite], denyWrite: toSandboxPatterns(fs.denyWrite) };
142
+ return { denyRead, allowRead, allowWrite: [...filesystem.allowWrite], denyWrite: toSandboxPatterns(filesystem.denyWrite) };
136
143
  }
@@ -8,9 +8,9 @@
8
8
  * trust.ts) is the tighten-only merge for an untrusted project.
9
9
  */
10
10
 
11
- const isPlainObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
11
+ const isPlainObject = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
12
12
 
13
- const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []);
13
+ const stringList = (value: unknown): string[] => (Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []);
14
14
 
15
15
  /**
16
16
  * Overlay `top` onto `base`. Object keys merge shallowly; `overrides` is
@@ -18,22 +18,22 @@ const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is
18
18
  * base value. Absent keys keep the base, so a layer only states what it changes.
19
19
  */
20
20
  export function overlayPolicy<T extends object>(base: T, top: Partial<T>): T {
21
- const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
21
+ const merged: Record<string, unknown> = { ...(base as Record<string, unknown>) };
22
22
  for (const [key, value] of Object.entries(top as Record<string, unknown>)) {
23
23
  if (value === undefined) continue;
24
- const existing = out[key];
24
+ const existing = merged[key];
25
25
  if (key === "overrides" && isPlainObject(value)) {
26
- const b = isPlainObject(existing) ? existing : {};
27
- out[key] = {
28
- allowRead: [...strings(b.allowRead), ...strings(value.allowRead)],
29
- allowWrite: [...strings(b.allowWrite), ...strings(value.allowWrite)],
30
- allowDomains: [...strings(b.allowDomains), ...strings(value.allowDomains)],
26
+ const baseOverrides = isPlainObject(existing) ? existing : {};
27
+ merged[key] = {
28
+ allowRead: [...stringList(baseOverrides.allowRead), ...stringList(value.allowRead)],
29
+ allowWrite: [...stringList(baseOverrides.allowWrite), ...stringList(value.allowWrite)],
30
+ allowDomains: [...stringList(baseOverrides.allowDomains), ...stringList(value.allowDomains)],
31
31
  };
32
32
  } else if (isPlainObject(value) && isPlainObject(existing)) {
33
- out[key] = { ...existing, ...value };
33
+ merged[key] = { ...existing, ...value };
34
34
  } else {
35
- out[key] = value;
35
+ merged[key] = value;
36
36
  }
37
37
  }
38
- return out as T;
38
+ return merged as T;
39
39
  }
@@ -3,24 +3,33 @@
3
3
  * downgrades a rule. Pure.
4
4
  *
5
5
  * `yolo` is the weakest tier: every layer is off. `MODE_ORDER` is weakest →
6
- * strictest, which is what the untrusted-project merge uses to decide whether a
7
- * project file may change `mode` (it may only move up).
6
+ * strictest, which is what the untrusted-project merge uses to decide whether
7
+ * a project file may change `mode` (it may only move up).
8
8
  */
9
9
 
10
- export type PermissionMode = "yolo" | "default" | "advanced-secure";
10
+ /**
11
+ * Erasable enum: named constants whose runtime values are the JSON strings, so
12
+ * config files keep working. Node's type stripping cannot run a TS `enum`.
13
+ */
14
+ export const PermissionMode = {
15
+ Yolo: "yolo",
16
+ Default: "default",
17
+ AdvancedSecure: "advanced-secure",
18
+ } as const;
19
+ export type PermissionMode = (typeof PermissionMode)[keyof typeof PermissionMode];
11
20
 
12
- export const DEFAULT_MODE: PermissionMode = "default";
21
+ export const DEFAULT_MODE: PermissionMode = PermissionMode.Default;
13
22
 
14
23
  /** Weakest → strictest. Used by the untrusted-project tighten-only merge. */
15
- export const MODE_ORDER: readonly PermissionMode[] = ["yolo", "default", "advanced-secure"];
24
+ export const MODE_ORDER: readonly PermissionMode[] = [PermissionMode.Yolo, PermissionMode.Default, PermissionMode.AdvancedSecure];
16
25
 
17
26
  /** Anything that is not one of the known modes resolves to the default. */
18
27
  export function normalizeMode(value: unknown): PermissionMode {
19
- return value === "advanced-secure" || value === "yolo" ? value : DEFAULT_MODE;
28
+ return value === PermissionMode.AdvancedSecure || value === PermissionMode.Yolo ? value : DEFAULT_MODE;
20
29
  }
21
30
 
22
31
  export function modeLabel(mode: PermissionMode): string {
23
- if (mode === "advanced-secure") return "Advanced Secure";
24
- if (mode === "yolo") return "YOLO";
32
+ if (mode === PermissionMode.AdvancedSecure) return "Advanced Secure";
33
+ if (mode === PermissionMode.Yolo) return "YOLO";
25
34
  return "Default";
26
35
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Persisted ask-tier overrides (ADR-007). Both layers write the same policy
3
+ * file from their "remember" branch, so the read-modify-write lives here once:
4
+ * appending a grant must preserve the other override kinds and every
5
+ * hand-written key, and a trusted project file must stay trusted after its own
6
+ * grant is written.
7
+ */
8
+
9
+ import { mkdirSync, writeFileSync } from "node:fs";
10
+ import { dirname } from "node:path";
11
+ import { readPolicyForUpdate } from "./policy-file";
12
+ import { recordProjectTrust } from "../trust";
13
+
14
+ /** Erasable enum for the three additive grant lists in `overrides`. */
15
+ export const OverrideKind = {
16
+ AllowRead: "allowRead",
17
+ AllowWrite: "allowWrite",
18
+ AllowDomains: "allowDomains",
19
+ } as const;
20
+ export type OverrideKind = (typeof OverrideKind)[keyof typeof OverrideKind];
21
+
22
+ /** Erasable enum for where a grant is written. */
23
+ export const GrantScope = {
24
+ Cwd: "cwd",
25
+ Global: "global",
26
+ } as const;
27
+ export type GrantScope = (typeof GrantScope)[keyof typeof GrantScope];
28
+
29
+ type OverrideLists = Partial<Record<OverrideKind, string[]>>;
30
+
31
+ /**
32
+ * Append `value` to `overrides[kind]` in `path`, preserving every other key.
33
+ * Returns the path written. Throws when the file does not parse, so a
34
+ * hand-written policy is never overwritten blind.
35
+ */
36
+ export function addOverride(path: string, kind: OverrideKind, value: string): string {
37
+ const existing = readPolicyForUpdate(path) as { overrides?: OverrideLists };
38
+ const overrides = existing.overrides ?? {};
39
+ const list = overrides[kind] ?? [];
40
+ if (!list.includes(value)) list.push(value);
41
+ overrides[kind] = list;
42
+ existing.overrides = overrides;
43
+ mkdirSync(dirname(path), { recursive: true });
44
+ writeFileSync(path, `${JSON.stringify(existing, null, 2)}\n`);
45
+ return path;
46
+ }
47
+
48
+ /**
49
+ * `addOverride` for a trusted project file: record the new content hash, so the
50
+ * grant write itself does not invalidate the trust that allowed it (ADR-013).
51
+ */
52
+ export function addProjectOverride(projectPath: string, storePath: string, kind: OverrideKind, value: string): string {
53
+ const path = addOverride(projectPath, kind, value);
54
+ recordProjectTrust(projectPath, storePath);
55
+ return path;
56
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Canonical policy-file locations, shared by both layers. Keeping the strings
3
+ * here means the file a "remember" writes and the file the trust store keys
4
+ * can never drift apart.
5
+ */
6
+
7
+ import { join } from "node:path";
8
+
9
+ /** `<cwd>/.pi/sandbox.json` — the project-local policy (ADR-007, ADR-013). */
10
+ export const projectPolicyPath = (cwd: string): string => join(cwd, ".pi", "sandbox.json");
11
+
12
+ /** `<agentDir>/extensions/sandbox.json` — the global policy (and the persisted mode). */
13
+ export const globalPolicyPath = (agentDir: string): string => join(agentDir, "extensions", "sandbox.json");
14
+
15
+ /** `<agentDir>/extensions/sandbox.trust.json` — project-policy hashes (ADR-013). */
16
+ export const trustStorePath = (agentDir: string): string => join(agentDir, "extensions", "sandbox.trust.json");
@@ -1,16 +1,18 @@
1
1
  /**
2
2
  * Path pattern matching shared by both layers. Pure: no pi, no OS side effects.
3
+ *
4
+ * It also owns Layer 1's violation attribution: the evidence patterns that
5
+ * recognise a refused filesystem access and recover the operand path from a
6
+ * sandboxed command's output.
3
7
  */
4
8
 
5
- // ---------- Layer 1 violation attribution ----------
6
-
7
9
  /**
8
10
  * Filesystem-refusal evidence in a sandboxed command's output. Linux
9
11
  * bubblewrap reports a write outside `allowWrite` as `EROFS: read-only file
10
12
  * system`; macOS sandbox-exec reports the same fence as `Operation not
11
13
  * permitted`; EACCES covers a permission-denied variant either can print.
12
14
  */
13
- const FS_BLOCKED_RE = /operation not permitted|EPERM|EACCES|EROFS|read-only file system/i;
15
+ const BLOCKED_ACCESS_RE = /operation not permitted|EPERM|EACCES|EROFS|read-only file system/i;
14
16
 
15
17
  /**
16
18
  * `EROFS` can only come from a modification, so it proves the refused access
@@ -21,7 +23,7 @@ const WRITE_BLOCK_RE = /EROFS|read-only file system/i;
21
23
 
22
24
  /** True when the output shows the OS sandbox refused a filesystem access. */
23
25
  export function isBlockedAccessError(output: string): boolean {
24
- return FS_BLOCKED_RE.test(output);
26
+ return BLOCKED_ACCESS_RE.test(output);
25
27
  }
26
28
 
27
29
  /** True when the refused access was a write (EROFS only happens on a modification). */
@@ -30,56 +32,90 @@ export function isWriteBlockError(output: string): boolean {
30
32
  }
31
33
 
32
34
  /** A shell-name/empty token is not a path; everything else resolves against cwd unless absolute. */
33
- function toAbsPath(raw: string, cwd: string, home: string): string | undefined {
34
- let token = raw.trim().replace(/[.,;]+$/, "");
35
+ function toAbsolutePath(rawToken: string, cwd: string, home: string): string | undefined {
36
+ let token = rawToken.trim().replace(/[.,;]+$/, "");
35
37
  if (!token || /^(bash|sh|zsh)$/.test(token)) return undefined;
36
38
  if (token === "~" || token.startsWith("~/")) token = home + token.slice(1);
37
- const abs = token.startsWith("/") ? token : `${cwd.replace(/\/+$/, "")}/${token}`;
38
- return normalizeAbs(abs);
39
+ const absolute = token.startsWith("/") ? token : `${cwd.replace(/\/+$/, "")}/${token}`;
40
+ return normalizeAbs(absolute);
41
+ }
42
+
43
+ /**
44
+ * Strip the diagnostic lead-in that precedes an unquoted operand so the whole
45
+ * operand survives (`open /path with spaces/file` -> `/path with spaces/file`).
46
+ * Handles the `[pid:...]` prefix runtimes print before errno-style messages and
47
+ * the coreutils verbs (`cannot touch`, `cannot remove`, ...).
48
+ */
49
+ function stripErrnoLeadIn(text: string): string {
50
+ const withoutPidPrefix = text.replace(/^\[[^\]]*\]\s*/, "");
51
+ return withoutPidPrefix
52
+ .replace(/^bwrap:\s*/i, "")
53
+ .replace(/^(?:open|cannot (?:touch|remove|create|open|stat|access)|(?:can't|failed to) create file|touch|creat|unlink|mkdir|rmdir|rename)\s+/i, "")
54
+ .trim();
55
+ }
56
+
57
+ /**
58
+ * Whether a recovered operand really names a file. Operation words that appear
59
+ * in diagnostics (`socket`, `display`, `failed`, `Failed to open socket`) are
60
+ * not paths; a path either is rooted, contains a separator, or carries an
61
+ * extension. A candidate with whitespace must contain a separator, so a
62
+ * sentence like `. socket() failed` is rejected while `Crash Reports/x.dmp`
63
+ * (an unquoted path with a space) is kept.
64
+ */
65
+ function looksLikePath(token: string): boolean {
66
+ if (!token) return false;
67
+ if (/\s/.test(token)) return token.includes("/");
68
+ if (token.startsWith("/") || token.startsWith("~")) return true;
69
+ if (token.startsWith(".")) return true;
70
+ return token.includes("/") || token.includes(".");
39
71
  }
40
72
 
41
73
  /**
42
- * The path a sandboxed bash command was refused, from its error output, as
43
- * an absolute path. Handles `tool: ./rel: Operation not permitted`, quoted
44
- * paths (`cannot touch 'x.pem'`), `~/…` and absolute paths, plus the
45
- * errno-style forms that put the path *after* the error text
74
+ * The path a sandboxed bash command was refused, from its error output, as an
75
+ * absolute path. Handles `tool: ./rel: Operation not permitted`, quoted paths
76
+ * (`cannot touch 'x.pem'`), `~/…` and absolute paths, plus the errno-style
77
+ * forms that put the path *after* the error text
46
78
  * (`EROFS: read-only file system, open '/abs/path'`,
47
- * `[Errno 30] Read-only file system: '/abs/path'`). Relative paths resolve
48
- * against the command's working directory, never against `/`.
79
+ * `[Errno 30] Read-only file system: '/abs/path'`). A path with spaces keeps
80
+ * its spaces (`open /…/Crash Reports/new/x.dmp`) while operation words
81
+ * (`socket`, `display`) are not mistaken for files. A `bwrap: Can't create
82
+ * file <path>` line is the OS sandbox failing to build its own mount mask, but
83
+ * its path is still recovered so the caller can report it as a setup problem.
84
+ * Relative paths resolve against the command's working directory, never
85
+ * against `/`.
49
86
  */
50
87
  export function extractBlockedPath(output: string, cwd: string, home: string): string | undefined {
51
- for (const line of output.split("\n").reverse()) {
52
- const m = FS_BLOCKED_RE.exec(line);
53
- if (!m) continue;
54
- // errno-style messages (Node, Python, coreutils) put the operand in
55
- // quotes, usually after the error token: take the last quoted token.
56
- const quoted = [...line.matchAll(/['"“‘`]([^'"”’`]+)['"”’`]/g)].map((q) => q[1] as string);
57
- const quotedPath = quoted.length ? toAbsPath(quoted[quoted.length - 1] as string, cwd, home) : undefined;
88
+ for (const line of [...output.split("\n")].reverse()) {
89
+ const match = BLOCKED_ACCESS_RE.exec(line);
90
+ if (!match) continue;
91
+ const quotedTokens = [...line.matchAll(/['"“‘`]([^'"”’`]+)['"”’`]/g)].map((quote) => quote[1] as string);
92
+ const quotedPath = toAbsolutePath(quotedTokens.at(-1) ?? "", cwd, home);
58
93
  if (quotedPath) return quotedPath;
59
- // The segment right before the error text, e.g. "grep: ./.env: Operation…" → "./.env".
60
- const before = line.slice(0, m.index).replace(/[\s:]+$/, "");
61
- const segment = before.split(/:\s+/).pop() ?? "";
62
- const token = toAbsPath(segment.split(/\s+/).pop() ?? "", cwd, home);
63
- if (token) return token;
94
+ const beforeError = line.slice(0, match.index).replace(/[\s:]+$/, "");
95
+ const candidate = stripErrnoLeadIn(beforeError.split(/:\s+/).pop() ?? "");
96
+ if (!looksLikePath(candidate)) continue;
97
+ const resolved = toAbsolutePath(candidate, cwd, home);
98
+ if (resolved) return resolved;
64
99
  }
65
100
  return undefined;
66
101
  }
67
102
 
68
- export function normalizeAbs(p: string): string {
69
- const out: string[] = [];
70
- for (const part of p.split("/")) {
103
+ /** Collapse `.` and `..` segments to a canonical absolute path (no filesystem access). */
104
+ export function normalizeAbs(path: string): string {
105
+ const parts: string[] = [];
106
+ for (const part of path.split("/")) {
71
107
  if (!part || part === ".") continue;
72
- if (part === "..") out.pop();
73
- else out.push(part);
108
+ if (part === "..") parts.pop();
109
+ else parts.push(part);
74
110
  }
75
- return `/${out.join("/")}`;
111
+ return `/${parts.join("/")}`;
76
112
  }
77
113
 
78
114
  /** A folder Layer 1 may offer as a one-click write grant: never `/`, the home folder, or anything above it. */
79
- export function isSafeFolderGrant(dir: string, home: string): boolean {
80
- const d = normalizeAbs(dir);
81
- const h = normalizeAbs(home);
82
- return d !== "/" && d !== h && !h.startsWith(`${d}/`);
115
+ export function isSafeFolderGrant(directory: string, home: string): boolean {
116
+ const normalizedDirectory = normalizeAbs(directory);
117
+ const normalizedHome = normalizeAbs(home);
118
+ return normalizedDirectory !== "/" && normalizedDirectory !== normalizedHome && !normalizedHome.startsWith(`${normalizedDirectory}/`);
83
119
  }
84
120
 
85
121
  /**
@@ -88,26 +124,26 @@ export function isSafeFolderGrant(dir: string, home: string): boolean {
88
124
  * `.` is the project root, anything else matches the basename (glob allowed).
89
125
  */
90
126
  export function matchesPolicyPattern(absPath: string, pattern: string, cwd: string, home: string): boolean {
91
- let p = pattern;
92
- if (p === "~") p = home;
93
- else if (p.startsWith("~/")) p = `${home}/${p.slice(2)}`;
94
- const ci = process.platform === "darwin" ? "i" : "";
95
- const glob = (s: string) => {
96
- const body = s
127
+ let expandedPattern = pattern;
128
+ if (expandedPattern === "~") expandedPattern = home;
129
+ else if (expandedPattern.startsWith("~/")) expandedPattern = `${home}/${expandedPattern.slice(2)}`;
130
+ const caseInsensitiveFlag = process.platform === "darwin" ? "i" : "";
131
+ const compileGlob = (source: string): RegExp => {
132
+ const body = source
97
133
  .replace(/[.+^${}()|[\]\\]/g, "\\$&")
98
134
  .split("**")
99
135
  .map((part) => part.replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]"))
100
136
  .join(".*");
101
- return new RegExp(`^${body}$`, ci);
137
+ return new RegExp(`^${body}$`, caseInsensitiveFlag);
102
138
  };
103
- if (p === ".") return absPath === cwd || absPath.startsWith(`${cwd}/`);
104
- if (p.startsWith("/")) {
105
- if (p.includes("*")) return glob(p).test(absPath);
106
- return absPath === p || absPath.startsWith(`${p}/`);
139
+ if (expandedPattern === ".") return absPath === cwd || absPath.startsWith(`${cwd}/`);
140
+ if (expandedPattern.startsWith("/")) {
141
+ if (expandedPattern.includes("*")) return compileGlob(expandedPattern).test(absPath);
142
+ return absPath === expandedPattern || absPath.startsWith(`${expandedPattern}/`);
107
143
  }
108
- const base = absPath.slice(absPath.lastIndexOf("/") + 1);
109
- if (p.includes("*")) return glob(p).test(base);
110
- return ci ? base.toLowerCase() === p.toLowerCase() : base === p;
144
+ const basename = absPath.slice(absPath.lastIndexOf("/") + 1);
145
+ if (expandedPattern.includes("*")) return compileGlob(expandedPattern).test(basename);
146
+ return caseInsensitiveFlag ? basename.toLowerCase() === expandedPattern.toLowerCase() : basename === expandedPattern;
111
147
  }
112
148
 
113
149
  /**
@@ -121,5 +157,5 @@ export function matchesPolicyPattern(absPath: string, pattern: string, cwd: stri
121
157
  * are passed through unchanged.
122
158
  */
123
159
  export function toSandboxPatterns(patterns: readonly string[]): string[] {
124
- return patterns.map((p) => (p === "." || p.includes("/") || p.startsWith("~") ? p : `**/${p}`));
160
+ return patterns.map((pattern) => (pattern === "." || pattern.includes("/") || pattern.startsWith("~") ? pattern : `**/${pattern}`));
125
161
  }
@@ -1,3 +1,8 @@
1
+ /**
2
+ * Reading and updating a policy file in place. Shared by both layers so the
3
+ * parse guard and the mode write cannot drift.
4
+ */
5
+
1
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
7
  import { dirname } from "node:path";
3
8
  import { normalizeMode, type PermissionMode } from "./mode";
@@ -16,8 +21,8 @@ export function policyFileError(path: string): string | null {
16
21
  try {
17
22
  JSON.parse(readFileSync(path, "utf-8"));
18
23
  return null;
19
- } catch (e) {
20
- return e instanceof Error ? e.message : String(e);
24
+ } catch (error) {
25
+ return error instanceof Error ? error.message : String(error);
21
26
  }
22
27
  }
23
28
 
@@ -30,15 +35,15 @@ export function readPolicyForUpdate(path: string): Record<string, unknown> {
30
35
  if (!existsSync(path)) return {};
31
36
  try {
32
37
  return JSON.parse(readFileSync(path, "utf-8"));
33
- } catch (e) {
34
- throw new Error(`refusing to overwrite ${path}: it does not parse as JSON (${e instanceof Error ? e.message : e})`);
38
+ } catch (error) {
39
+ throw new Error(`refusing to overwrite ${path}: it does not parse as JSON (${error instanceof Error ? error.message : error})`);
35
40
  }
36
41
  }
37
42
 
38
43
  /**
39
44
  * Set `mode` in a policy file in place, preserving its other fields (ADR-018).
40
- * Used by `/permission-mode` and the Shift+S shortcut. Creates the file when it
41
- * is absent; refuses to overwrite one that does not parse.
45
+ * Used by `/permission-mode` and the Shift+S shortcut. Creates the file when
46
+ * it is absent; refuses to overwrite one that does not parse.
42
47
  */
43
48
  export function setPolicyMode(path: string, mode: PermissionMode): void {
44
49
  const policy = readPolicyForUpdate(path);
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Layer 3 vocabulary: the `subagent.network` posture (ADR-005). Pure.
3
+ */
4
+
5
+ /** Erasable enum for `subagent.network`. */
6
+ export const SubagentNetwork = {
7
+ Allow: "allow",
8
+ Deny: "deny",
9
+ ResearchOnly: "research-only",
10
+ } as const;
11
+ export type SubagentNetwork = (typeof SubagentNetwork)[keyof typeof SubagentNetwork];