auto-permission-system 3.0.1 → 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 (58) hide show
  1. package/README.md +16 -7
  2. package/extensions/guard.ts +63 -37
  3. package/extensions/permission-mode.ts +9 -5
  4. package/extensions/sandbox.ts +34 -16
  5. package/package.json +3 -2
  6. package/sandbox.example.json +10 -0
  7. package/skills/auto-permission-system/SKILL.md +25 -5
  8. package/src/core/grep-filter.ts +12 -12
  9. package/src/core/index.ts +5 -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 +26 -0
  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 +211 -105
  40. package/src/l1-sandbox/config.ts +42 -68
  41. package/src/l1-sandbox/manager.ts +119 -38
  42. package/src/l1-sandbox/network-ask.ts +207 -0
  43. package/src/l2-guard/exposure.ts +13 -3
  44. package/src/l2-guard/matching.ts +66 -94
  45. package/src/l2-guard/policy.ts +36 -40
  46. package/src/l2-guard/program-grants.ts +35 -0
  47. package/src/l2-guard/program-id.ts +37 -0
  48. package/src/l2-guard/prompts.ts +65 -63
  49. package/src/l2-guard/subagent.ts +11 -11
  50. package/src/l2-guard/url.ts +12 -17
  51. package/src/shared/audit.ts +0 -1
  52. package/src/shared/canonicalize.ts +29 -0
  53. package/src/shared/network-grants.ts +31 -0
  54. package/src/shared/yolo.ts +0 -1
  55. package/src/ui/ask-flow.ts +425 -47
  56. package/src/ui/ask-selector.ts +245 -0
  57. package/src/ui/ask.ts +280 -0
  58. package/src/l2-guard/ask.ts +0 -95
package/src/core/index.ts CHANGED
@@ -3,14 +3,18 @@
3
3
  * Both layer adapters and the unit tests import from here.
4
4
  */
5
5
  export * from "./policy/defaults";
6
+ export * from "./policy/domains";
6
7
  export * from "./policy/default-file";
7
- export * from "./policy/files";
8
+ export * from "./policy/policy-file";
8
9
  export * from "./policy/merge";
9
10
  export * from "./policy/mode";
11
+ export * from "./policy/overrides";
12
+ export * from "./policy/paths";
10
13
  export * from "./policy/patterns";
11
14
  export * from "./policy/classify";
12
15
  export * from "./policy/commands";
13
16
  export * from "./policy/fence";
17
+ export * from "./policy/subagent";
14
18
  export * from "./grep-filter";
15
19
  export * from "./trust";
16
20
  export * from "./user-named";
@@ -1,6 +1,18 @@
1
+ /**
2
+ * The filesystem slice both layers read, plus the outside-project boundary
3
+ * (ADR-012) it is enforced through. Pure: no pi, no OS side effects.
4
+ */
5
+
1
6
  import { matchesPolicyPattern } from "./patterns";
2
7
 
