auto-permission-system 3.19.0 → 3.20.4

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 (48) hide show
  1. package/extensions/guard.ts +210 -364
  2. package/extensions/permission-mode.ts +112 -73
  3. package/extensions/sandbox.ts +228 -138
  4. package/package.json +1 -1
  5. package/src/core/grep-filter.ts +33 -32
  6. package/src/core/index.ts +16 -20
  7. package/src/core/policy/classify.ts +27 -20
  8. package/src/core/policy/commands.ts +47 -30
  9. package/src/core/policy/fence.ts +101 -80
  10. package/src/core/policy/overrides.ts +31 -35
  11. package/src/core/policy/patterns.ts +39 -0
  12. package/src/core/policy/policy-file.ts +20 -36
  13. package/src/core/trust.ts +217 -142
  14. package/src/core/user-named.ts +88 -63
  15. package/src/detect/filename-gate/classify.ts +2 -2
  16. package/src/detect/filename-gate/index.ts +4 -4
  17. package/src/detect/index.ts +4 -4
  18. package/src/detect/mcp-gate/evaluate.ts +4 -4
  19. package/src/detect/mcp-gate/index.ts +3 -3
  20. package/src/detect/mcp-gate/signals.ts +3 -3
  21. package/src/detect/mcp-gate/verbs.ts +1 -1
  22. package/src/detect/sensitive-file-scanner/assignment.ts +2 -2
  23. package/src/detect/sensitive-file-scanner/content-scan.ts +10 -10
  24. package/src/detect/sensitive-file-scanner/file-scan.ts +8 -8
  25. package/src/detect/sensitive-file-scanner/index.ts +7 -7
  26. package/src/detect/sensitive-file-scanner/patterns.ts +1 -1
  27. package/src/detect/sensitive-file-scanner/redaction.ts +6 -6
  28. package/src/detect/sensitive-file-scanner/scoring.ts +3 -3
  29. package/src/detect/sensitive-file-scanner/value-analysis.ts +3 -3
  30. package/src/detect/tool-output-scanner/evaluate.ts +5 -5
  31. package/src/detect/tool-output-scanner/index.ts +3 -3
  32. package/src/detect/tool-output-scanner/scan.ts +6 -5
  33. package/src/detect/tool-output-scanner/size.ts +2 -2
  34. package/src/detect/tool-output-scanner/types.ts +1 -1
  35. package/src/l1-sandbox/bash-artifacts.ts +70 -0
  36. package/src/l1-sandbox/bash-ops.ts +130 -437
  37. package/src/l1-sandbox/bash-preflight.ts +292 -0
  38. package/src/l1-sandbox/bash-recovery.ts +566 -0
  39. package/src/l1-sandbox/manager.ts +0 -14
  40. package/src/l2-guard/detection.ts +140 -0
  41. package/src/l2-guard/output-gates.ts +191 -0
  42. package/src/l2-guard/report.ts +158 -0
  43. package/src/l2-guard/tool-gates.ts +189 -0
  44. package/src/shared/audit.ts +13 -8
  45. package/src/shared/canonicalize.ts +23 -20
  46. package/src/shared/network-grants.ts +12 -19
  47. package/src/shared/yolo.ts +24 -50
  48. package/src/ui/ask.ts +403 -225
@@ -1,426 +1,272 @@
1
- /**
2
- * Pi Security Guard — Layer 2 entrypoint (thin pi wiring).
3
- *
4
- * Hooks `tool_call` for the in-process tools the bash sandbox can't reach, and
5
- * `tool_result` to filter grep output and (in Advanced Secure) gate secrets.
6
- * Logic lives in `src/l2-guard/` and `src/core/`. Disabled by `--yolo`.
7
- */
8
-
9
- import { existsSync, readFileSync, statSync } from "node:fs";
10
- import { isAbsolute, join, resolve } from "node:path";
11
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
2
  import { isToolCallEventType, getAgentDir } from "@earendil-works/pi-coding-agent";
