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/README.md CHANGED
@@ -11,7 +11,7 @@ It puts allow/deny rules, interactive ask prompts, secret detection and an audit
11
11
 
12
12
  Enforcement happens in three layers:
13
13
 
14
- - **Layer 1** — OS-level bash sandbox (`sandbox-exec` on macOS, `bubblewrap` on Linux) that blocks filesystem writes outside an allow-list, reads of sensitive paths, reads outside the project when `outsideProject.read` gates them, and network egress to unlisted domains.
14
+ - **Layer 1** — OS-level bash sandbox (`sandbox-exec` on macOS, `bubblewrap` on Linux) that blocks filesystem writes outside an allow-list, reads of sensitive paths, reads outside the project when `outsideProject.read` gates them, and network egress to unlisted domains — an unknown host asks first (a host in `deniedDomains` never does), with the same once/session/remember options as Layer 2.
15
15
  - **Layer 2** — In-process tool guard applying the same policy to the tools the OS sandbox can't reach: `read`, `grep`, `find`, `ls`, `write`, `edit`, `fetch_content`, `web_search`, `get_search_content`. `grep` output lines from denied files beneath an allowed search root are removed before the model sees them.
16
16
  - **Layer 3** — Subagent posture: optionally drop or restrict network access when running headless (`-p`, JSON mode, subagents).
17
17
 
@@ -72,11 +72,12 @@ A higher layer only states what it changes; absent keys keep the layer below. A
72
72
  "enabled": true, // set false to disable Layer 2 without --yolo
73
73
  "mode": "default", // "default" (rules) | "advanced-secure" (secret detection, ADR-018)
