auto-permission-system 3.6.0 → 3.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +10 -2
  2. package/extensions/guard.ts +17 -35
  3. package/extensions/permission-mode.ts +9 -5
  4. package/extensions/sandbox.ts +5 -18
  5. package/package.json +1 -1
  6. package/sandbox.example.json +10 -0
  7. package/skills/auto-permission-system/SKILL.md +17 -2
  8. package/src/core/grep-filter.ts +12 -12
  9. package/src/core/index.ts +4 -1
  10. package/src/core/policy/classify.ts +22 -10
  11. package/src/core/policy/commands.ts +17 -15
  12. package/src/core/policy/default-file.ts +8 -9
  13. package/src/core/policy/defaults.ts +9 -0
  14. package/src/core/policy/domains.ts +5 -5
  15. package/src/core/policy/fence.ts +44 -37
  16. package/src/core/policy/merge.ts +12 -12
  17. package/src/core/policy/mode.ts +17 -8
  18. package/src/core/policy/overrides.ts +56 -0
  19. package/src/core/policy/paths.ts +16 -0
  20. package/src/core/policy/patterns.ts +87 -51
  21. package/src/core/policy/{files.ts → policy-file.ts} +11 -6
  22. package/src/core/policy/subagent.ts +11 -0
  23. package/src/core/trust.ts +155 -92
  24. package/src/core/user-named.ts +36 -32
  25. package/src/detect/filename-gate/classify.ts +0 -9
  26. package/src/detect/mcp-gate/constants.ts +2 -0
  27. package/src/detect/mcp-gate/evaluate.ts +36 -22
  28. package/src/detect/mcp-gate/index.ts +2 -0
  29. package/src/detect/mcp-gate/signals.ts +2 -13
  30. package/src/detect/mcp-gate/verbs.ts +31 -9
  31. package/src/detect/sensitive-file-scanner/assignment.ts +3 -6
  32. package/src/detect/sensitive-file-scanner/content-scan.ts +0 -4
  33. package/src/detect/sensitive-file-scanner/file-scan.ts +0 -10
  34. package/src/detect/sensitive-file-scanner/jwt.ts +4 -5
  35. package/src/detect/sensitive-file-scanner/redaction.ts +0 -6
  36. package/src/detect/sensitive-file-scanner/value-analysis.ts +4 -15
  37. package/src/detect/tool-output-scanner/evaluate.ts +0 -1
  38. package/src/detect/tool-output-scanner/scan.ts +0 -2
  39. package/src/l1-sandbox/bash-ops.ts +192 -103
  40. package/src/l1-sandbox/config.ts +42 -68
  41. package/src/l1-sandbox/manager.ts +69 -37
  42. package/src/l1-sandbox/network-ask.ts +61 -52
  43. package/src/l2-guard/matching.ts +66 -94
  44. package/src/l2-guard/policy.ts +36 -40
  45. package/src/l2-guard/prompts.ts +61 -65
  46. package/src/l2-guard/subagent.ts +11 -11
  47. package/src/shared/audit.ts +0 -1
  48. package/src/shared/canonicalize.ts +29 -0
  49. package/src/shared/yolo.ts +0 -1
  50. package/src/ui/ask-flow.ts +160 -82
  51. package/src/ui/ask-selector.ts +28 -10
  52. package/src/ui/ask.ts +157 -126
package/README.md CHANGED
@@ -89,6 +89,14 @@ A higher layer only states what it changes; absent keys keep the layer below. A
89
89
  "subagent": {
90
90
  "network": "allow" // "allow" | "deny" | "research-only"
91
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
+ },
92
100
  "commands": {
93
101
  "ask": ["printenv", "env", "/proc/*/environ"] // bash: ask before these (they can print env tokens)
94
102
  }
@@ -124,7 +132,7 @@ Never asked about: the project itself, your `allowWrite` roots, pi's own package
124
132
 
125
133
  ### Ask-tier prompt
126
134
 