3
- /** The filesystem slice both layers read, with the Layer-2-only fields. */
8
+ /** Erasable enum for `filesystem.outsideProject.read`. */
9
+ export const ReadPosture = {
10
+ Allow: "allow",
11
+ Ask: "ask",
12
+ Deny: "deny",
13
+ } as const;
14
+ export type ReadPosture = (typeof ReadPosture)[keyof typeof ReadPosture];
15
+
4
16
  export interface FilesystemPolicy {
5
17
  denyRead: readonly string[];
6
18
  allowWrite: readonly string[];
@@ -8,23 +20,23 @@ export interface FilesystemPolicy {
8
20
  allowRead?: readonly string[];
9
21
  /** Paths that prompt on read instead of being hard-denied (ADR-019). */
10
22
  askRead?: readonly string[];
11
- /** Reads outside the project: "allow" (default), "ask" or "deny" (ADR-012). */
12
- outsideProject?: { read?: "allow" | "ask" | "deny"; allowRead?: string[] };
23
+ /** Reads outside the project: `allow` (default), `ask` or `deny` (ADR-012). */
24
+ outsideProject?: { read?: ReadPosture; allowRead?: string[] };
13
25
  }
14
26
 
15
- export function outsideProjectMode(fs: FilesystemPolicy): "allow" | "ask" | "deny" {
16
- return fs.outsideProject?.read ?? "allow";
27
+ export function outsideProjectMode(filesystem: FilesystemPolicy): ReadPosture {
28
+ return filesystem.outsideProject?.read ?? ReadPosture.Allow;
17
29
  }
18
30
 
19
31
  /**
20
32
  * Whether `absPath` is an outside-the-project read the policy wants to gate:
21
33
  * outside cwd, not under an allowWrite / allowRead / outsideProject.allowRead
22
- * root, and mode !== "allow". Pure; mirrors Layer 2's outsideProjectReason for
34
+ * root, and mode !== `allow`. Pure; mirrors Layer 2's outsideProjectReason for
23
35
  * callers (and tests) that cannot import security-guard.ts.
24
36
  */
25
- export function outsideProjectReadDenied(absPath: string, cwd: string, home: string, fs: FilesystemPolicy): boolean {
26
- if (outsideProjectMode(fs) === "allow") return false;
37
+ export function outsideProjectReadDenied(absPath: string, cwd: string, home: string, filesystem: FilesystemPolicy): boolean {
38
+ if (outsideProjectMode(filesystem) === ReadPosture.Allow) return false;
27
39
  if (absPath === cwd || absPath.startsWith(`${cwd}/`)) return false;
28
- const allowed = [...fs.allowWrite, ...(fs.allowRead ?? []), ...(fs.outsideProject?.allowRead ?? [])];
29
- return !allowed.some((pat) => matchesPolicyPattern(absPath, pat, cwd, home));
40
+ const allowed = [...filesystem.allowWrite, ...(filesystem.allowRead ?? []), ...(filesystem.outsideProject?.allowRead ?? [])];
41
+ return !allowed.some((pattern) => matchesPolicyPattern(absPath, pattern, cwd, home));
30
42
  }
@@ -1,19 +1,21 @@
1
- /** Commands/paths that can print secrets and must ask first (ADR-017). Pure. */
1
+ /**
2
+ * Commands/paths that can print secrets and must ask first (ADR-017). Pure.
3
+ */
2
4
 
3
5
  import { resolve } from "node:path";
4
6
  import { shellSegments, shellTokens } from "./fence";
5
7
  import { matchesPolicyPattern, normalizeAbs } from "./patterns";
6
8
 
7
9
  /** Expand a leading `~`, then resolve relative to cwd. Returns null for non-paths. */
8
- function asAbsolute(token: string, cwd: string, home: string): string | null {
9
- const t = token.trim();
10
- if (!t || t.startsWith("-")) return null;
11
- let p = t;
12
- if (p === "~") p = home;
13
- else if (p.startsWith("~/")) p = `${home}/${p.slice(2)}`;
14
- else if (!p.startsWith("/") && !p.startsWith("./") && !p.startsWith("../") && !p.includes("/")) return null;
15
- const abs = p.startsWith("/") ? p : resolve(cwd, p);
16
- return normalizeAbs(abs);
10
+ function asAbsolutePath(rawToken: string, cwd: string, home: string): string | null {
11
+ const token = rawToken.trim();
12
+ if (!token || token.startsWith("-")) return null;
13
+ let path = token;
14
+ if (path === "~") path = home;
15
+ else if (path.startsWith("~/")) path = `${home}/${path.slice(2)}`;
16
+ else if (!path.startsWith("/") && !path.startsWith("./") && !path.startsWith("../") && !path.includes("/")) return null;
17
+ const absolute = path.startsWith("/") ? path : resolve(cwd, path);
18
+ return normalizeAbs(absolute);
17
19
  }
18
20
 
19
21
  /**
@@ -22,18 +24,18 @@ function asAbsolute(token: string, cwd: string, home: string): string | null {
22
24
  * path token in the command, so a read of the per-process environ file is
23
25
  * caught whatever command performs it.
24
26
  */
25
- export function matchedAskCommands(command: string, ask: readonly string[], cwd: string, home: string): string[] {
27
+ export function matchedAskCommands(command: string, askEntries: readonly string[], cwd: string, home: string): string[] {
26
28
  const found = new Set<string>();
27
- if (!ask.length) return [];
29
+ if (!askEntries.length) return [];
28
30
  for (const segment of shellSegments(command)) {
29
31
  const tokens = shellTokens(segment);
30
32
  const head = (tokens[0] ?? "").split("/").pop() ?? "";
31
- for (const entry of ask) {
33
+ for (const entry of askEntries) {
32
34
  if (!entry) continue;
33
35
  if (entry.includes("/") || entry.startsWith("~")) {
34
36
  for (const raw of tokens.slice(1)) {
35
- const abs = asAbsolute(raw, cwd, home);
36
- if (abs && matchesPolicyPattern(abs, entry, cwd, home)) found.add(`${head} ${raw}`.trim());
37
+ const absolute = asAbsolutePath(raw, cwd, home);
38
+ if (absolute && matchesPolicyPattern(absolute, entry, cwd, home)) found.add(`${head} ${raw}`.trim());
37
39
  }
38
40
  } else if (head === entry) {
39
41
  found.add(entry);
@@ -4,8 +4,8 @@
4
4
  * global/project files tune it on top.
5
5
  *
6
6
  * Robust by design: if the file is missing or unparseable the caller falls back
7
- * to the constants in `defaults.ts`, so a broken package layout never leaves the
8
- * sandbox wide open.
7
+ * to the constants in `defaults.ts`, so a broken package layout never leaves
8
+ * the sandbox wide open.
9
9
  */
10
10
 
11
11
  import { existsSync, readFileSync } from "node:fs";
@@ -14,13 +14,13 @@ import { fileURLToPath } from "node:url";
14
14
 
15
15
  /** Walk up from this module looking for the file the package ships. */
16
16
  function findDefaultFile(): string | null {
17
- let dir = dirname(fileURLToPath(import.meta.url));
18
- for (let i = 0; i < 6; i++) {
19
- const candidate = join(dir, "sandbox.default.json");
17
+ let directory = dirname(fileURLToPath(import.meta.url));
18
+ for (let depth = 0; depth < 6; depth++) {
19
+ const candidate = join(directory, "sandbox.default.json");
20
20
  if (existsSync(candidate)) return candidate;
21
- const parent = dirname(dir);
22
- if (parent === dir) break;
23
- dir = parent;
21
+ const parent = dirname(directory);
22
+ if (parent === directory) break;
23
+ directory = parent;
24
24
  }
25
25
  return null;
26
26
  }
@@ -37,7 +37,6 @@ export function loadDefaultPolicy(): Record<string, unknown> | null {
37
37
  const parsed = JSON.parse(readFileSync(path, "utf-8"));
38
38
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) resolved = parsed;
39
39
  } catch {
40
- /* fall back to the caller's constants */
41
40
  }
42
41
  }
43
42
  cached = resolved;
@@ -8,3 +8,12 @@
8
8
  export const DEFAULT_DENY_READ = ["~/.ssh", "~/.aws", "~/.gnupg", "~/.pi/agent"];
9
9
  export const DEFAULT_ALLOW_WRITE = [".", "/tmp"];
10
10
  export const DEFAULT_DENY_WRITE = [".env", ".env.*", "*.pem", "*.key"];
11
+
12
+ /** Domains every install may reach without a prompt; the shipped baseline adds more. */
13
+ export const BUILTIN_NETWORK_ALLOWED = [
14
+ "npmjs.org", "*.npmjs.org",
15
+ "registry.npmjs.org", "registry.yarnpkg.com",
16
+ "pypi.org", "*.pypi.org",
17
+ "github.com", "*.github.com",
18
+ "api.github.com", "raw.githubusercontent.com",
19
+ ];
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Domain pattern matching shared by both layers. Pure: no pi, no OS.
3
+ *
4
+ * `hostnameOf` parses a URL to its lowercase host; `domainMatches` implements
5
+ * the allow-list syntax used by `allowedDomains` / `deniedDomains` / grants:
6
+ * an exact host, or `*.domain` which also matches the apex. Port suffixes
7
+ * (`host:443`) are a sandbox-runtime config feature only; this matcher is
8
+ * hostname-only, which is what both layers store in policy.
9
+ */
10
+
11
+ export function hostnameOf(url: string): string | null {
12
+ try {
13
+ return new URL(url).hostname.toLowerCase();
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ export function domainMatches(host: string, pattern: string): boolean {
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
+ }
25
+ return host === normalizedPattern;
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");