13
- import { filterGrepOutput, policyFileError, forgetProjectTrust, isProjectFileDeclined, recordProjectDeclined, recordProjectTrust } from "../src/core/index";
14
- import { classifyFilename, evaluateMcpCall, type JsonSchemaLike, type McpPolicy, type McpToolAnnotations } from "../src/detect/index";
15
- import { collectExposure } from "../src/l2-guard/exposure";
16
- import { askExposure, askMain, withheldNotice } from "../src/ui/ask-flow";
17
- import { loadPolicy, projectPolicyPath, projectTrusted, setPiDeclinedTrust, untrustedProjectChanges, bullets, TRUST_STORE } from "../src/l2-guard/policy";
18
- import { canonicalize, expandHome, isAbsoluteDeny, isAskRead, isDeniedRead, isDeniedWrite, outsideProjectReason } from "../src/l2-guard/matching";
19
- import { deniedUrlReason, hostnameOf, isAllowedUrl } from "../src/l2-guard/url";
20
- import { audit, AUDIT_PATH } from "../src/shared/audit";
21
- import { askOrBlock, clearSessionGrants, sessionGrantSummary, type UICtx } from "../src/l2-guard/prompts";
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";
26
- import { subagentNetworkBlock } from "../src/l2-guard/subagent";
27
- import { onYolo } from "../src/shared/yolo";
28
-
29
- /**
30
- * Advanced Secure (ADR-018): detection-based asks and output redaction.
31
- * Detection may only ADD asks or redactions; it never loosens a rule. Session
32
- * grants are in memory only, exactly like the path grants.
33
- */
34
- const detectionGrants = new Set<string>();
35
-
36
- function detectionBlock(action: "read" | "tool", why: string): string {
37
- if (action === "read") return `Read blocked by policy: ${why}. Nothing was read — ask the user to adjust the permission mode (/permission-mode) if needed. Do not attempt workarounds or inspect plugin files.`;
38
- return `Tool call blocked by policy: ${why}. Nothing was run — ask the user to adjust the permission mode (/permission-mode) if needed. Do not attempt workarounds or inspect plugin files.`;
39
- }
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
-
46
- async function askDetection(
47
- ctx: UICtx,
48
- kind: "read" | "tool",
49
- subject: string,
50
- reason: string,
51
- ): Promise<{ block: true; reason: string } | null> {
52
- const key = `${kind}:${subject}`;
53
- if (detectionGrants.has(key)) return null;
54
- if (ctx.hasUI === false) {
55
- audit({ layer: 2, tool: kind, subject, reason, decision: "no", note: "advanced-secure-headless", cwd: ctx.cwd });
56
- return { block: true, reason: detectionBlock(kind, reason) };
57
- }
58
- const header = kind === "read" ? "Read blocked — sensitive file" : "Tool call blocked — risky";
59
- const body = ` ${kind === "read" ? "file" : "tool"}: ${subject}\n why: ${reason}`;
60
- const choice = await askMain(ctx, header, body, { session: true, remember: false });
61
- if (choice === "once") {
62
- audit({ layer: 2, tool: kind, subject, reason, decision: "yes", note: "advanced-secure", cwd: ctx.cwd });
63
- return null;
64
- }
65
- if (choice === "session") {
66
- detectionGrants.add(key);
67
- audit({ layer: 2, tool: kind, subject, reason, decision: "session", note: "advanced-secure", cwd: ctx.cwd });
68
- ctx.ui.notify(`Allowed for this conversation: ${subject}`, "info");
69
- return null;
70
- }
71
- audit({ layer: 2, tool: kind, subject, reason, decision: "no", note: "advanced-secure", cwd: ctx.cwd });
72
- return { block: true, reason: detectionBlock(kind, reason) };
73
- }
74
-
75
- async function detectMcpAsk(
76
- pi: ExtensionAPI,
77
- toolName: string,
78
- input: Record<string, unknown>,
79
- ctx: UICtx,
80
- mcpPolicy?: McpPolicy,
81
- ): Promise<{ block: true; reason: string } | null> {
82
- const info = pi.getAllTools().find((t) => t.name === toolName);
83
- const result = evaluateMcpCall({
84
- tool: {
85
- name: toolName,
86
- description: info?.description,
87
- inputSchema: /* SAFETY: ToolInfo.parameters is a JSON Schema; JsonSchemaLike is its structural subset. */ info?.parameters as JsonSchemaLike | undefined,
88
- annotations: /* SAFETY: pi's ToolAnnotations mirrors the MCP hints McpToolAnnotations names. */ info?.annotations as McpToolAnnotations | undefined,
89
- },
90
- call: { name: toolName, arguments: input },
91
- policy: mcpPolicy,
92
- });
93
- if (result.decision !== "ask") return null;
94
-
95
- // Include detailed call parameters or script preview if present
96
- let detailedSummary = result.summary;
97
- if (input && Object.keys(input).length > 0) {
98
- const rawScript = input.code ?? input.script ?? input.command ?? input.query;
99
- if (typeof rawScript === "string" && rawScript.includes("\n")) {
100
- detailedSummary += `\n\n${rawScript}`;
101
- } else {
102
- const formattedArgs = JSON.stringify(input, null, 2);
103
- if (formattedArgs.includes("\n")) {
104
- detailedSummary += `\n\nArguments:\n${formattedArgs}`;
105
- }
106
- }
107
- }
108
-
109
- return askDetection(ctx, "tool", toolName, detailedSummary);
110
- }
3
+ import { policyFileError, isProjectFileDeclined } from "../src/core/index.ts";
4
+ import { classifyFilename } from "../src/detect/index.ts";
5
+ import {
6
+ loadPolicy,
7
+ projectPolicyPath,
8
+ setPiDeclinedTrust,
9
+ untrustedProjectChanges,
10
+ TRUST_STORE,
11
+ } from "../src/l2-guard/policy.ts";
12
+ import { canonicalize } from "../src/l2-guard/matching.ts";
13
+ import { audit } from "../src/shared/audit.ts";
14
+ import { clearSessionGrants, type UICtx } from "../src/l2-guard/prompts.ts";
15
+ import { clearProgramOutputSessionGrants } from "../src/l2-guard/program-grants.ts";
16
+ import { programIdForToolCall } from "../src/l2-guard/program-id.ts";
17
+ import { emitDomainGrant } from "../src/shared/network-grants.ts";
18
+ import { subagentNetworkBlock } from "../src/l2-guard/subagent.ts";
19
+ import { onYolo } from "../src/shared/yolo.ts";
20
+ import {
21
+ askDetectionConfirmation,
22
+ clearDetectionGrants,
23
+ detectMcpAsk,
24
+ } from "../src/l2-guard/detection.ts";
25
+ import {
26
+ checkNetworkAccess,
27
+ checkReadAccess,
28
+ checkWriteAccess,
29
+ extractUrlsFromInput,
30
+ } from "../src/l2-guard/tool-gates.ts";
31
+ import {
32
+ clearProgramIds,
33
+ filterGrepToolResult,
34
+ gateSensitiveToolOutput,
35
+ recordProgramIdForCall,
36
+ } from "../src/l2-guard/output-gates.ts";
37
+ import { formatSecurityGuardReport, handleProjectTrustCommand } from "../src/l2-guard/report.ts";
111
38
 
