auto-permission-system 3.0.0 → 3.6.0

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.
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Layer 1 sandbox-runtime lifecycle: initialize, reset/reload, and persist an
3
- * ask-tier "always" grant into sandbox.json.
2
+ * Layer 1 sandbox-runtime lifecycle: initialize, reset/reload, persist an
3
+ * ask-tier "always" grant into sandbox.json, and apply network grants live.
4
4
  *
5
5
  * Adapter module: owns the SandboxManager singleton and the on-disk policy.
6
6
  */
@@ -8,13 +8,15 @@
8
8
  import { mkdirSync, writeFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import { join } from "node:path";
11
- import { SandboxManager } from "@anthropic-ai/sandbox-runtime";
11
+ import { SandboxManager, type SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
12
12
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
13
13
  import { readPolicyForUpdate, recordProjectTrust, sandboxFilesystem } from "../core/index";
14
14
  import { loadConfig, projectTrusted, TRUST_STORE, type SandboxConfig } from "./config";
15
+ import { createNetworkAsk, type NetworkAskDeps } from "./network-ask";
15
16
 
16
- export async function initSandbox(cwd: string, config: SandboxConfig): Promise<void> {
17
- await SandboxManager.initialize({
17
+ /** The sandbox-runtime view of a SandboxConfig (pi-only fields stripped). */
18
+ function runtimeConfig(cwd: string, config: SandboxConfig): SandboxRuntimeConfig {
19
+ return {
18
20
  network: config.network,
19
21
  // Strip pi-only fields (modelDenyRead, _comment_*) so SandboxManager
20
22
  // doesn't see keys it doesn't understand; modelDenyRead is enforced by
@@ -25,7 +27,16 @@ export async function initSandbox(cwd: string, config: SandboxConfig): Promise<v
25
27
  : { denyRead: [], allowRead: [], allowWrite: [], denyWrite: [], disabled: true },
26
28
  ignoreViolations: config.ignoreViolations,
27
29
  enableWeakerNestedSandbox: config.enableWeakerNestedSandbox,
28
- });
30
+ };
31
+ }
32
+
33
+ /**
34
+ * `deps` registers the unknown-domain ask callback (ADR-023). Without it the
35
+ * proxy hard-denies hosts outside `allowedDomains` (sandbox-runtime's own
36
+ * behavior when no callback is registered).
37
+ */
38
+ export async function initSandbox(cwd: string, config: SandboxConfig, deps?: NetworkAskDeps): Promise<void> {
39
+ await SandboxManager.initialize(runtimeConfig(cwd, config), deps ? createNetworkAsk(deps) : undefined);
29
40
  }
30
41
 
31
42
  export async function resetSandbox(): Promise<void> {
@@ -36,17 +47,35 @@ export async function resetSandbox(): Promise<void> {
36
47
  }
37
48
  }
38
49
 
39
- export async function reloadSandbox(cwd: string): Promise<void> {
50
+ export async function reloadSandbox(cwd: string, deps?: NetworkAskDeps): Promise<void> {
40
51
  const config = loadConfig(cwd);
41
52
  await resetSandbox();
42
- await initSandbox(cwd, config);
53
+ await initSandbox(cwd, config, deps);
43
54
  }
44
55
 
45
- /** Persist an "always" Layer 1 override (scope: cwd or global) and live-reload SandboxManager. */
46
- export async function persistLayer1Override(
56
+ /**
57
+ * Add a host to the running proxy's allowlist in place. `updateConfig` swaps
58
+ * the config the proxy reads per request; a `reset()`/`initialize()` here
59
+ * would tear down the proxy that is serving the request being approved.
60
+ */
61
+ export function applyNetworkGrant(host: string): void {
62
+ const current = SandboxManager.getConfig();
63
+ if (!current) return;
64
+ const pattern = host.toLowerCase();
65
+ if (current.network.allowedDomains.some((p) => p.toLowerCase() === pattern)) return;
66
+ SandboxManager.updateConfig({
67
+ ...current,
68
+ network: { ...current.network, allowedDomains: [...current.network.allowedDomains, pattern] },
69
+ });
70
+ }
71
+
72
+ type Layer1OverrideKind = "allowWrite" | "allowRead" | "allowDomains";
73
+
74
+ /** File write only — the caller decides whether (and how) to apply it live. */
75
+ async function writeLayer1Override(
47
76
  cwd: string,
48
- kind: "allowWrite" | "allowRead",
49
- absPath: string,
77
+ kind: Layer1OverrideKind,
78
+ value: string,
50
79
  scope: "cwd" | "global",
51
80
  ): Promise<string> {
52
81
  const { dir, path } =
@@ -59,15 +88,35 @@ export async function persistLayer1Override(
59
88
  throw new Error(`${path} is not trusted; run /security trust first, or choose an "ALL projects" option`);
60
89
  }
61
90
  // Throws on an unparseable file: never overwrite a hand-written policy we could not read.
62
- const existing = readPolicyForUpdate(path) as { overrides?: { allowWrite?: string[]; allowRead?: string[] } };
91
+ const existing = readPolicyForUpdate(path) as { overrides?: Partial<Record<Layer1OverrideKind, string[]>> };
63
92
  const overrides = existing.overrides ?? {};
64
93
  const list = overrides[kind] ?? [];
65
- if (!list.includes(absPath)) list.push(absPath);
94
+ if (!list.includes(value)) list.push(value);
66
95
  overrides[kind] = list;
67
96
  existing.overrides = overrides;
68
97
  mkdirSync(dir, { recursive: true });
69
98
  writeFileSync(path, `${JSON.stringify(existing, null, 2)}\n`);
70
99
  if (scope === "cwd") recordProjectTrust(path, TRUST_STORE);
71
- await reloadSandbox(cwd);
100
+ return path;
101
+ }
102
+
103
+ /**
104
+ * Write an "always" domain grant (file only). The network ask applies it live
105
+ * with `applyNetworkGrant` right after, so the in-flight request survives.
106
+ */
107
+ export async function writeNetworkOverride(cwd: string, host: string, scope: "cwd" | "global"): Promise<string> {
108
+ return writeLayer1Override(cwd, "allowDomains", host, scope);
109
+ }
110
+
111
+ /** Persist an "always" Layer 1 override (scope: cwd or global) and live-reload SandboxManager. */
112
+ export async function persistLayer1Override(
113
+ cwd: string,
114
+ kind: "allowWrite" | "allowRead",
115
+ absPath: string,
116
+ scope: "cwd" | "global",
117
+ deps?: NetworkAskDeps,
118
+ ): Promise<string> {
119
+ const path = await writeLayer1Override(cwd, kind, absPath, scope);
120
+ await reloadSandbox(cwd, deps);
72
121
  return path;
73
122
  }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Layer 1 network ask (ADR-023): the sandbox-runtime proxy asks before it
3
+ * denies a host that matched neither `allowedDomains` nor `deniedDomains`.
4
+ * Uses the same two-screen ask-tier as Layer 2 (once / session / remember
5
+ * project|global) and the same grant semantics:
6
+ *
7
+ * - session grants: in memory only, cleared at `session_start`, never saved;
8
+ * - "once" grants: one bash command's connections to that host (the proxy
9
+ * calls back per request, so a redirect or a parallel connection must not
10
+ * re-prompt), scoped by `beginNetworkCommand`/`endNetworkCommand`;
11
+ * - remember grants: written to `overrides.allowDomains` by the caller and
12
+ * applied live with `SandboxManager.updateConfig` (a reload here would tear
13
+ * down the proxy serving the request being approved).
14
+ *
15
+ * Headless (`hasUI === false`) denies, like every other gate.
16
+ *
17
+ * Session grants use the parent-domain wildcard when useful (covers all sibling
18
+ * subdomains). Screen 2 hides the "in this project" rows in untrusted
19
+ * projects so the user can't quietly trust a widening file.
20
+ */
21
+
22
+ import { domainMatches } from "../core/index";
23
+ import { askMain, askRememberHost, ASK_TIMEOUT_MS, parentDomainWildcard, type AskCtx, type MainChoice } from "../ui/ask-flow";
24
+
25
+ /** In-memory session grants ("Yes, for this session"). Cleared at session_start. */
26
+ const sessionGrants: string[] = [];
27
+ /** Hosts allowed for the current bash command ("Yes, just this once" covers its connections). */
28
+ let commandGrants: Set<string> | null = null;
29
+ let commandDepth = 0;
30
+ /** Single-flight: one prompt per host even when several connections overlap. */
31
+ const pending = new Map<string, Promise<boolean>>();
32
+
33
+ export function clearNetworkSessionGrants(): void {
34
+ sessionGrants.length = 0;
35
+ }
36
+
37
+ export function networkSessionGrantSummary(): string {
38
+ return sessionGrants.join(", ");
39
+ }
40
+
41
+ export function beginNetworkCommand(): void {
42
+ commandDepth += 1;
43
+ commandGrants ??= new Set();
44
+ }
45
+
46
+ export function endNetworkCommand(): void {
47
+ commandDepth = Math.max(0, commandDepth - 1);
48
+ if (commandDepth === 0) commandGrants = null;
49
+ }
50
+
51
+ /** The active pi context at ask time (set at session_start; sandbox.ts owns it). */
52
+ export type NetworkAskCtx = AskCtx & {
53
+ ui: AskCtx["ui"] & { notify?: (m: string, l?: string) => void };
54
+ };
55
+
56
+ export interface NetworkAskDeps {
57
+ cwd: string;
58
+ getCtx: () => NetworkAskCtx | undefined;
59
+ /** Write the host into `overrides.allowDomains` (file only); returns the path written. */
60
+ persist: (host: string, scope: "cwd" | "global") => Promise<string>;
61
+ /** Make a grant effective in the running sandbox without restarting the proxy. */
62
+ applyLive: (host: string) => void;
63
+ audit: (entry: Record<string, unknown>) => void;
64
+ /** True when the cwd's project sandbox.json isn't trusted (ADR-013). */
65
+ projectTrusted?: boolean;
66
+ /** The bash command that triggered the proxy ask (shown in the body as `Command`). */
67
+ command?: string;
68
+ }
69
+
70
+ function granted(host: string): "session" | "command" | null {
71
+ if (sessionGrants.some((g) => domainMatches(host, g))) return "session";
72
+ if (commandGrants?.has(host)) return "command";
73
+ return null;
74
+ }
75
+
76
+ /** Display subject: host, plus a non-default port when the proxy reports one. */
77
+ function subjectOf(host: string, port: number | undefined): string {
78
+ return port && port !== 443 && port !== 80 ? `${host}:${port}` : host;
79
+ }
80
+
81
+ /** First whitespace-separated word of a bash command \u2014 used to name the binary in the title. */
82
+ function firstWord(command: string | undefined): string | null {
83
+ if (!command) return null;
84
+ const w = command.trim().split(/\s+/)[0] ?? "";
85
+ // Strip common prefixes.
86
+ return w.replace(/^sudo$/, "").replace(/^command$/, "").replace(/^\\\//, "") || null;
87
+ }
88
+
89
+ async function decide(deps: NetworkAskDeps, host: string, port: number | undefined): Promise<boolean> {
90
+ const subject = subjectOf(host, port);
91
+ const base = { layer: 1, tool: "network", subject, cwd: deps.cwd, note: "network-ask" };
92
+ const ctx = deps.getCtx();
93
+ if (!ctx || ctx.hasUI === false || !ctx.ui?.select) {
94
+ deps.audit({ ...base, decision: "no", note: "network-ask-headless" });
95
+ return false;
96
+ }
97
+
98
+ const wild = parentDomainWildcard(host);
99
+ const verb = firstWord(deps.command) ?? "this command";
100
+ const header = `Let ${verb} connect to ${host}?`;
101
+ const bodyParts: string[] = [];
102
+ bodyParts.push(`Site ${host}`);
103
+ if (wild !== host.toLowerCase()) {
104
+ const bare = wild.replace(/^\*\./, "");
105
+ bodyParts.push(`Group ${wild} (every ${bare} site)`);
106
+ }
107
+ if (deps.command) bodyParts.push(`Command ${deps.command}`);
108
+ bodyParts.push(`Why this site isn't on your allowed list yet`);
109
+
110
+ let mainChoice: MainChoice;
111
+ try {
112
+ mainChoice = await askMain(ctx, header, bodyParts.join("\n"), {
113
+ session: true,
114
+ sessionLabel: "Yes, all in group for this session",
115
+ allowFirst: true,
116
+ icon: "net",
117
+ timeoutMs: ASK_TIMEOUT_MS,
118
+ });
119
+ } catch {
120
+ deps.audit({ ...base, decision: "no", note: "screen1-threw" });
121
+ return false;
122
+ }
123
+
124
+ if (mainChoice === "block") {
125
+ deps.audit({ ...base, decision: "no" });
126
+ return false;
127
+ }
128
+ if (mainChoice === "once") {
129
+ commandGrants?.add(host);
130
+ deps.audit({ ...base, decision: "yes" });
131
+ return true;
132
+ }
133
+ if (mainChoice === "session") {
134
+ // ADR-030: session grant uses the parent-domain wildcard (e.g. *.composio.dev)
135
+ // when the host has a useful parent, so any sibling subdomain is also covered
136
+ // for the rest of the session without re-prompting. Apex hosts (2 parts)
137
+ // fall back to the exact host since `*.example.com` would not match `example.com`.
138
+ const grant = wild;
139
+ sessionGrants.push(grant);
140
+ deps.audit({ ...base, decision: "session", grant, requested: host });
141
+ ctx.ui.notify?.(`pi-sandbox: allowed for this session (not saved) \u2192 ${grant}`, "info");
142
+ return true;
143
+ }
144
+
145
+ // mainChoice === "remember" \u2014 screen 2 picks the wildcard or exact host pattern.
146
+ const title = "Always allow connecting \u2014 what, and where?";
147
+ const body: string[] = [];
148
+ body.push(`Site ${host}`);
149
+ if (wild !== host.toLowerCase()) {
150
+ const bare = wild.replace(/^\*\./, "");
151
+ body.push(`Group ${wild} (every ${bare} site)`);
152
+ }
153
+ body.push(`Note the rule works for any command, not just ${verb}`);
154
+ if (deps.projectTrusted === false) {
155
+ body.push(`Note this project isn't trusted \u2014 run /security trust to save rules here`);
156
+ }
157
+ const picked = await askRememberHost(ctx, title, body.join("\n"), host, { icon: "save", untrusted: deps.projectTrusted === false });
158
+ if (!picked) {
159
+ deps.audit({ ...base, decision: "no", note: "remember-screen-cancelled" });
160
+ return false;
161
+ }
162
+ const scope = picked.scope;
163
+ const pattern = picked.pattern;
164
+ try {
165
+ const path = await deps.persist(pattern, scope);
166
+ deps.applyLive(pattern);
167
+ deps.audit({ ...base, decision: scope === "cwd" ? "always-cwd" : "always-global", scope, persisted_to: path, pattern, requested: host });
168
+ ctx.ui.notify?.(`pi-sandbox: persisted ${scope} override \u2192 ${path}`, "warning");
169
+ } catch (e) {
170
+ // Mirror Layer 2: a failed write still allows the call, covering the
171
+ // whole command so a redirect cannot loop the prompt.
172
+ commandGrants?.add(host);
173
+ deps.audit({ ...base, decision: "yes", note: `always-persist-failed: ${e}`, scope });
174
+ ctx.ui.notify?.(`pi-sandbox: could not persist ${scope} override (${e}); allowing this command only`, "warning");
175
+ }
176
+ return true;
177
+ }
178
+
179
+ /**
180
+ * Build the `SandboxAskCallback` for `SandboxManager.initialize`. One asker
181
+ * per sandbox start; the session and command grants it consults are shared, so
182
+ * a reload keeps them.
183
+ */
184
+ export function createNetworkAsk(deps: NetworkAskDeps): (params: { host: string; port?: number }) => Promise<boolean> {
185
+ return async ({ host, port }) => {
186
+ const key = host.toLowerCase();
187
+ const hit = granted(key);
188
+ if (hit) {
189
+ deps.audit({ layer: 1, tool: "network", subject: subjectOf(key, port), cwd: deps.cwd, decision: hit === "session" ? "session-grant" : "yes", note: `${hit}-grant` });
190
+ return true;
191
+ }
192
+ const inflight = pending.get(key);
193
+ if (inflight) return inflight;
194
+ const decision = decide(deps, key, port).finally(() => pending.delete(key));
195
+ pending.set(key, decision);
196
+ return decision;
197
+ };
198
+ }
@@ -24,9 +24,19 @@ export function collectExposure(content: ReadonlyArray<{ type: string; text?: st
24
24
  for (const f of r.findings) types.add(f.type);
25
25
  const before = c.text.split("\n");
26
26
  const after = String(r.redactedOutput ?? "").split("\n");
27
- for (let i = 0; i < before.length && lines.length < MAX_LINES; i++) {
28
- if (after[i] !== before[i] && before[i].trim()) lines.push(`${i + 1}: ${clip(before[i].trim())}`);
27
+ const matchedIndices: number[] = [];
28
+ for (let i = 0; i < before.length; i++) {
29
+ if (after[i] !== before[i] && before[i].trim()) matchedIndices.push(i);
29
30
  }
31
+ if (matchedIndices.length === 0) continue;
32
+ const shown = matchedIndices.slice(0, MAX_LINES);
33
+ const firstIdx = shown[0];
34
+ const lastIdx = shown[shown.length - 1];
35
+ const moreMatched = matchedIndices.length > MAX_LINES;
36
+ const moreBelow = lastIdx < before.length - 1;
37
+ if (firstIdx > 0) lines.push(" \u2026");
38
+ for (const idx of shown) lines.push(`${idx + 1}: ${clip(before[idx].trim())}`);
39
+ if (moreMatched || moreBelow) lines.push(" \u2026");
30
40
  }
31
- return { types: [...types], hits: lines.length ? [`${subject}\n${lines.join("\n")}`] : [] };
41
+ return { types: [...types], hits: lines.length ? [`From ${subject}\n${lines.join("\n")}`] : [] };
32
42
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * In-memory session grants for the Advanced Secure output gate (ADR-018).
3
+ *
4
+ * When a CLI like `composio` returns output that matches a secret pattern
5
+ * (JWT, OAuth refresh token, etc.), the user can pick "Yes, all <program>
6
+ * output for this session" instead of approving every block. The program id
7
+ * is added here, and the output gate short-circuits for the rest of the
8
+ * session — same shape as path/host session grants, but for tool output.
9
+ *
10
+ * Cleared at session_start (ADR-010), never written to disk. The credential
11
+ * tier (denyRead / askRead) still wins on the file side; this only loosens
12
+ * the *output* gate, which is about display, not access.
13
+ */
14
+
15
+ /** The binary / MCP server / tool that produced the output. Case-folded. */
16
+ const programOutputSessionGrants = new Set<string>();
17
+
18
+ export function clearProgramOutputSessionGrants(): void {
19
+ programOutputSessionGrants.clear();
20
+ }
21
+
22
+ /** Add a program to the session grant list. Idempotent. */
23
+ export function grantProgramOutputSession(programId: string): void {
24
+ programOutputSessionGrants.add(programId.toLowerCase());
25
+ }
26
+
27
+ /** True when the program is already granted for the rest of the session. */
28
+ export function isProgramOutputGranted(programId: string | undefined): boolean {
29
+ if (!programId) return false;
30
+ return programOutputSessionGrants.has(programId.toLowerCase());
31
+ }
32
+
33
+ export function programOutputSessionGrantSummary(): string {
34
+ return [...programOutputSessionGrants].join(", ");
35
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Program-id detection for the Advanced Secure output gate.
3
+ *
4
+ * Used to label the "This output may contain a secret" prompt with the binary
5
+ * that produced it (`composio`, `aws`, `gcloud`, …) and to key the per-program
6
+ * session grant (`isProgramOutputGranted`). Pure functions, no pi / fs / audit.
7
+ */
8
+
9
+ /** Strip env assignments (`FOO=bar`), common wrappers (sudo, nice, command, …), and the
10
+ * flags they take to find the real binary name in a bash command. Handles `nice -n 10`,
11
+ * `sudo -u user -- composio`, `command -v`, `strace -f -o /tmp/log aws s3 ls`, etc.
12
+ * Returns undefined for empty input. */
13
+ export function bashBinary(command: string | undefined): string | undefined {
14
+ if (!command) return undefined;
15
+ const tokens = command.trim().split(/\s+/).filter(Boolean);
16
+ let i = 0;
17
+ while (i < tokens.length && /^[A-Z_][A-Z0-9_]*=/.test(tokens[i])) i++;
18
+ const WRAPPERS = new Set(["sudo", "nice", "command", "time", "strace", "stdbuf", "timeout"]);
19
+ while (i < tokens.length) {
20
+ if (WRAPPERS.has(tokens[i])) { i++; continue; }
21
+ if (tokens[i] === "-n" && i + 1 < tokens.length && /^\d+$/.test(tokens[i + 1])) { i += 2; continue; }
22
+ if (tokens[i].startsWith("-") && tokens[i].length > 1) { i++; continue; }
23
+ break;
24
+ }
25
+ return tokens[i];
26
+ }
27
+
28
+ /** The program id for a tool call. Bash binaries only — MCP and other tools return
29
+ * `undefined` so the Advanced Secure output gate doesn't offer them a per-program
30
+ * session grant (MCP tools handle their own secrets and don't share the
31
+ * composio-style "same binary, same surface" pattern). */
32
+ export function programIdForToolCall(toolName: string | undefined, input: unknown): string | undefined {
33
+ if (!toolName) return undefined;
34
+ if (toolName !== "bash") return undefined;
35
+ const cmd = (input as { command?: string } | undefined)?.command;
36
+ return bashBinary(cmd);
37
+ }
@@ -8,14 +8,14 @@ import { mkdirSync, writeFileSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
9
  import { dirname, join } from "node:path";
10
10
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
11
- import { extractUserMessages, readPolicyForUpdate, recordProjectTrust, userNamedFile } from "../core/index";
11
+ import { extractUserMessages, isSafeFolderGrant, readPolicyForUpdate, recordProjectTrust, userNamedFile } from "../core/index";
12
12
  import { audit } from "../shared/audit";
13
13
  import { canonicalize, matchPattern } from "./matching";
14
14
  import { projectTrusted, TRUST_STORE } from "./policy";
15
15
  import { domainMatches } from "./url";
16
- import { askDecision, denyMessage, type AskKind, type OverrideKind } from "./ask";
16
+ import { askDecision, denyMessage, type AskCtx, type AskKind, type OverrideKind } from "../ui/ask";
17
17
 
18
- export type { AskKind, Decision, OverrideKind } from "./ask";
18
+ export type { AskKind, Decision, OverrideKind } from "../ui/ask";
19
19
  export type Scope = "cwd" | "global";
20
20
 
21
21
  export function persistOverride(scope: Scope, cwd: string, kind: OverrideKind, value: string): string {
@@ -68,14 +68,15 @@ function sessionGranted(k: AskKind, cwd: string): string | null {
68
68
  export type UICtx = {
69
69
  cwd: string;
70
70
  hasUI?: boolean;
71
- ui: {
72
- select: (t: string, o: string[], op?: { timeout?: number }) => Promise<string | undefined>;
71
+ /** pi's run mode; `"tui"` enables the custom footer selector (ADR-031). */
72
+ mode?: string;
73
+ ui: AskCtx["ui"] & {
73
74
  input: (t: string, p?: string, op?: { timeout?: number }) => Promise<string | undefined>;
74
75
  notify: (m: string, l?: string) => void;
75
76
  };
76
77
  };
77
78
 
78
- export async function askOrBlock(ctx: UICtx, k: AskKind, absoluteDenyPattern: string | null): Promise<{ block: true; reason: string } | null> {
79
+ export async function askOrBlock(ctx: UICtx, k: AskKind, absoluteDenyPattern: string | null, onPersist?: (kind: OverrideKind, value: string, scope: Scope) => void): Promise<{ block: true; reason: string } | null> {
79
80
  if (!absoluteDenyPattern) {
80
81
  const granted = sessionGranted(k, ctx.cwd);
81
82
  if (granted) {
@@ -92,13 +93,17 @@ export async function askOrBlock(ctx: UICtx, k: AskKind, absoluteDenyPattern: st
92
93
  }
93
94
  }
94
95
  }
95
- const decision = await askDecision(ctx, k, absoluteDenyPattern);
96
+ const decision = await askDecision(ctx, { ...k, projectTrusted: projectTrusted(ctx.cwd) }, absoluteDenyPattern);
96
97
  if (decision === "no") {
97
98
  audit({ layer: 2, tool: k.tool, subject: k.subject, reason: k.reason, decision: "no", cwd: ctx.cwd });
98
99
  return { block: true, reason: denyMessage(k.overrideKind, k.reason) };
99
100
  }
100
101
  if (decision === "session" || decision === "session-folder") {
101
- const value = decision === "session-folder" ? dirname(k.overrideValue) : k.overrideValue;
102
+ // ADR-030: a session grant for a file covers the whole folder, so a CLI writing
103
+ // many files under ~/.composio/ stops re-prompting after the first grant. Falls
104
+ // back to the exact path when the parent folder is unsafe (root, home, …).
105
+ const parent = k.overrideKind === "allowDomains" ? k.overrideValue : dirname(k.overrideValue);
106
+ const value = k.overrideKind === "allowDomains" || isSafeFolderGrant(parent, homedir()) ? parent : k.overrideValue;
102
107
  sessionGrants.push({ kind: k.overrideKind, value });
103
108
  audit({ layer: 2, tool: k.tool, subject: k.subject, reason: k.reason, decision, grant: value, cwd: ctx.cwd });
104
109
  ctx.ui.notify(`security-guard: allowed for this session (not saved) → ${value}`, "info");
@@ -114,6 +119,7 @@ export async function askOrBlock(ctx: UICtx, k: AskKind, absoluteDenyPattern: st
114
119
  const value = useFolder ? dirname(k.overrideValue) : k.overrideValue;
115
120
  try {
116
121
  const path = persistOverride(scope, ctx.cwd, k.overrideKind, value);
122
+ onPersist?.(k.overrideKind, value, scope);
117
123
  audit({ layer: 2, tool: k.tool, subject: k.subject, reason: k.reason, decision, scope, granularity: useFolder ? "folder" : "file", cwd: ctx.cwd, persisted_to: path, override: { [k.overrideKind]: value } });
118
124
  ctx.ui.notify(`security-guard: persisted ${scope}${useFolder ? " (folder)" : ""} override → ${path}`, "warning");
119
125
  } catch (e) {
@@ -1,32 +1,27 @@
1
1
  /**
2
2
  * Layer 2 domain allow/deny matching for the URL tools.
3
- * Pure: no pi, no OS.
3
+ * Pure: no pi, no OS. Host/pattern primitives live in src/core (shared with
4
+ * the Layer 1 network ask, ADR-023).
4
5
  */
5
6
 
7
+ import { domainMatches, hostnameOf } from "../core/policy/domains";
6
8
  import type { Policy } from "./policy";
7
9
 
8
- export function hostnameOf(url: string): string | null {
9
- try {
10
- return new URL(url).hostname.toLowerCase();
11
- } catch {
12
- return null;
13
- }
14
- }
10
+ export { domainMatches, hostnameOf };
15
11
 
16
- export function domainMatches(host: string, pattern: string): boolean {
17
- const p = pattern.toLowerCase();
18
- if (p.startsWith("*.")) {
19
- const suffix = p.slice(1); // ".npmjs.org"
20
- return host === p.slice(2) || host.endsWith(suffix);
21
- }
22
- return host === p;
12
+ /** The hard-deny tier: a host on `deniedDomains` is never ask-able in either layer. */
13
+ export function deniedUrlReason(url: string, policy: Policy): string | null {
14
+ const host = hostnameOf(url);
15
+ if (!host) return null;
16
+ if (policy.network.deniedDomains.some((p) => domainMatches(host, p))) return `denied domain: ${host}`;
17
+ return null;
23
18
  }
24
19
 
25
20
  export function isAllowedUrl(url: string, policy: Policy): string | null {
26
21
  const host = hostnameOf(url);
27
22
  if (!host) return `not a valid URL: ${url}`;
28
- if (policy.network.deniedDomains.some((p) => domainMatches(host, p)))
29
- return `denied domain: ${host}`;
23
+ const denied = deniedUrlReason(url, policy);
24
+ if (denied) return denied;
30
25
  if ((policy.overrides?.allowDomains ?? []).some((p) => domainMatches(host, p))) return null;
31
26
  if (policy.network.allowedDomains.length === 0) return null;
32
27
  if (policy.network.allowedDomains.some((p) => domainMatches(host, p))) return null;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Cross-layer network grant notification (ADR-023), shared by every layer
3
+ * entrypoint.
4
+ *
5
+ * Layer 2's "Allow and remember" writes `overrides.allowDomains`; Layer 1's
6
+ * running proxy only learns about it on reload. The entrypoints share no
7
+ * memory (ADR-018/020), so the grant travels over pi's process-wide event bus:
8
+ * guard.ts emits, sandbox.ts applies it live with SandboxManager.updateConfig.
9
+ */
10
+
11
+ /** pi's shared event bus channel for persisted domain grants. */
12
+ export const DOMAIN_GRANT_CHANNEL = "auto-permission-system:domain-grant";
13
+
14
+ /** The pi bus APIs this module needs; kept structural so tests can fake them. */
15
+ export interface DomainGrantBus {
16
+ emit(channel: string, data: unknown): void;
17
+ on(channel: string, handler: (data: unknown) => void): () => void;
18
+ }
19
+
20
+ /** Announce that `host` was persisted into overrides.allowDomains. */
21
+ export function emitDomainGrant(bus: DomainGrantBus, host: string): void {
22
+ bus.emit(DOMAIN_GRANT_CHANNEL, { host });
23
+ }
24
+
25
+ /** Subscribe to persisted domain grants; ignores malformed payloads. Returns an unsubscribe. */
26
+ export function onDomainGrant(bus: DomainGrantBus, handler: (host: string) => void): () => void {
27
+ return bus.on(DOMAIN_GRANT_CHANNEL, (data) => {
28
+ const host = (data as { host?: unknown } | null | undefined)?.host;
29
+ if (typeof host === "string" && host) handler(host);
30
+ });
31
+ }