74
74
  "network": {
75
- "allowedDomains": [ // domains fetch_content / get_search_content may reach
75
+ "allowedDomains": [ // domains the URL tools and bash may reach
76
76
  "github.com", "*.github.com",
77
77
  "registry.npmjs.org"
78
78
  ],
79
- "deniedDomains": [] // explicit block-list (checked before allowedDomains)
79
+ "deniedDomains": [], // hard deny, checked first, never ask-able
80
+ "strictAllowlist": false // true = deny unknown hosts instead of asking
80
81
  },
81
82
  "filesystem": {
82
83
  "denyRead": ["~/.ssh", "~/.aws", "~/.pi/agent"], // Layer 1 + Layer 2: no read at all
@@ -88,6 +89,14 @@ A higher layer only states what it changes; absent keys keep the layer below. A
88
89
  "subagent": {
89
90
  "network": "allow" // "allow" | "deny" | "research-only"
90
91
  },
92
+ "mcp": { // Advanced Secure MCP gate tuning (ADR-032)
93
+ "allowTools": ["mcp__diffusion_studio__context"], // exact names that never ask
94
+ "allowPrefixes": ["mcp__ticktick__get_"], // name prefixes that never ask
95
+ "askTools": [], // exact names that always ask
96
+ "trustAnnotations": false, // true = a server's readOnlyHint allows without asking
97
+ "allowSimpleUpdates": true, // narrow field updates skip the ask
98
+ "askThreshold": 30 // risk score at which a call asks (lower = stricter)
99
+ },
91
100
  "commands": {
92
101
  "ask": ["printenv", "env", "/proc/*/environ"] // bash: ask before these (they can print env tokens)
93
102
  }
@@ -123,9 +132,9 @@ Never asked about: the project itself, your `allowWrite` roots, pi's own package
123
132
 
124
133
  ### Ask-tier prompt
125
134
 
126
- `no — block` is pre-selected, so Enter alone blocks. Besides `yes — this once` and the persistent `always` options there is `yes — for this session` (this file, its folder, or this domain), kept in memory until the session ends and never saved (ADR-010). For a bash write that the sandbox refused after the command already ran, `yes — this once` re-runs the command once with that folder allowed for the one invocation only (never saved), and `denyWrite` still wins. The refusal is read from the command's own error text — `EPERM` / `Operation not permitted` on macOS, `EROFS` / `Read-only file system` on Linux (ADR-022) — so a Linux EROFS gets the same prompt instead of a silent read-only failure; a refused write is never mistaken for an outside-project read.
135
+ `no — block` is pre-selected, so Enter alone blocks. **Network (domain) asks are the exception** (ADR-024): the countdown default is `Allow once` — the footer reads `Default: Yes, just this once (15s)` — so an unanswered countdown counts as allow-once for that bash command, while Enter and Esc still block/deny. Once you press ↑/↓ the `Default:` line disappears and the 15s activity timer restarts (ADR-031). The prompt is keyboard-only (ADR-036): ↑/↓ move the highlight, Enter selects, Esc cancels — a mouse click or wheel over the prompt does nothing. Besides `yes — this once` and the persistent `always` options there is `yes — for this session` (this file, its folder, or this domain), kept in memory until the session ends and never saved (ADR-010). A bash command that reaches a domain outside `allowedDomains` gets the same prompt (ADR-023); because the sandbox proxy asks per connection, `yes — this once` covers that command's connections to the host, and a remembered host is written to `overrides.allowDomains` and applies immediately. Hosts in `deniedDomains`, or with `strictAllowlist: true`, are blocked without a prompt. Prompts are shown one at a time: the extension serializes them, because pi's selector is a singleton and two overlapping prompts can orphan each other and hang the waiting connection. For a bash write that the sandbox refused after the command already ran, `yes — this once` re-runs the command once with that folder allowed for the one invocation only (never saved), and `denyWrite` still wins. The refusal is read from the command's own error text — `EPERM` / `Operation not permitted` on macOS, `EROFS` / `Read-only file system` on Linux (ADR-022) — so a Linux EROFS gets the same prompt instead of a silent read-only failure; a refused write is never mistaken for an outside-project read.
127
136
 
128
- Every prompt waits **10 seconds** by default. If you do not answer, it resolves to the safe default: **block / deny**.
137
+ Every prompt waits **10 seconds** by default. For file reads and writes, no answer resolves to the safe default: **block / deny**. For a network ask, no answer resolves to **allow once** (ADR-024); Esc still denies.
129
138
 
130
139
  ### File-name patterns
131
140
 
@@ -138,7 +147,7 @@ An entry without `/` or `~` (`.env`, `*.key`) is a file name. pi's own tools (La
138
147
  - **`default`** (shipped) — rules only: sandbox.json paths, domains, and commands. Nothing inspects file contents.
139
148
  - **`advanced-secure`** (recommended) — adds secret/credential detection:
140
149
  - **File reads:** a filename that looks like a credential store asks before the read.
141
- - **MCP calls:** `mcp__<server>__<tool>` calls are classified; a risky (mutating/destructive) call asks before it runs.
150
+ - **MCP calls:** `mcp__<server>__<tool>` calls are classified; a risky call asks before it runs. The gate ignores the server namespace, reads the operation from the head verb of the tool name (or the first verb of the description), and asks for destructive names, mutating names (`update_*`, `create_*`, `run_*`, `evaluate_*`), and destructive arguments (SQL `DELETE`, `rm -rf`, `*` targets, HTTP `DELETE`, production targets). A documented tool with an unclassified name (`context`, `capture`, `logs`) does not ask; a tool with no description counts as opaque and does. Tune it under `mcp` in `sandbox.json` — `allowTools` / `allowPrefixes` skip asks, `askTools` forces them, `trustAnnotations` trusts a server's `readOnlyHint`, `askThreshold` moves the score line (ADR-032).
142
151
  - **Tool/command/file output:** before any output reaches the model, a secret-like hit shows `⚠ Sensitive information detected`, the file, and each detected line as `lineNo: text`, and the question **Should the AI be allowed to see it?** with two choices — **No, keep private** or **Yes, allow**. A bare `key` counts only when the value looks machine-generated: `key=<random>` asks, while `key=value`, `key=subagent.network`, and code such as `const key = x` stay clean. No, keep private withholds the output and tells the model plainly that the output was withheld because it may contain sensitive information, so the model does not mistake it for a failure or an empty result. Esc or timeout keeps it private.
143
152
 
144
153
  Detection can only **add** asks or blocks; it never loosens a rule. The mode is layered like every other key: an untrusted project may opt *in* to `advanced-secure`, but only a trusted project (or the global file) may turn it off.
@@ -200,7 +209,7 @@ Every block/allow/always decision is appended to `~/.pi/agent/audit.log` as a JS
200
209
  ## FAQ
201
210
 
202
211
  ### How do I allow the agent to reach a new domain?
203
- Add it to `network.allowedDomains` in a policy file (global `~/.pi/agent/extensions/sandbox.json` or project `<cwd>/.pi/sandbox.json`), or just run the tool once and pick an **always** option in the ask prompt — that writes the domain to the project file for you.
212
+ Add it to `network.allowedDomains` in a policy file (global `~/.pi/agent/extensions/sandbox.json` or project `<cwd>/.pi/sandbox.json`), or just run the tool (or a bash `curl`) once and pick an **always** option in the ask prompt — that writes the domain to the project file for you.
204
213
 
205
214
  ### How do I allow writes to another folder?
206
215
  Add the folder to `filesystem.allowWrite`. Relative paths (`.`, `/tmp`) resolve from the project root.
@@ -11,15 +11,18 @@ import { isAbsolute, join, resolve } from "node:path";
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
  import { isToolCallEventType, getAgentDir } from "@earendil-works/pi-coding-agent";
13
13
  import { filterGrepOutput, policyFileError, forgetProjectTrust, isProjectFileDeclined, recordProjectDeclined, recordProjectTrust } from "../src/core/index";
14
- import { classifyFilename, evaluateMcpCall, type JsonSchemaLike, type McpToolAnnotations } from "../src/detect/index";
14
+ import { classifyFilename, evaluateMcpCall, type JsonSchemaLike, type McpPolicy, type McpToolAnnotations } from "../src/detect/index";
15
15
  import { collectExposure } from "../src/l2-guard/exposure";
16
16
  import { askExposure, askMain, withheldNotice } from "../src/ui/ask-flow";
17
17
  import { loadPolicy, projectPolicyPath, projectTrusted, setPiDeclinedTrust, untrustedProjectChanges, bullets, TRUST_STORE } from "../src/l2-guard/policy";
18
18
  import { canonicalize, expandHome, isAbsoluteDeny, isAskRead, isDeniedRead, isDeniedWrite, outsideProjectReason } from "../src/l2-guard/matching";
19
- import { hostnameOf, isAllowedUrl } from "../src/l2-guard/url";
19
+ import { deniedUrlReason, hostnameOf, isAllowedUrl } from "../src/l2-guard/url";
20
20
  import { audit, AUDIT_PATH } from "../src/shared/audit";
21
21
  import { askOrBlock, clearSessionGrants, sessionGrantSummary, type UICtx } from "../src/l2-guard/prompts";
22
- import { denyMessage } from "../src/l2-guard/ask";
22
+ import { grantProgramOutputSession, isProgramOutputGranted, clearProgramOutputSessionGrants, programOutputSessionGrantSummary } from "../src/l2-guard/program-grants";
23
+ import { programIdForToolCall } from "../src/l2-guard/program-id";
24
+ import { denyMessage } from "../src/ui/ask";
25
+ import { emitDomainGrant } from "../src/shared/network-grants";
23
26
  import { subagentNetworkBlock } from "../src/l2-guard/subagent";
24
27
  import { onYolo } from "../src/shared/yolo";
25
28
 
@@ -35,6 +38,11 @@ function detectionBlock(action: "read" | "tool", why: string): string {
35
38
  return `Tool call blocked by policy: ${why}. Nothing was run — ask the user.`;
36
39
  }
37
40
 
41
+ /** Maps each in-flight tool call to the program id we'll show in the
42
+ * Advanced Secure output gate. Populated at `tool_call`, consumed at
43
+ * `tool_result`, cleared at `session_start`. */
44
+ const programIdByCallId = new Map<string, string>();
45
+
38
46
  async function askDetection(
39
47
  ctx: UICtx,
40
48
  kind: "read" | "tool",
@@ -69,6 +77,7 @@ async function detectMcpAsk(
69
77
  toolName: string,
70
78
  input: Record<string, unknown>,
71
79
  ctx: UICtx,
80
+ mcpPolicy?: McpPolicy,
72
81
  ): Promise<{ block: true; reason: string } | null> {
73
82
  const info = pi.getAllTools().find((t) => t.name === toolName);
74
83
  const result = evaluateMcpCall({
@@ -79,6 +88,7 @@ async function detectMcpAsk(
79
88
  annotations: /* SAFETY: pi's ToolAnnotations mirrors the MCP hints McpToolAnnotations names. */ info?.annotations as McpToolAnnotations | undefined,
80
89
  },
81
90
  call: { name: toolName, arguments: input },
91
+ policy: mcpPolicy,
82
92
  });
83
93
  if (result.decision !== "ask") return null;
84
94
  return askDetection(ctx, "tool", toolName, result.summary);
@@ -93,10 +103,6 @@ export default function (pi: ExtensionAPI) {
93
103
  active = policyEnabled && !runtimeYolo;
94
104
  };
95
105
 
96
- // `--yolo` and the `/permission-mode` toggle both arrive on pi's process-wide
97
- // bus; `sandbox.ts` is the only extension that registers the CLI flags and
98
- // broadcasts the state (ADR-020). The entrypoints share no memory, so this is
99
- // the only channel that reaches Layer 2.
100
106
  onYolo(pi.events, (enabled) => {
101
107
  runtimeYolo = enabled;
102
108
  applyActive();
@@ -104,8 +110,6 @@ export default function (pi: ExtensionAPI) {
104
110
  });
105
111
 
106
112
  pi.on("session_start", (_event, ctx) => {
107
- // A policy file that does not parse is skipped and the defaults apply.
108
- // Say so loudly: otherwise one trailing comma silently disables the whole file.
109
113
  for (const p of [`${getAgentDir()}/extensions/sandbox.json`, `${ctx.cwd}/.pi/sandbox.json`]) {
110
114
  const err = policyFileError(p);
111
115
  if (!err) continue;
@@ -135,14 +139,17 @@ export default function (pi: ExtensionAPI) {
135
139
  applyActive();
136
140
  clearSessionGrants();
137
141
  detectionGrants.clear();
142
+ clearProgramOutputSessionGrants();
143
+ programIdByCallId.clear();
138
144
  });
139
145
 
140
146
  pi.on("tool_call", async (event, ctx) => {
141
147
  if (!active) return;
142
148
  const policy = loadPolicy(ctx.cwd);
143
149
 
144
- // --- Path-based gates (with ask-tier prompt) ---
145
- // One gate for every read: the read policy first, then the project boundary.
150
+ const programId = programIdForToolCall(event.toolName, event.input);
151
+ if (event.toolCallId && programId) programIdByCallId.set(event.toolCallId, programId);
152
+
146
153
  const gateRead = async (rawPath: string, tool: string) => {
147
154
  const abs = canonicalize(rawPath, ctx.cwd);
148
155
  let reason = isDeniedRead(rawPath, ctx.cwd, policy);
@@ -174,10 +181,6 @@ export default function (pi: ExtensionAPI) {
174
181
  }
175
182
  }
176
183
  }
177
- // Read-only search tools. grep returns file contents and runs ripgrep in
178
- // process, so neither the bash sandbox nor the read gate saw it before.
179
- // Gate the search root here; tool_result below drops output lines from
180
- // denied files beneath the root.
181
184
  const searchRoot = isToolCallEventType("grep", event) || isToolCallEventType("find", event) || isToolCallEventType("ls", event)
182
185
  ? (event.input.path ?? ".")
183
186
  : null;
@@ -202,7 +205,6 @@ export default function (pi: ExtensionAPI) {
202
205
  }
203
206
  }
204
207
 
205
- // --- URL/domain gates (best-effort by tool name; tools are extension-defined) ---
206
208
  const input = event.input as Record<string, unknown> | undefined;
207
209
  const collectUrls = (): string[] => {
208
210
  if (!input) return [];
@@ -218,10 +220,17 @@ export default function (pi: ExtensionAPI) {
218
220
  const saReason = subagentNetworkBlock(ctx as { hasUI?: boolean; sessionManager?: unknown }, policy);
219
221
  if (saReason) return { block: true, reason: saReason };
220
222
  for (const u of collectUrls()) {
223
+ const denied = deniedUrlReason(u, policy);
224
+ if (denied) {
225
+ audit({ layer: 2, tool: event.toolName, subject: u, reason: denied, decision: "no", note: "denied-domain", cwd: ctx.cwd });
226
+ return { block: true, reason: denyMessage("allowDomains", denied) };
227
+ }
221
228
  const reason = isAllowedUrl(u, policy);
222
229
  if (!reason) continue;
223
230
  const host = hostnameOf(u) ?? u;
224
- const result = await askOrBlock(/* SAFETY: pi's ctx carries cwd/UI at runtime. */ ctx as unknown as UICtx, { layer: 2, tool: event.toolName, subject: u, reason, overrideKind: "allowDomains", overrideValue: host }, null);
231
+ const result = await askOrBlock(/* SAFETY: pi's ctx carries cwd/UI at runtime. */ ctx as unknown as UICtx, { layer: 2, tool: event.toolName, subject: u, reason, overrideKind: "allowDomains", overrideValue: host }, null, (kind, value) => {
232
+ if (kind === "allowDomains") emitDomainGrant(pi.events, value);
233
+ });
225
234
  if (result) return result;
226
235
  }
227
236
  }
@@ -229,22 +238,14 @@ export default function (pi: ExtensionAPI) {
229
238
  if (event.toolName === "web_search") {
230
239
  const saReason = subagentNetworkBlock(ctx as { hasUI?: boolean; sessionManager?: unknown }, policy);
231
240
  if (saReason) return { block: true, reason: saReason };
232
- // web_search itself goes to the search provider (out of scope for v1
233
- // allowlist). The follow-on fetch_content for individual results is
234
- // the catchable surface.
235
241
  }
236
242
 
237
- // Advanced Secure (ADR-018): classify MCP tool calls before they run.
238
243
  if (policy.mode === "advanced-secure" && event.toolName.startsWith("mcp__")) {
239
- const det = await detectMcpAsk(pi, event.toolName, (event.input ?? {}) as Record<string, unknown>, /* SAFETY: pi's ctx carries cwd/UI at runtime. */ ctx as unknown as UICtx);
244
+ const det = await detectMcpAsk(pi, event.toolName, (event.input ?? {}) as Record<string, unknown>, /* SAFETY: pi's ctx carries cwd/UI at runtime. */ ctx as unknown as UICtx, policy.mcp);
240
245
  if (det) return det;
241
246
  }
242
247
  });
243
248
 
244
- // grep output filter. A root that passed the gate can still contain denied
245
- // files (`.env`, `*.pem`, `~/.ssh` under a search of `~`). Approving the
246
- // root does not approve the denied files beneath it. Runs before the
247
- // result reaches the model.
248
249
  pi.on("tool_result", (event, ctx) => {
249
250
  if (!active || event.toolName !== "grep" || event.isError) return;
250
251
  const input = event.input as { path?: string } | undefined;
@@ -256,7 +257,6 @@ export default function (pi: ExtensionAPI) {
256
257
  } catch {
257
258
  return;
258
259
  }
259
- // A single-file search prints only the basename; its root gate already decided.
260
260
  if (!rootIsDir) return;
261
261
  let removedLines = 0;
262
262
  const removedFiles = new Set<string>();
@@ -273,25 +273,39 @@ export default function (pi: ExtensionAPI) {
273
273
  return { content };
274
274
  });
275
275
 
276
- // Advanced Secure (ADR-018): before any tool output reaches the model, ask
277
- // the user when it looks like it carries a secret. Block withholds it and
278
- // tells the model plainly, so the model does not read it as a failure or an
279
- // empty result; Allow send passes the real values through.
280
276
  pi.on("tool_result", async (event, ctx) => {
281
277
  if (!active || event.isError) return;
282
278
  const policy = loadPolicy(ctx.cwd);
283
279
  if (policy.mode !== "advanced-secure") return;
280
+ const programId = event.toolCallId ? programIdByCallId.get(event.toolCallId) : undefined;
281
+ if (programId && isProgramOutputGranted(programId)) {
282
+ audit({ layer: 2, tool: event.toolName, decision: "session-grant", grant: programId, note: "advanced-secure-output", cwd: ctx.cwd });
283
+ return;
284
+ }
284
285
  const rawInput = event.input ?? {};
285
286
  const rawPath = rawInput.path ?? rawInput.file_path;
286
- const subject = typeof rawPath === "string" && rawPath ? rawPath : event.toolName;
287
+ const subject = (() => {
288
+ if (typeof rawPath === "string" && rawPath) return rawPath;
289
+ if (event.toolName === "bash") {
290
+ const cmd = (event.input as { command?: string } | undefined)?.command;
291
+ return cmd ? `bash: ${cmd}` : "bash";
292
+ }
293
+ return event.toolName;
294
+ })();
287
295
  const { types, hits } = collectExposure(event.content, subject);
288
296
  if (!types.length) return;
289
297
  const findings = types;
290
298
  const ui = /* SAFETY: pi's ctx carries cwd/UI at runtime; the local type only names the members used. */ ctx as unknown as UICtx;
291
- const allow = ui.hasUI !== false && (await askExposure(ui, hits)) === "allow";
292
- if (allow) {
293
- audit({ layer: 2, tool: event.toolName, decision: "yes", note: "advanced-secure-output", findings, cwd: ctx.cwd });
294
- ctx.ui.notify(`🛡 Advanced Secure: you allowed ${event.toolName} output to be sent.`, "warning");
299
+ const choice = ui.hasUI !== false ? await askExposure(ui, hits, programId) : "block";
300
+ if (choice === "allow" || choice === "program-session") {
301
+ if (choice === "program-session" && programId) {
302
+ grantProgramOutputSession(programId);
303
+ audit({ layer: 2, tool: event.toolName, decision: "session", grant: programId, note: "advanced-secure-output", findings, cwd: ctx.cwd });
304
+ ctx.ui.notify(`🛡 Advanced Secure: you allowed all ${programId} output for the rest of this session.`, "info");
305
+ } else {
306
+ audit({ layer: 2, tool: event.toolName, decision: "yes", note: "advanced-secure-output", findings, cwd: ctx.cwd });
307
+ ctx.ui.notify(`🛡 Advanced Secure: you allowed ${event.toolName} output to be sent.`, "warning");
308
+ }
295
309
  return;
296
310
  }
297
311
  audit({ layer: 2, tool: event.toolName, decision: "no", note: "advanced-secure-output", findings, cwd: ctx.cwd });
@@ -371,6 +385,19 @@ export default function (pi: ExtensionAPI) {
371
385
  ` allowWrite: ${(overrides.allowWrite ?? []).join(", ") || "(none)"}`,
372
386
  ` allowDomains: ${(overrides.allowDomains ?? []).join(", ") || "(none)"}`,
373
387
  ];
388
+ if (policy.mode === "advanced-secure") {
389
+ const mcp = policy.mcp ?? {};
390
+ lines.push(
391
+ "",
392
+ "Advanced Secure (MCP gate, ADR-032):",
393
+ ` allowTools: ${(mcp.allowTools ?? []).join(", ") || "(none)"}`,
394
+ ` allowPrefixes: ${(mcp.allowPrefixes ?? []).join(", ") || "(none)"}`,
395
+ ` askTools: ${(mcp.askTools ?? []).join(", ") || "(none)"}`,
396
+ ` trustAnnotations: ${mcp.trustAnnotations === true}`,
397
+ ` allowSimpleUpdates: ${mcp.allowSimpleUpdates !== false}`,
398
+ ` askThreshold: ${mcp.askThreshold ?? 30}`,
399
+ );
400
+ }
374
401
  try {
375
402
  const tail = readFileSync(AUDIT_PATH, "utf-8").trim().split("\n").slice(-10);
376
403
  if (tail.length && tail[0]) {
@@ -378,7 +405,6 @@ export default function (pi: ExtensionAPI) {
378
405
  for (const l of tail) lines.push(` ${l}`);
379
406
  }
380
407
  } catch {
381
- /* no audit log yet */
382
408
  }
383
409
  ctx.ui.notify(lines.join("\n"), "info");
384
410
  },
@@ -84,9 +84,17 @@ async function pickMode(pi: ExtensionAPI, ctx: ExtensionContext, yolo: { active:
84
84
  MODES.map((m) => m.label),
85
85
  { timeout: 120_000 },
86
86
  );
87
- if (picked === undefined) return; // Esc / timeout → keep the current mode
87
+ if (picked === undefined) return;
88
88
  const next = MODES.find((m) => m.label === picked)?.value ?? current;
89
89
  if (next === current) {
90
+ if (next === "yolo" && !yolo.active) {
91
+ yolo.active = true;
92
+ emitYolo(pi.events, true);
93
+ audit({ layer: 0, event: "permission-mode", mode: "yolo", cwd: ctx.cwd, note: "runtime-sync" });
94
+ renderSandboxChip(ctx);
95
+ ctx.ui.notify("⚠️ YOLO — all security layers disabled for this session.", "warning");
96
+ return;
97
+ }
90
98
  ctx.ui.notify(`Permission mode is already ${modeLabel(next)}.`, "info");
91
99
  return;
92
100
  }
@@ -98,8 +106,6 @@ async function pickMode(pi: ExtensionAPI, ctx: ExtensionContext, yolo: { active:
98
106
  return;
99
107
  }
100
108
  yolo.active = next === "yolo";
101
- // Layers off/on in place, without a restart. `--yolo` keeps its own flag
102
- // value; this only flips the runtime state.
103
109
  emitYolo(pi.events, yolo.active);
104
110
  audit({ layer: 0, event: "permission-mode", mode: next, cwd: ctx.cwd });
105
111
  renderSandboxChip(ctx);
@@ -109,8 +115,6 @@ async function pickMode(pi: ExtensionAPI, ctx: ExtensionContext, yolo: { active:
109
115
 
110
116
  export default function (pi: ExtensionAPI) {
111
117
  const yolo = { active: false };
112
- // `sandbox.ts` owns the CLI flags and broadcasts the startup state, so the
113
- // menu shows the truth even when `--yolo` was passed for this run only.
114
118
  onYolo(pi.events, (enabled) => {
115
119
  yolo.active = enabled;
116
120
  });
@@ -30,16 +30,19 @@
30
30
 
31
31
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
32
32
  import { createBashTool } from "@earendil-works/pi-coding-agent";
33
- import { createSandboxedBashOps } from "../src/l1-sandbox/bash-ops";
34
- import { initSandbox, persistLayer1Override, reloadSandbox, resetSandbox } from "../src/l1-sandbox/manager";
35
- import { loadConfig, setPiDeclinedTrust } from "../src/l1-sandbox/config";
33
+ import { clearFilesystemSessionGrants, createSandboxedBashOps, filesystemSessionGrantSummary } from "../src/l1-sandbox/bash-ops";
34
+ import { applyNetworkGrant, initSandbox, persistLayer1Override, reloadSandbox, resetSandbox, writeNetworkOverride } from "../src/l1-sandbox/manager";
35
+ import { clearNetworkSessionGrants, type NetworkAskCtx, type NetworkAskDeps } from "../src/l1-sandbox/network-ask";
36
+ import { loadConfig, projectTrusted, setPiDeclinedTrust } from "../src/l1-sandbox/config";
37
+ import { audit } from "../src/shared/audit";
38
+ import { onDomainGrant } from "../src/shared/network-grants";
36
39
  import { emitYolo, onYolo, registerYoloFlags, yoloFromFlags, YOLO_STATUS } from "../src/shared/yolo";
37
40
 
38
41
  export default function (pi: ExtensionAPI) {
39
- // pi scopes flags per extension (ADR-020); registering here makes
40
- // `yoloFromFlags(pi)` below read the real CLI value.
41
42
  registerYoloFlags(pi);
42
43
 
44
+ onDomainGrant(pi.events, applyNetworkGrant);
45
+
43
46
  const localCwd = process.cwd();
44
47
  const localBash = createBashTool(localCwd);
45
48
 
@@ -48,10 +51,22 @@ export default function (pi: ExtensionAPI) {
48
51
  let runtimeYolo = false;
49
52
  let sandboxStarting: Promise<void> | null = null;
50
53
  let latestCtx: ExtensionContext | undefined;
54
+ let latestBashCommand: string | undefined;
51
55
  let activeCtx: { cwd: string; hasUI?: boolean; ui?: { select?: (t: string, o: string[], op?: { timeout?: number }) => Promise<string | undefined>; notify?: (m: string, l?: string) => void } } | undefined;
52
56
 
53
- const persistAndReload = (absPath: string, scope: "cwd" | "global") => persistLayer1Override(localCwd, "allowWrite", absPath, scope);
54
- const persistAndReloadRead = (absPath: string, scope: "cwd" | "global") => persistLayer1Override(localCwd, "allowRead", absPath, scope);
57
+ const persistAndReload = (absPath: string, scope: "cwd" | "global") => persistLayer1Override(localCwd, "allowWrite", absPath, scope, networkDeps(localCwd));
58
+ const persistAndReloadRead = (absPath: string, scope: "cwd" | "global") => persistLayer1Override(localCwd, "allowRead", absPath, scope, networkDeps(localCwd));
59
+
60
+ /** Layer 1 network ask wiring (ADR-023): the proxy asks for unknown hosts and applies grants live. */
61
+ const networkDeps = (cwd: string): NetworkAskDeps => ({
62
+ cwd,
63
+ getCtx: () => /* SAFETY: pi's runtime ctx carries ui.select/ui.notify; the local type names only the members used. */ activeCtx as unknown as NetworkAskCtx | undefined,
64
+ persist: (host, scope) => writeNetworkOverride(cwd, host, scope),
65
+ applyLive: applyNetworkGrant,
66
+ audit,
67
+ get projectTrusted() { return projectTrusted(cwd); },
68
+ get command() { return latestBashCommand; },
69
+ });
55
70
 
56
71
  /** Bring the bash sandbox down; safe when it was never up. */
57
72
  const stopSandbox = async (): Promise<void> => {
@@ -77,7 +92,7 @@ export default function (pi: ExtensionAPI) {
77
92
  return;
78
93
  }
79
94
  try {
80
- await initSandbox(ctx.cwd, config);
95
+ await initSandbox(ctx.cwd, config, networkDeps(ctx.cwd));
81
96
  sandboxEnabled = true;
82
97
  sandboxInitialized = true;
83
98
  const networkCount = config.network?.allowedDomains?.length ?? 0;
@@ -97,17 +112,12 @@ export default function (pi: ExtensionAPI) {
97
112
  }
98
113
  };
99
114
 
100
- // Runtime YOLO from `/permission-mode` (ADR-020): stop or restart the
101
- // sandbox in place, without a session restart.
102
115
  onYolo(pi.events, (enabled) => {
103
- // The startup broadcast originates here; ignore our own echo.
104
116
  if (enabled === runtimeYolo) return;
105
117
  runtimeYolo = enabled;
106
118
  const ctx = latestCtx;
107
119
  if (!ctx) return;
108
120
  if (!enabled) {
109
- // Gate bash until the sandbox is back up, so turning YOLO off cannot
110
- // leave a window where a command runs unfenced (ADR-020 follow-up).
111
121
  sandboxStarting = startSandbox(ctx).finally(() => {
112
122
  sandboxStarting = null;
113
123
  });
@@ -140,13 +150,20 @@ export default function (pi: ExtensionAPI) {
140
150
  return { operations: createSandboxedBashOps({ ctx: activeCtx, onAlways: persistAndReload, onAlwaysRead: persistAndReloadRead }) };
141
151
  });
142
152
 
153
+ pi.on("tool_call", (event, _ctx) => {
154
+ if (event.toolName === "bash") {
155
+ const input = event.input as { command?: string };
156
+ if (typeof input.command === "string") latestBashCommand = input.command;
157
+ }
158
+ });
159
+
143
160
  pi.on("session_start", async (_event, ctx) => {
144
161
  latestCtx = ctx;
145
162
  activeCtx = /* SAFETY: pi's runtime ctx carries cwd/hasUI/ui; the local type only names the fields we use. */ ctx as unknown as typeof activeCtx;
163
+ clearNetworkSessionGrants();
164
+ clearFilesystemSessionGrants();
146
165
  setPiDeclinedTrust((ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted?.() === false);
147
166
  runtimeYolo = yoloFromFlags(pi) || loadConfig(ctx.cwd).mode === "yolo";
148
- // Broadcast before any await: guard.ts consumes this on the same bus to
149
- // learn YOLO is on (ADR-020).
150
167
  emitYolo(pi.events, runtimeYolo);
151
168
 
152
169
  if (runtimeYolo) {
@@ -175,7 +192,7 @@ export default function (pi: ExtensionAPI) {
175
192
  return;
176
193
  }
177
194
  try {
178
- await reloadSandbox(localCwd);
195
+ await reloadSandbox(localCwd, networkDeps(localCwd));
179
196
  ctx.ui.notify("🔄 Sandbox reloaded from disk (global + project sandbox.json)", "info");
180
197
  } catch (e) {
181
198
  ctx.ui.notify(`Sandbox reload failed: ${e instanceof Error ? e.message : e}`, "error");
@@ -201,6 +218,7 @@ export default function (pi: ExtensionAPI) {
201
218
  ` Allow Write: ${config.filesystem?.allowWrite?.join(", ") || "(none)"}`,
202
219
  ` Deny Write: ${config.filesystem?.denyWrite?.join(", ") || "(none)"}`,
203
220
  ` Outside project reads: ${config.filesystem?.outsideProject?.read ?? "allow"}`,
221
+ ` Session grants: ${filesystemSessionGrantSummary() || "(none)"}`,
204
222
  ];
205
223
  ctx.ui.notify(lines.join("\n"), "info");
206
224
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-permission-system",
3
- "version": "3.0.1",
3
+ "version": "3.7.8",
4
4
  "description": "Security sandbox, permission system and tool guard for the Pi coding agent (pi): enforce filesystem allow/deny lists and network domain allowlists on read, write, edit, bash, grep, find, ls, fetch_content and web_search, with interactive ask prompts, secret detection and an audit log.",
5
5
  "type": "module",
6
6
  "author": "Navin Kumar",
@@ -39,7 +39,8 @@
39
39
  "@anthropic-ai/sandbox-runtime": "^0.0.78"
40
40
  },
41
41
  "peerDependencies": {
42
- "@earendil-works/pi-coding-agent": "*"
42
+ "@earendil-works/pi-coding-agent": "*",
43
+ "@earendil-works/pi-tui": "*"
43
44
  },
44
45
  "devDependencies": {
45
46
  "@commitlint/cli": "^21.2.3",
@@ -119,6 +119,16 @@
119
119
  "network": "allow"
120
120
  },
121
121
 
122
+ "_comment_mcp": "Advanced Secure only. Tune the MCP tool-call gate (ADR-032). allowTools = exact tool names that never ask; allowPrefixes = name prefixes that never ask (e.g. 'mcp__ticktick__get_'); askTools = tool names that always ask; trustAnnotations = a server's readOnlyHint allows the call without asking; allowSimpleUpdates = narrow field updates skip the ask (default true); askThreshold = risk score at which calls ask (default 30, lower = stricter). Detected destructive calls always ask, unless an allowTools/allowPrefixes entry covers them (an explicit override). Unclassified names with a real description start at 25; a tool with no description counts as opaque and still asks. An untrusted project file may only tighten: it may add askTools, lower askThreshold, and turn allowSimpleUpdates/trustAnnotations off; allow lists need /security trust.",
123
+ "mcp": {
124
+ "allowTools": [],
125
+ "allowPrefixes": [],
126
+ "askTools": [],
127
+ "trustAnnotations": false,
128
+ "allowSimpleUpdates": true,
129
+ "askThreshold": 30
130
+ },
131
+
122
132
  "_comment_overrides": "Additive overrides written by ask-tier prompts. You can also edit by hand. These are merged on top of the main policy.",
123
133
  "overrides": {
124
134
  "allowRead": [],
@@ -14,7 +14,7 @@ Wraps the `bash` tool with `sandbox-exec` (macOS) or `bubblewrap` (Linux). Block
14
14
  - Filesystem writes outside `allowWrite`
15
15
  - Filesystem reads of `denyRead` paths (defaults include `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.pi/agent`)
16
16
  - Filesystem reads outside the project when `outsideProject.read` is `"ask"` or `"deny"` — when set, the home dir (and the project's parent) is fenced with `denyRead` and the project plus `allowWrite`/`outsideProject.allowRead` are re-exposed (ADR-014). With `"ask"`, a bash command that *plainly* names an outside path is confirmed with you **before it runs** (ADR-015): allow (`yes — this once`, or a persistent `always` grant) → the real, complete output; deny → the command is not run at all (no silently trimmed output). `install`/`npm` under `~/.pi/agent` are pre-allowed via `outsideProject.allowRead`, so they never prompt. Obfuscated reads still hit the fence and stay masked.
17
- - Network egress to domains not in `allowedDomains`
17
+ - Network egress to domains not in `allowedDomains`: a host in neither list asks before the proxy decides (ADR-023) — `yes — this once` covers that bash command's connections to the host, `yes — for this session` lasts until the session ends, and `always` writes `overrides.allowDomains` and applies live. A host in `deniedDomains`, or `network.strictAllowlist: true`, is a hard deny that is never ask-able; headless blocks.
18
18
 
19
19
  **Layer 2 — In-process tool guard**
20
20
  Hooks `tool_call` for `read`, `grep`, `find`, `ls`, `write`, `edit`, `fetch_content`, `web_search`, `get_search_content`. Applies the same policy file as Layer 1. For `grep`, `find` and `ls` the search root is checked like a `read` path. A `tool_result` hook also removes `grep` output lines from denied files beneath an allowed root (for example a `.env` deep in the tree); approving the root does not approve those files. When a call is blocked, shows an interactive prompt with persistence options.
@@ -39,7 +39,8 @@ A higher layer only states what it changes; absent keys keep the layer below. A
39
39
  "mode": "default", // "default" (rules) | "advanced-secure" (secret detection, ADR-018)
40
40
  "network": {
41
41
  "allowedDomains": ["github.com", "*.github.com"], // empty = allow all
42
- "deniedDomains": []
42
+ "deniedDomains": [], // hard deny, never ask-able
43
+ "strictAllowlist": false // true = deny unknown hosts instead of asking
43
44
  },
44
45
  "filesystem": {
45
46
  "denyRead": ["~/.ssh", "~/.pi/agent"], // Layer 1 + 2: hard block (defaults add ~/.aws, ~/.gnupg)
@@ -49,6 +50,14 @@ A higher layer only states what it changes; absent keys keep the layer below. A
49
50
  "denyWrite": [".env", "*.pem"] // Layer 1 + 2: always blocked
50
51
  },
51
52
  "subagent": { "network": "allow" },
53
+ "mcp": { // Advanced Secure MCP gate tuning (ADR-032)
54
+ "allowTools": ["mcp__diffusion_studio__context"], // exact names that never ask
55
+ "allowPrefixes": ["mcp__ticktick__get_"], // name prefixes that never ask
56
+ "askTools": [], // exact names that always ask
57
+ "trustAnnotations": false, // true = a server's readOnlyHint allows without asking
58
+ "allowSimpleUpdates": true, // narrow field updates skip the ask
59
+ "askThreshold": 30 // risk score at which a call asks (lower = stricter)
60
+ },
52
61
  "commands": {
53
62
  "ask": ["printenv", "env", "/proc/*/environ"] // bash: ask before these (they can print env tokens)
54
63
  },
@@ -83,9 +92,13 @@ When a normal (non-absolute-deny) call is blocked:
83
92
  - **always for ALL projects** — whitelist in `~/.pi/agent/extensions/sandbox.json`
84
93
  - **always for ALL projects (folder)** — whitelist parent directory globally
85
94
 
95
+ A **network** (domain) ask keeps the same rows, but the *unanswered* default flips (ADR-024): the footer reads `Default: Yes, just this once (15s)`, and a countdown that runs out counts as **allow once for this bash command**. `No` is still preselected (Enter blocks) and Esc still denies. A domain in `deniedDomains` (or `network.strictAllowlist: true`) never prompts.
96
+
86
97
  For a Layer 1 bash **write** blocked after the command already ran, `yes — this once` re-runs the command once with the offending path's parent folder allowed for that invocation only (never saved); the same folder is not offered again within that invocation, and `denyWrite` still wins over the grant. The block is recognized from the error output either way it is reported — `EPERM`/`Operation not permitted` on macOS, `EROFS`/`Read-only file system` on Linux (ADR-022) — and a refused write is never offered as a read grant. If the output never names the operand, there is nothing to attribute and no prompt, so the command is simply refused.
87
98
 
88
- Every prompt waits **10 seconds** by default; no answer means the safe default: **block / deny**. Esc on any screen also blocks.
99
+ Every prompt waits **15 seconds** by default and shows it live as `Default: … (Ns)` under the body. For file reads/writes no answer means the safe default: **block / deny**, and Esc blocks. For a network ask, an unanswered countdown means **allow once** (Esc still denies) — ADR-024. In the TUI the `Default:` line disappears as soon as you press ↑/↓ and the timer restarts, so the hint never competes with an active choice (ADR-031). The prompt is keyboard-only (ADR-036): ↑/↓ move the highlight, Enter selects, Esc cancels — a mouse click or wheel over the prompt does nothing.
100
+
101
+ The same options apply to a bash command whose network request reaches a host outside `allowedDomains` (ADR-023), with the countdown default and live footer from ADR-024/ADR-031: `No` / `Yes, just this once` / `Yes, all in group for this session` / `Yes, always…`, and an untouched countdown counts as allow-once. Because the sandbox proxy asks per connection, `Allow (default)` covers that bash command rather than one connection — redirects and parallel connections to the same host do not re-prompt. A host in `deniedDomains`, or `network.strictAllowlist: true`, is blocked outright: the prompt never appears. A remembered host is written to `overrides.allowDomains` and takes effect immediately, so the waiting request completes without a retry. Prompts from both layers are serialized through one queue (`src/ui/ask-flow.ts`), so two unknown hosts cannot replace each other's selector and hang the waiting connection.
89
102
 
90
103
  ## Sensitive reads ask
91
104
 
@@ -98,7 +111,7 @@ Every prompt waits **10 seconds** by default; no answer means the safe default:
98
111
  - **`default`** (shipped) — rules only. No content inspection.
99
112
  - **`advanced-secure`** (recommended) — adds secret/credential detection, and **only adds asks or blocks** (never loosens):
100
113
  - file reads whose name looks like a credential store ask before the read;
101
- - risky `mcp__<server>__<tool>` calls ask before they run;
114
+ - risky `mcp__<server>__<tool>` calls ask before they run. The gate ignores the `mcp__<server>__` namespace, takes the operation from the head verb of the name (or the first verb of the description), and asks for destructive names, mutating names (`update_*`, `create_*`, `run_*`, `evaluate_*`), and destructive arguments (SQL `DELETE`/`UPDATE` without `WHERE`, `rm -rf`, `*` targets, HTTP `DELETE`, production targets). A documented tool with an unclassified name (`context`, `capture`, `logs`) does not ask; a tool with **no description** counts as opaque and does. Tune it under `mcp` in `sandbox.json` (ADR-032): `allowTools` / `allowPrefixes` skip asks, `askTools` forces them, `trustAnnotations` lets a server's `readOnlyHint` allow the call, `askThreshold` moves the score line (default 30). An untrusted project file may only tighten it (add `askTools`, lower `askThreshold`, turn `allowSimpleUpdates`/`trustAnnotations` off).
102
115
  - before any tool/command/file output reaches the model, a secret-like hit shows the file and each detected line as `lineNo: text`, with two choices — **No, keep private** or **Yes, allow**. No, keep private withholds the output and tells the model it was withheld because it may contain sensitive information (not a failure, not an empty result).
103
116
 
104
117
  Switch with `/permission-mode`; all three modes are saved to the global `sandbox.json` and reflected in the footer's sandbox chip (`Sandbox: 🧠 N domains, M paths` for advanced-secure, `Sandbox: 🔒 …` for default). An untrusted project may move the mode **up** (toward stricter), never down: it cannot turn on YOLO or turn off Advanced Secure. The third choice, **YOLO**, disables every layer; the chip reads `⚠️ YOLO — all security layers disabled` until you pick another mode (ADR-020).
@@ -129,7 +142,7 @@ Without restarting, `/permission-mode` → **YOLO** turns every layer off in one
129
142
 
130
143
  ## Common tasks
131
144
 
132
- **Allow a new domain** — either answer "always" in the prompt when the block fires, or add it manually:
145
+ **Allow a new domain** — either answer "always" in the prompt when the block fires (from a bash command or a URL tool), or add it manually:
133
146
  ```json
134
147
  // ~/.pi/agent/extensions/sandbox.json (global)
135
148
  // or <cwd>/.pi/sandbox.json (project-local)
@@ -138,6 +151,13 @@ Without restarting, `/permission-mode` → **YOLO** turns every layer off in one
138
151
 
139
152
  **Allow writes to a new path** — same pattern with `allowWrite`.
140
153
 
154
+ **Allow an MCP tool** — in Advanced Secure mode, a `mcp__<server>__<tool>` call that keeps asking can be allowed without weakening anything else:
155
+ ```json
156
+ // ~/.pi/agent/extensions/sandbox.json (global)
157
+ { "mcp": { "allowPrefixes": ["mcp__ticktick__get_"], "allowTools": ["mcp__diffusion_studio__export"] } }
158
+ ```
159
+ `allowTools` matches exact names, `allowPrefixes` matches the start (case-insensitive). Detected destructive calls still ask unless an exact `allowTools` entry names them. `"trustAnnotations": true` lets servers that mark a tool `readOnlyHint` skip the ask, and a project's `mcp` file may only tighten (add `askTools`, lower `askThreshold`, turn `allowSimpleUpdates`/`trustAnnotations` off) until you trust it.
160
+
141
161
  **View recent blocks** — run `/security` and read the audit section, or:
142
162
  ```bash
143
163
  tail -20 ~/.pi/agent/audit.log | python3 -m json.tool
@@ -3,7 +3,7 @@
3
3
  * `path-N- text` for a context line. The separator is the same on both sides
4
4
  * of the line number, which keeps paths containing `-` or `:` parseable.
5
5
  */
6
- const GREP_LINE = /^(.+?)([:-])(\d+)\2 /;
6
+ const GREP_LINE_RE = /^(.+?)([:-])(\d+)\2 /;
7
7
 
8
8
  export interface GrepFilterResult {
9
9
  text: string;
@@ -20,26 +20,26 @@ export interface GrepFilterResult {
20
20
  * per path.
21
21
  */
22
22
  export function filterGrepOutput(text: string, isDenied: (printedPath: string) => boolean): GrepFilterResult {
23
- const cache = new Map<string, boolean>();
23
+ const deniedByPath = new Map<string, boolean>();
24
24
  const removedFiles = new Set<string>();
25
25
  let removedLines = 0;
26
- const kept: string[] = [];
26
+ const keptLines: string[] = [];
27
27
  for (const line of text.split("\n")) {
28
- const m = GREP_LINE.exec(line);
29
- if (m) {
30
- const file = m[1];
31
- let denied = cache.get(file);
28
+ const match = GREP_LINE_RE.exec(line);
29
+ if (match) {
30
+ const printedPath = match[1];
31
+ let denied = deniedByPath.get(printedPath);
32
32
  if (denied === undefined) {
33
- denied = isDenied(file);
34
- cache.set(file, denied);
33
+ denied = isDenied(printedPath);
34
+ deniedByPath.set(printedPath, denied);
35
35
  }
36
36
  if (denied) {
37
37
  removedLines++;
38
- removedFiles.add(file);
38
+ removedFiles.add(printedPath);
39
39
  continue;
40
40
  }
41
41
  }
42
- kept.push(line);
42
+ keptLines.push(line);
43
43
  }
44
- return { text: kept.join("\n"), removedLines, removedFiles: [...removedFiles] };
44
+ return { text: keptLines.join("\n"), removedLines, removedFiles: [...removedFiles] };
45
45
  }