127
- `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). 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.
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.
128
136
 
129
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.
130
138
 
@@ -139,7 +147,7 @@ An entry without `/` or `~` (`.env`, `*.key`) is a file name. pi's own tools (La
139
147
  - **`default`** (shipped) — rules only: sandbox.json paths, domains, and commands. Nothing inspects file contents.
140
148
  - **`advanced-secure`** (recommended) — adds secret/credential detection:
141
149
  - **File reads:** a filename that looks like a credential store asks before the read.
142
- - **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).
143
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.
144
152
 
145
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.
@@ -11,7 +11,7 @@ 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";
@@ -77,6 +77,7 @@ async function detectMcpAsk(
77
77
  toolName: string,
78
78
  input: Record<string, unknown>,
79
79
  ctx: UICtx,
80
+ mcpPolicy?: McpPolicy,
80
81
  ): Promise<{ block: true; reason: string } | null> {
81
82
  const info = pi.getAllTools().find((t) => t.name === toolName);
82
83
  const result = evaluateMcpCall({
@@ -87,6 +88,7 @@ async function detectMcpAsk(
87
88
  annotations: /* SAFETY: pi's ToolAnnotations mirrors the MCP hints McpToolAnnotations names. */ info?.annotations as McpToolAnnotations | undefined,
88
89
  },
89
90
  call: { name: toolName, arguments: input },
91
+ policy: mcpPolicy,
90
92
  });
91
93
  if (result.decision !== "ask") return null;
92
94
  return askDetection(ctx, "tool", toolName, result.summary);
@@ -101,10 +103,6 @@ export default function (pi: ExtensionAPI) {
101
103
  active = policyEnabled && !runtimeYolo;
102
104
  };
103
105
 
104
- // `--yolo` and the `/permission-mode` toggle both arrive on pi's process-wide
105
- // bus; `sandbox.ts` is the only extension that registers the CLI flags and
106
- // broadcasts the state (ADR-020). The entrypoints share no memory, so this is
107
- // the only channel that reaches Layer 2.
108
106
  onYolo(pi.events, (enabled) => {
109
107
  runtimeYolo = enabled;
110
108
  applyActive();
@@ -112,8 +110,6 @@ export default function (pi: ExtensionAPI) {
112
110
  });
113
111
 
114
112
  pi.on("session_start", (_event, ctx) => {
115
- // A policy file that does not parse is skipped and the defaults apply.
116
- // Say so loudly: otherwise one trailing comma silently disables the whole file.
117
113
  for (const p of [`${getAgentDir()}/extensions/sandbox.json`, `${ctx.cwd}/.pi/sandbox.json`]) {
118
114
  const err = policyFileError(p);
119
115
  if (!err) continue;
@@ -151,14 +147,9 @@ export default function (pi: ExtensionAPI) {
151
147
  if (!active) return;
152
148
  const policy = loadPolicy(ctx.cwd);
153
149
 
154
- // Capture the program id (binary for bash, MCP server for mcp__ tools) so the
155
- // Advanced Secure output gate can label the prompt and honor a session grant
156
- // keyed by that program. Falls back to the tool name when nothing else fits.
157
150
  const programId = programIdForToolCall(event.toolName, event.input);
158
151
  if (event.toolCallId && programId) programIdByCallId.set(event.toolCallId, programId);
159
152
 
160
- // --- Path-based gates (with ask-tier prompt) ---
161
- // One gate for every read: the read policy first, then the project boundary.
162
153
  const gateRead = async (rawPath: string, tool: string) => {
163
154
  const abs = canonicalize(rawPath, ctx.cwd);
164
155
  let reason = isDeniedRead(rawPath, ctx.cwd, policy);
@@ -190,10 +181,6 @@ export default function (pi: ExtensionAPI) {
190
181
  }
191
182
  }
192
183
  }
193
- // Read-only search tools. grep returns file contents and runs ripgrep in
194
- // process, so neither the bash sandbox nor the read gate saw it before.
195
- // Gate the search root here; tool_result below drops output lines from
196
- // denied files beneath the root.
197
184
  const searchRoot = isToolCallEventType("grep", event) || isToolCallEventType("find", event) || isToolCallEventType("ls", event)
198
185
  ? (event.input.path ?? ".")
199
186
  : null;
@@ -218,7 +205,6 @@ export default function (pi: ExtensionAPI) {
218
205
  }
219
206
  }
220
207
 
221
- // --- URL/domain gates (best-effort by tool name; tools are extension-defined) ---
222
208
  const input = event.input as Record<string, unknown> | undefined;
223
209
  const collectUrls = (): string[] => {
224
210
  if (!input) return [];
@@ -234,8 +220,6 @@ export default function (pi: ExtensionAPI) {
234
220
  const saReason = subagentNetworkBlock(ctx as { hasUI?: boolean; sessionManager?: unknown }, policy);
235
221
  if (saReason) return { block: true, reason: saReason };
236
222
  for (const u of collectUrls()) {
237
- // deniedDomains is the hard-deny tier: the bash proxy refuses it before
238
- // any ask, so Layer 2 must not offer to override it either.
239
223
  const denied = deniedUrlReason(u, policy);
240
224
  if (denied) {
241
225
  audit({ layer: 2, tool: event.toolName, subject: u, reason: denied, decision: "no", note: "denied-domain", cwd: ctx.cwd });
@@ -245,7 +229,6 @@ export default function (pi: ExtensionAPI) {
245
229
  if (!reason) continue;
246
230
  const host = hostnameOf(u) ?? u;
247
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) => {
248
- // Layer 1's proxy does not re-read the file: apply the remembered host live (ADR-023).
249
232
  if (kind === "allowDomains") emitDomainGrant(pi.events, value);
250
233
  });
251
234
  if (result) return result;
@@ -255,22 +238,14 @@ export default function (pi: ExtensionAPI) {
255
238
  if (event.toolName === "web_search") {
256
239
  const saReason = subagentNetworkBlock(ctx as { hasUI?: boolean; sessionManager?: unknown }, policy);
257
240
  if (saReason) return { block: true, reason: saReason };
258
- // web_search itself goes to the search provider (out of scope for v1
259
- // allowlist). The follow-on fetch_content for individual results is
260
- // the catchable surface.
261
241
  }
262
242
 
263
- // Advanced Secure (ADR-018): classify MCP tool calls before they run.
264
243
  if (policy.mode === "advanced-secure" && event.toolName.startsWith("mcp__")) {
265
- 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);
266
245
  if (det) return det;
267
246
  }
268
247
  });
269
248
 
270
- // grep output filter. A root that passed the gate can still contain denied
271
- // files (`.env`, `*.pem`, `~/.ssh` under a search of `~`). Approving the
272
- // root does not approve the denied files beneath it. Runs before the
273
- // result reaches the model.
274
249
  pi.on("tool_result", (event, ctx) => {
275
250
  if (!active || event.toolName !== "grep" || event.isError) return;
276
251
  const input = event.input as { path?: string } | undefined;
@@ -282,7 +257,6 @@ export default function (pi: ExtensionAPI) {
282
257
  } catch {
283
258
  return;
284
259
  }
285
- // A single-file search prints only the basename; its root gate already decided.
286
260
  if (!rootIsDir) return;
287
261
  let removedLines = 0;
288
262
  const removedFiles = new Set<string>();
@@ -299,10 +273,6 @@ export default function (pi: ExtensionAPI) {
299
273
  return { content };
300
274
  });
301
275
 
302
- // Advanced Secure (ADR-018): before any tool output reaches the model, ask
303
- // the user when it looks like it carries a secret. Block withholds it and
304
- // tells the model plainly, so the model does not read it as a failure or an
305
- // empty result; Allow send passes the real values through.
306
276
  pi.on("tool_result", async (event, ctx) => {
307
277
  if (!active || event.isError) return;
308
278
  const policy = loadPolicy(ctx.cwd);
@@ -415,6 +385,19 @@ export default function (pi: ExtensionAPI) {
415
385
  ` allowWrite: ${(overrides.allowWrite ?? []).join(", ") || "(none)"}`,
416
386
  ` allowDomains: ${(overrides.allowDomains ?? []).join(", ") || "(none)"}`,
417
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
+ }
418
401
  try {
419
402
  const tail = readFileSync(AUDIT_PATH, "utf-8").trim().split("\n").slice(-10);
420
403
  if (tail.length && tail[0]) {
@@ -422,7 +405,6 @@ export default function (pi: ExtensionAPI) {
422
405
  for (const l of tail) lines.push(` ${l}`);
423
406
  }
424
407
  } catch {
425
- /* no audit log yet */
426
408
  }
427
409
  ctx.ui.notify(lines.join("\n"), "info");
428
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,21 +30,17 @@
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";
33
+ import { clearFilesystemSessionGrants, createSandboxedBashOps, filesystemSessionGrantSummary } from "../src/l1-sandbox/bash-ops";
34
34
  import { applyNetworkGrant, initSandbox, persistLayer1Override, reloadSandbox, resetSandbox, writeNetworkOverride } from "../src/l1-sandbox/manager";
35
35
  import { clearNetworkSessionGrants, type NetworkAskCtx, type NetworkAskDeps } from "../src/l1-sandbox/network-ask";
36
- import { loadConfig, setPiDeclinedTrust } from "../src/l1-sandbox/config";
36
+ import { loadConfig, projectTrusted, setPiDeclinedTrust } from "../src/l1-sandbox/config";
37
37
  import { audit } from "../src/shared/audit";
38
38
  import { onDomainGrant } from "../src/shared/network-grants";
39
39
  import { emitYolo, onYolo, registerYoloFlags, yoloFromFlags, YOLO_STATUS } from "../src/shared/yolo";
40
40
 
41
41
  export default function (pi: ExtensionAPI) {
42
- // pi scopes flags per extension (ADR-020); registering here makes
43
- // `yoloFromFlags(pi)` below read the real CLI value.
44
42
  registerYoloFlags(pi);
45
43
 
46
- // A domain remembered from the Layer 2 guard prompt is written to
47
- // sandbox.json by the guard; apply it to the running proxy here (ADR-023).
48
44
  onDomainGrant(pi.events, applyNetworkGrant);
49
45
 
50
46
  const localCwd = process.cwd();
@@ -68,10 +64,7 @@ export default function (pi: ExtensionAPI) {
68
64
  persist: (host, scope) => writeNetworkOverride(cwd, host, scope),
69
65
  applyLive: applyNetworkGrant,
70
66
  audit,
71
- // Lazy getters so each proxy ask reads the live trust state and the most
72
- // recent bash command. Without these, the values captured at initSandbox()
73
- // would be stale.
74
- get projectTrusted() { return latestCtx?.isProjectTrusted?.(); },
67
+ get projectTrusted() { return projectTrusted(cwd); },
75
68
  get command() { return latestBashCommand; },
76
69
  });
77
70
 
@@ -119,17 +112,12 @@ export default function (pi: ExtensionAPI) {
119
112
  }
120
113
  };
121
114
 
122
- // Runtime YOLO from `/permission-mode` (ADR-020): stop or restart the
123
- // sandbox in place, without a session restart.
124
115
  onYolo(pi.events, (enabled) => {
125
- // The startup broadcast originates here; ignore our own echo.
126
116
  if (enabled === runtimeYolo) return;
127
117
  runtimeYolo = enabled;
128
118
  const ctx = latestCtx;
129
119
  if (!ctx) return;
130
120
  if (!enabled) {
131
- // Gate bash until the sandbox is back up, so turning YOLO off cannot
132
- // leave a window where a command runs unfenced (ADR-020 follow-up).
133
121
  sandboxStarting = startSandbox(ctx).finally(() => {
134
122
  sandboxStarting = null;
135
123
  });
@@ -162,7 +150,6 @@ export default function (pi: ExtensionAPI) {
162
150
  return { operations: createSandboxedBashOps({ ctx: activeCtx, onAlways: persistAndReload, onAlwaysRead: persistAndReloadRead }) };
163
151
  });
164
152
 
165
- // Capture the latest bash command so the network ask body can show it.
166
153
  pi.on("tool_call", (event, _ctx) => {
167
154
  if (event.toolName === "bash") {
168
155
  const input = event.input as { command?: string };
@@ -174,10 +161,9 @@ export default function (pi: ExtensionAPI) {
174
161
  latestCtx = ctx;
175
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;
176
163
  clearNetworkSessionGrants();
164
+ clearFilesystemSessionGrants();
177
165
  setPiDeclinedTrust((ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted?.() === false);
178
166
  runtimeYolo = yoloFromFlags(pi) || loadConfig(ctx.cwd).mode === "yolo";
179
- // Broadcast before any await: guard.ts consumes this on the same bus to
180
- // learn YOLO is on (ADR-020).
181
167
  emitYolo(pi.events, runtimeYolo);
182
168
 
183
169
  if (runtimeYolo) {
@@ -232,6 +218,7 @@ export default function (pi: ExtensionAPI) {
232
218
  ` Allow Write: ${config.filesystem?.allowWrite?.join(", ") || "(none)"}`,
233
219
  ` Deny Write: ${config.filesystem?.denyWrite?.join(", ") || "(none)"}`,
234
220
  ` Outside project reads: ${config.filesystem?.outsideProject?.read ?? "allow"}`,
221
+ ` Session grants: ${filesystemSessionGrantSummary() || "(none)"}`,
235
222
  ];
236
223
  ctx.ui.notify(lines.join("\n"), "info");
237
224
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-permission-system",
3
- "version": "3.6.0",
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",
@@ -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": [],
@@ -50,6 +50,14 @@ A higher layer only states what it changes; absent keys keep the layer below. A
50
50
  "denyWrite": [".env", "*.pem"] // Layer 1 + 2: always blocked
51
51
  },
52
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
+ },
53
61
  "commands": {
54
62
  "ask": ["printenv", "env", "/proc/*/environ"] // bash: ask before these (they can print env tokens)
55
63
  },
@@ -88,7 +96,7 @@ A **network** (domain) ask keeps the same rows, but the *unanswered* default fli
88
96
 
89
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.
90
98
 
91
- 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).
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.
92
100
 
93
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.
94
102
 
@@ -103,7 +111,7 @@ The same options apply to a bash command whose network request reaches a host ou
103
111
  - **`default`** (shipped) — rules only. No content inspection.
104
112
  - **`advanced-secure`** (recommended) — adds secret/credential detection, and **only adds asks or blocks** (never loosens):
105
113
  - file reads whose name looks like a credential store ask before the read;
106
- - 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).
107
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).
108
116
 
109
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).
@@ -143,6 +151,13 @@ Without restarting, `/permission-mode` → **YOLO** turns every layer off in one
143
151
 
144
152
  **Allow writes to a new path** — same pattern with `allowWrite`.
145
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
+
146
161
  **View recent blocks** — run `/security` and read the audit section, or:
147
162
  ```bash
148
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
  }
package/src/core/index.ts CHANGED
@@ -5,13 +5,16 @@
5
5
  export * from "./policy/defaults";
6
6
  export * from "./policy/domains";
7
7
  export * from "./policy/default-file";
8
- export * from "./policy/files";
8
+ export * from "./policy/policy-file";
9
9
  export * from "./policy/merge";
10
10
  export * from "./policy/mode";
11
+ export * from "./policy/overrides";
12
+ export * from "./policy/paths";
11
13
  export * from "./policy/patterns";
12
14
  export * from "./policy/classify";
13
15
  export * from "./policy/commands";
14
16
  export * from "./policy/fence";
17
+ export * from "./policy/subagent";
15
18
  export * from "./grep-filter";
16
19
  export * from "./trust";
17
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
+ ];