112
39
  export default function (pi: ExtensionAPI) {
113
- let active = false;
114
- let policyEnabled = false;
115
- let runtimeYolo = false;
40
+ let isGuardActive = false;
41
+ let isPolicyEnabled = false;
42
+ let isRuntimeYolo = false;
116
43
 
117
- const applyActive = () => {
118
- active = policyEnabled && !runtimeYolo;
44
+ const updateActiveState = () => {
45
+ isGuardActive = isPolicyEnabled && !isRuntimeYolo;
119
46
  };
120
47
 
121
48
  onYolo(pi.events, (enabled) => {
122
- runtimeYolo = enabled;
123
- applyActive();
124
- if (enabled) detectionGrants.clear();
49
+ isRuntimeYolo = enabled;
50
+ updateActiveState();
51
+
52
+ if (enabled) {
53
+ clearDetectionGrants();
54
+ }
125
55
  });
126
56
 
127
- pi.on("session_start", (_event, ctx) => {
128
- for (const p of [`${getAgentDir()}/extensions/sandbox.json`, `${ctx.cwd}/.pi/sandbox.json`]) {
129
- const err = policyFileError(p);
130
- if (!err) continue;
131
- audit({ layer: 2, event: "policy-parse-error", file: p, error: err, cwd: ctx.cwd });
132
- console.error(`security-guard: ${p} does not parse (${err}); its rules are NOT applied`);
133
- const isProject = p === `${ctx.cwd}/.pi/sandbox.json`;
134
- const msg = isProject
57
+ pi.on("session_start", (_event, context) => {
58
+ for (const policyPath of [`${getAgentDir()}/extensions/sandbox.json`, `${context.cwd}/.pi/sandbox.json`]) {
59
+ const parseError = policyFileError(policyPath);
60
+
61
+ if (!parseError) {
62
+ continue;
63
+ }
64
+
65
+ audit({
66
+ layer: 2,
67
+ event: "policy-parse-error",
68
+ file: policyPath,
69
+ error: parseError,
70
+ cwd: context.cwd,
71
+ });
72
+
73
+ console.error(`security-guard: ${policyPath} does not parse (${parseError}); its rules are NOT applied`);
74
+
75
+ const isProject = policyPath === `${context.cwd}/.pi/sandbox.json`;
76
+ const errorMessage = isProject
135
77
  ? "⚠️ Syntax error in settings file — project configuration not loaded."
136
78
  : "⚠️ Syntax error in settings file — global configuration not loaded.";
137
- ctx.ui.notify(msg, "error");
79
+
80
+ context.ui.notify(errorMessage, "error");
138
81
  }
139
- setPiDeclinedTrust((ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted?.() === false);
140
- const changes = untrustedProjectChanges(ctx.cwd);
141
- if (changes.length && !isProjectFileDeclined(projectPolicyPath(ctx.cwd), TRUST_STORE)) {
142
- audit({ layer: 2, event: "untrusted-project-policy", file: projectPolicyPath(ctx.cwd), changes, cwd: ctx.cwd });
143
- ctx.ui.notify("🛡️ Project-level configuration detected: run /security trust to approve.", "warning");
82
+
83
+ setPiDeclinedTrust((context as { isProjectTrusted?: () => boolean }).isProjectTrusted?.() === false);
84
+
85
+ const changes = untrustedProjectChanges(context.cwd);
86
+
87
+ if (changes.length && !isProjectFileDeclined(projectPolicyPath(context.cwd), TRUST_STORE)) {
88
+ audit({
89
+ layer: 2,
90
+ event: "untrusted-project-policy",
91
+ file: projectPolicyPath(context.cwd),
92
+ changes,
93
+ cwd: context.cwd,
94
+ });
95
+
96
+ context.ui.notify("🛡️ Project-level configuration detected: run /security trust to approve.", "warning");
144
97
  }
145
- const policy = loadPolicy(ctx.cwd);
98
+
99
+ const policy = loadPolicy(context.cwd);
100
+
146
101
  if (!policy.enabled) {
147
- policyEnabled = false;
148
- applyActive();
149
- ctx.ui.notify("Tool guard is disabled in your sandbox.json settings.", "info");
102
+ isPolicyEnabled = false;
103
+ updateActiveState();
104
+ context.ui.notify("Tool guard is disabled in your sandbox.json settings.", "info");
150
105
  return;
151
106
  }
152
- policyEnabled = true;
153
- applyActive();
107
+
108
+ isPolicyEnabled = true;
109
+ updateActiveState();
110
+
154
111
  clearSessionGrants();
155
- detectionGrants.clear();
112
+ clearDetectionGrants();
156
113
  clearProgramOutputSessionGrants();
157
- programIdByCallId.clear();
114
+ clearProgramIds();
158
115
  });
159
116
 
160
- pi.on("tool_call", async (event, ctx) => {
161
- if (!active) return;
162
- const policy = loadPolicy(ctx.cwd);
117
+ pi.on("tool_call", async (event, context) => {
118
+ if (!isGuardActive) {
119
+ return;
120
+ }
163
121
 
122
+ const policy = loadPolicy(context.cwd);
164
123
  const programId = programIdForToolCall(event.toolName, event.input);
165
- if (event.toolCallId && programId) programIdByCallId.set(event.toolCallId, programId);
166
-
167
- const gateRead = async (rawPath: string, tool: string) => {
168
- const abs = canonicalize(rawPath, ctx.cwd);
169
- let reason = isDeniedRead(rawPath, ctx.cwd, policy);
170
- if (!reason) reason = isAskRead(rawPath, ctx.cwd, policy);
171
- let outside = false;
172
- if (!reason) {
173
- reason = outsideProjectReason(abs, ctx.cwd, policy);
174
- outside = reason !== null;
175
- }
176
- if (!reason) return undefined;
177
- if (outside && policy.filesystem.outsideProject?.read === "deny") {
178
- audit({ layer: 2, tool, subject: abs, reason, decision: "no", note: "outside-project-deny", cwd: ctx.cwd });
179
- return { block: true as const, reason: denyMessage("allowRead", reason) };
180
- }
181
- const e = expandHome(rawPath);
182
- const spelled = isAbsolute(e) ? e : resolve(ctx.cwd, e);
183
- const result = await askOrBlock(/* SAFETY: pi's ctx carries cwd/UI at runtime; local UICtx names the members used. */ ctx as unknown as UICtx, { layer: 2, tool, subject: abs, reason, overrideKind: "allowRead", overrideValue: abs, outside, spelled }, isAbsoluteDeny(abs, ctx.cwd));
184
- return result ?? undefined;
185
- };
124
+
125
+ if (event.toolCallId && programId) {
126
+ recordProgramIdForCall(event.toolCallId, programId);
127
+ }
128
+
186
129
  if (isToolCallEventType("read", event)) {
187
- const result = await gateRead(event.input.path, "read");
188
- if (result) return result;
130
+ const result = await checkReadAccess(event.input.path, "read", context, policy);
131
+
132
+ if (result) {
133
+ return result;
134
+ }
135
+
189
136
  if (policy.mode === "advanced-secure") {
190
- const abs = canonicalize(event.input.path, ctx.cwd);
191
- const verdict = classifyFilename(abs);
192
- if (verdict.risk === "strong") {
193
- const det = await askDetection(/* SAFETY: pi's ctx carries cwd/UI at runtime. */ ctx as unknown as UICtx, "read", abs, verdict.reasons[0] ?? "sensitive filename");
194
- if (det) return det;
137
+ const canonicalPath = canonicalize(event.input.path, context.cwd);
138
+ const classification = classifyFilename(canonicalPath);
139
+
140
+ if (classification.risk === "strong") {
141
+ const verdict = await askDetectionConfirmation(
142
+ /* SAFETY: pi's ctx carries cwd/UI at runtime. */
143
+ context as unknown as UICtx,
144
+ "read",
145
+ canonicalPath,
146
+ classification.reasons[0] ?? "sensitive filename",
147
+ );
148
+
149
+ if (verdict) {
150
+ return verdict;
151
+ }
195
152
  }
196
153
  }
197
154
  }
198
- const searchRoot = isToolCallEventType("grep", event) || isToolCallEventType("find", event) || isToolCallEventType("ls", event)
199
- ? (event.input.path ?? ".")
200
- : null;
155
+
156
+ const searchRoot =
157
+ isToolCallEventType("grep", event) || isToolCallEventType("find", event) || isToolCallEventType("ls", event)
158
+ ? (event.input.path ?? ".")
159
+ : null;
160
+
201
161
  if (searchRoot !== null) {
202
- const result = await gateRead(searchRoot, event.toolName);
203
- if (result) return result;
204
- }
205
- if (isToolCallEventType("write", event)) {
206
- const reason = isDeniedWrite(event.input.path, ctx.cwd, policy);
207
- if (reason) {
208
- const abs = canonicalize(event.input.path, ctx.cwd);
209
- const result = await askOrBlock(/* SAFETY: pi's ctx carries cwd/UI at runtime. */ ctx as unknown as UICtx, { layer: 2, tool: "write", subject: abs, reason, overrideKind: "allowWrite", overrideValue: abs }, isAbsoluteDeny(abs, ctx.cwd));
210
- if (result) return result;
162
+ const result = await checkReadAccess(searchRoot, event.toolName, context, policy);
163
+
164
+ if (result) {
165
+ return result;
211
166
  }
212
167
  }
213
- if (isToolCallEventType("edit", event)) {
214
- const reason = isDeniedWrite(event.input.path, ctx.cwd, policy);
215
- if (reason) {
216
- const abs = canonicalize(event.input.path, ctx.cwd);
217
- const result = await askOrBlock(/* SAFETY: pi's ctx carries cwd/UI at runtime. */ ctx as unknown as UICtx, { layer: 2, tool: "edit", subject: abs, reason, overrideKind: "allowWrite", overrideValue: abs }, isAbsoluteDeny(abs, ctx.cwd));
218
- if (result) return result;
168
+
169
+ if (isToolCallEventType("write", event) || isToolCallEventType("edit", event)) {
170
+ const result = await checkWriteAccess(event.input.path, event.toolName, context, policy);
171
+
172
+ if (result) {
173
+ return result;
219
174
  }
220
175
  }
221
176
 
222
- const input = event.input as Record<string, unknown> | undefined;
223
- const collectUrls = (): string[] => {
224
- if (!input) return [];
225
- const urls: string[] = [];
226
- if (typeof input.url === "string") urls.push(input.url);
227
- if (Array.isArray(input.urls)) {
228
- for (const u of input.urls) if (typeof u === "string") urls.push(u);
229
- }
230
- return urls;
231
- };
177
+ const toolInput = event.input as Record<string, unknown> | undefined;
232
178
 
233
179
  if (event.toolName === "fetch_content" || event.toolName === "get_search_content") {
234
- const saReason = subagentNetworkBlock(ctx as { hasUI?: boolean; sessionManager?: unknown }, policy);
235
- if (saReason) return { block: true, reason: saReason };
236
- for (const u of collectUrls()) {
237
- const denied = deniedUrlReason(u, policy);
238
- if (denied) {
239
- audit({ layer: 2, tool: event.toolName, subject: u, reason: denied, decision: "no", note: "denied-domain", cwd: ctx.cwd });
240
- return { block: true, reason: denyMessage("allowDomains", denied) };
241
- }
242
- const reason = isAllowedUrl(u, policy);
243
- if (!reason) continue;
244
- const host = hostnameOf(u) ?? u;
245
- 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) => {
246
- if (kind === "allowDomains") emitDomainGrant(pi.events, value);
247
- });
248
- if (result) return result;
180
+ const subagentReason = subagentNetworkBlock(
181
+ context as { hasUI?: boolean; sessionManager?: unknown },
182
+ policy,
183
+ );
184
+
185
+ if (subagentReason) {
186
+ return { block: true, reason: subagentReason };
187
+ }
188
+
189
+ const targetUrls = extractUrlsFromInput(toolInput);
190
+ const networkResult = await checkNetworkAccess(
191
+ targetUrls,
192
+ event.toolName,
193
+ context,
194
+ policy,
195
+ (domain) => emitDomainGrant(pi.events, domain),
196
+ );
197
+
198
+ if (networkResult) {
199
+ return networkResult;
249
200
  }
250
201
  }
251
202
 
252
203
  if (event.toolName === "web_search") {
253
- const saReason = subagentNetworkBlock(ctx as { hasUI?: boolean; sessionManager?: unknown }, policy);
254
- if (saReason) return { block: true, reason: saReason };
204
+ const subagentReason = subagentNetworkBlock(
205
+ context as { hasUI?: boolean; sessionManager?: unknown },
206
+ policy,
207
+ );
208
+
209
+ if (subagentReason) {
210
+ return { block: true, reason: subagentReason };
211
+ }
255
212
  }
256
213
 
257
214
  if (policy.mode === "advanced-secure" && event.toolName.startsWith("mcp__")) {
258
- 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);
259
- if (det) return det;
215
+ const verdict = await detectMcpAsk(
216
+ pi,
217
+ event.toolName,
218
+ (event.input ?? {}) as Record<string, unknown>,
219
+ /* SAFETY: pi's ctx carries cwd/UI at runtime. */
220
+ context as unknown as UICtx,
221
+ policy.mcp,
222
+ );
223
+
224
+ if (verdict) {
225
+ return verdict;
226
+ }
260
227
  }
261
228
  });
262
229
 
263
- pi.on("tool_result", (event, ctx) => {
264
- if (!active || event.toolName !== "grep" || event.isError) return;
265
- const input = event.input as { path?: string } | undefined;
266
- const policy = loadPolicy(ctx.cwd);
267
- const root = canonicalize(input?.path ?? ".", ctx.cwd);
268
- let rootIsDir = false;
269
- try {
270
- rootIsDir = statSync(root).isDirectory();
271
- } catch {
230
+ pi.on("tool_result", (event, context) => {
231
+ if (!isGuardActive) {
272
232
  return;
273
233
  }
274
- if (!rootIsDir) return;
275
- let removedLines = 0;
276
- const removedFiles = new Set<string>();
277
- const content = event.content.map((c) => {
278
- if (c.type !== "text") return c;
279
- const r = filterGrepOutput(c.text, (printed) => isDeniedRead(join(root, printed), ctx.cwd, policy) !== null);
280
- removedLines += r.removedLines;
281
- for (const f of r.removedFiles) removedFiles.add(f);
282
- return { ...c, text: r.text };
283
- });
284
- if (removedLines === 0) return;
285
- audit({ layer: 2, tool: "grep", subject: root, decision: "redacted", removedLines, removedFiles: [...removedFiles], cwd: ctx.cwd });
286
- content.push({ type: "text", text: `\n[security-guard: removed ${removedLines} line(s) from ${removedFiles.size} file(s) that match the read-deny policy]` });
287
- return { content };
234
+
235
+ const policy = loadPolicy(context.cwd);
236
+ return filterGrepToolResult(event, context, policy);
288
237
  });
289
238
 
290
- pi.on("tool_result", async (event, ctx) => {
291
- if (!active || event.isError) return;
292
- const policy = loadPolicy(ctx.cwd);
293
- if (policy.mode !== "advanced-secure") return;
294
- const programId = event.toolCallId ? programIdByCallId.get(event.toolCallId) : undefined;
295
- if (programId && isProgramOutputGranted(programId)) {
296
- audit({ layer: 2, tool: event.toolName, decision: "session-grant", grant: programId, note: "advanced-secure-output", cwd: ctx.cwd });
239
+ pi.on("tool_result", async (event, context) => {
240
+ if (!isGuardActive) {
297
241
  return;
298
242
  }
299
- const rawInput = event.input ?? {};
300
- const rawPath = rawInput.path ?? rawInput.file_path;
301
- const subject = (() => {
302
- if (typeof rawPath === "string" && rawPath) return rawPath;
303
- if (event.toolName === "bash") {
304
- const cmd = (event.input as { command?: string } | undefined)?.command;
305
- return cmd ? `bash (${cmd})` : "bash";
306
- }
307
- return event.toolName;
308
- })();
309
- const { types, hits } = collectExposure(event.content, subject);
310
- if (!types.length) return;
311
- const findings = types;
312
- const ui = /* SAFETY: pi's ctx carries cwd/UI at runtime; the local type only names the members used. */ ctx as unknown as UICtx;
313
- const choice = ui.hasUI !== false ? await askExposure(ui, hits, programId) : "block";
314
- if (choice === "allow" || choice === "program-session") {
315
- if (choice === "program-session" && programId) {
316
- grantProgramOutputSession(programId);
317
- audit({ layer: 2, tool: event.toolName, decision: "session", grant: programId, note: "advanced-secure-output", findings, cwd: ctx.cwd });
318
- ctx.ui.notify(`Output permitted: allowed all ${programId} output for this conversation.`, "info");
319
- } else {
320
- audit({ layer: 2, tool: event.toolName, decision: "yes", note: "advanced-secure-output", findings, cwd: ctx.cwd });
321
- ctx.ui.notify(`Output permitted: allowed ${event.toolName} output for this call.`, "info");
322
- }
323
- return;
324
- }
325
- audit({ layer: 2, tool: event.toolName, decision: "no", note: "advanced-secure-output", findings, cwd: ctx.cwd });
326
- ctx.ui.notify(`Sensitive data protected: ${event.toolName} output was hidden from the AI (potential secrets detected).`, "warning");
327
- return { content: [{ type: "text", text: withheldNotice(subject) }] };
243
+
244
+ const policy = loadPolicy(context.cwd);
245
+ return gateSensitiveToolOutput(event, context, policy);
328
246
  });
329
247
 
330
248
  pi.registerCommand?.("security", {
331
- description: "Show Layer 2 policy and status. /security trust | untrust: trust this project's .pi/sandbox.json as it is now, or stop trusting it",
332
- handler: async (args, ctx) => {
333
- const sub = String(args ?? "").trim();
334
- const file = projectPolicyPath(ctx.cwd);
335
- if (sub === "trust" || sub === "untrust") {
336
- if (!existsSync(file)) {
337
- ctx.ui.notify("This folder has no .pi/sandbox.json.", "info");
338
- return;
339
- }
340
- if (sub === "untrust") {
341
- forgetProjectTrust(file, TRUST_STORE);
342
- audit({ layer: 2, event: "project-untrusted", file, cwd: ctx.cwd });
343
- ctx.ui.notify("OK. The file is not trusted now. Only its block rules apply.", "info");
344
- return;
345
- }
346
- if (projectTrusted(ctx.cwd)) {
347
- ctx.ui.notify("This file is already trusted.", "info");
348
- return;
349
- }
350
- const changes = untrustedProjectChanges(ctx.cwd);
351
- if (!changes.length) {
352
- ctx.ui.notify("This file only adds block rules. They apply already. You do not have to trust it.", "info");
353
- return;
354
- }
355
- const options = ["No — ignore these changes (default)", "Yes — I wrote this file. Apply it."];
356
- const title = `Do you trust this file?\n${file}\n\nIt will:\n${bullets(changes)}\n\nIf the file changes, auto-permission-system asks again.`;
357
- const chosen = await ctx.ui.select(title, options, { timeout: 120_000 });
358
- if (chosen !== options[1]) {
359
- recordProjectDeclined(file, TRUST_STORE);
360
- audit({ layer: 2, event: "project-trust-declined", file, changes, cwd: ctx.cwd });
361
- ctx.ui.notify("OK. auto-permission-system ignores these changes. It does not ask again until the file changes.", "info");
362
- return;
363
- }
364
- recordProjectTrust(file, TRUST_STORE);
365
- audit({ layer: 2, event: "project-trusted", file, applied: changes, cwd: ctx.cwd });
366
- ctx.ui.notify("Done. The file applies now. Start a new conversation so the bash sandbox uses it too.", "info");
249
+ description:
250
+ "Show Layer 2 policy and status. /security trust | untrust: trust this project's .pi/sandbox.json as it is now, or stop trusting it",
251
+ handler: async (args, context) => {
252
+ const subcommand = String(args ?? "").trim();
253
+
254
+ if (subcommand === "trust" || subcommand === "untrust") {
255
+ await handleProjectTrustCommand(context, subcommand);
367
256
  return;
368
257
  }
369
- if (!active) {
370
- ctx.ui.notify(
371
- runtimeYolo ? "security-guard: inactive — YOLO is on (/permission-mode to turn it off)" : "security-guard: inactive (yolo or disabled)",
372
- "info",
373
- );
258
+
259
+ if (!isGuardActive) {
260
+ const inactiveMessage = isRuntimeYolo
261
+ ? "security-guard: inactive — YOLO is on (/permission-mode to turn it off)"
262
+ : "security-guard: inactive (yolo or disabled)";
263
+
264
+ context.ui.notify(inactiveMessage, "info");
374
265
  return;
375
266
  }
376
- const policy = loadPolicy(ctx.cwd);
377
- const overrides = policy.overrides ?? {};
378
- const lines = [
379
- "Security Guard (Layer 2):",
380
- "",
381
- ` cwd: ${ctx.cwd}`,
382
- ` hasUI: ${(ctx as { hasUI?: boolean }).hasUI !== false}`,
383
- ` subagent.network: ${policy.subagent?.network ?? "allow"}`,
384
- ` conversation grants: ${sessionGrantSummary() || "(none)"}`,
385
- ` project file: ${!existsSync(projectPolicyPath(ctx.cwd)) ? "none" : projectTrusted(ctx.cwd) ? "trusted" : untrustedProjectChanges(ctx.cwd).length ? `not trusted, its weaker settings are ignored (/security trust)` : "only block rules, they apply"}`,
386
- ` outside project: read ${policy.filesystem.outsideProject?.read ?? "allow"}${(policy.filesystem.outsideProject?.allowRead ?? []).length ? `, allowRead ${policy.filesystem.outsideProject?.allowRead?.join(", ")}` : ""}`,
387
- "",
388
- "Filesystem:",
389
- ` denyRead: ${policy.filesystem.denyRead.join(", ") || "(none)"}`,
390
- ` allowWrite: ${policy.filesystem.allowWrite.join(", ") || "(none)"}`,
391
- ` denyWrite: ${policy.filesystem.denyWrite.join(", ") || "(none)"}`,
392
- "",
393
- "Network (URL tools):",
394
- ` allowed: ${policy.network.allowedDomains.join(", ") || "(none)"}`,
395
- ` denied: ${policy.network.deniedDomains.join(", ") || "(none)"}`,
396
- "",
397
- "Project-local overrides (<cwd>/.pi/sandbox.json `overrides`):",
398
- ` allowRead: ${(overrides.allowRead ?? []).join(", ") || "(none)"}`,
399
- ` allowWrite: ${(overrides.allowWrite ?? []).join(", ") || "(none)"}`,
400
- ` allowDomains: ${(overrides.allowDomains ?? []).join(", ") || "(none)"}`,
401
- ];
402
- if (policy.mode === "advanced-secure") {
403
- const mcp = policy.mcp ?? {};
404
- lines.push(
405
- "",
406
- "Advanced Secure (MCP gate, ADR-032):",
407
- ` allowTools: ${(mcp.allowTools ?? []).join(", ") || "(none)"}`,
408
- ` allowPrefixes: ${(mcp.allowPrefixes ?? []).join(", ") || "(none)"}`,
409
- ` askTools: ${(mcp.askTools ?? []).join(", ") || "(none)"}`,
410
- ` trustAnnotations: ${mcp.trustAnnotations === true}`,
411
- ` allowSimpleUpdates: ${mcp.allowSimpleUpdates !== false}`,
412
- ` askThreshold: ${mcp.askThreshold ?? 30}`,
413
- );
414
- }
415
- try {
416
- const tail = readFileSync(AUDIT_PATH, "utf-8").trim().split("\n").slice(-10);
417
- if (tail.length && tail[0]) {
418
- lines.push("", "Recent audit (last 10):");
419
- for (const l of tail) lines.push(` ${l}`);
420
- }
421
- } catch {
422
- }
423
- ctx.ui.notify(lines.join("\n"), "info");
267
+
268
+ const policy = loadPolicy(context.cwd);
269
+ context.ui.notify(formatSecurityGuardReport(policy, context), "info");
424
270
  },
425
271
  });
426
272
  }