pi-mono-all 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENCE.md +7 -0
  3. package/node_modules/pi-common/package.json +22 -0
  4. package/node_modules/pi-common/src/auth-config.ts +290 -0
  5. package/node_modules/pi-common/src/auth.ts +63 -0
  6. package/node_modules/pi-common/src/cache.ts +60 -0
  7. package/node_modules/pi-common/src/errors.ts +47 -0
  8. package/node_modules/pi-common/src/http-client.ts +118 -0
  9. package/node_modules/pi-common/src/index.ts +7 -0
  10. package/node_modules/pi-common/src/rate-limiter.ts +32 -0
  11. package/node_modules/pi-common/src/tool-result.ts +27 -0
  12. package/node_modules/pi-mono-ask-user-question/CHANGELOG.md +185 -0
  13. package/node_modules/pi-mono-ask-user-question/README.md +226 -0
  14. package/node_modules/pi-mono-ask-user-question/index.ts +923 -0
  15. package/node_modules/pi-mono-ask-user-question/package.json +29 -0
  16. package/node_modules/pi-mono-auto-fix/CHANGELOG.md +59 -0
  17. package/node_modules/pi-mono-auto-fix/README.md +77 -0
  18. package/node_modules/pi-mono-auto-fix/index.ts +488 -0
  19. package/node_modules/pi-mono-auto-fix/package.json +23 -0
  20. package/node_modules/pi-mono-btw/CHANGELOG.md +180 -0
  21. package/node_modules/pi-mono-btw/README.md +24 -0
  22. package/node_modules/pi-mono-btw/index.ts +499 -0
  23. package/node_modules/pi-mono-btw/package.json +29 -0
  24. package/node_modules/pi-mono-clear/CHANGELOG.md +180 -0
  25. package/node_modules/pi-mono-clear/README.md +40 -0
  26. package/node_modules/pi-mono-clear/index.ts +45 -0
  27. package/node_modules/pi-mono-clear/package.json +29 -0
  28. package/node_modules/pi-mono-context/CHANGELOG.md +12 -0
  29. package/node_modules/pi-mono-context/README.md +74 -0
  30. package/node_modules/pi-mono-context/index.ts +641 -0
  31. package/node_modules/pi-mono-context/package.json +29 -0
  32. package/node_modules/pi-mono-context-guard/CHANGELOG.md +195 -0
  33. package/node_modules/pi-mono-context-guard/README.md +81 -0
  34. package/node_modules/pi-mono-context-guard/index.ts +212 -0
  35. package/node_modules/pi-mono-context-guard/package.json +23 -0
  36. package/node_modules/pi-mono-figma/CHANGELOG.md +59 -0
  37. package/node_modules/pi-mono-figma/README.md +236 -0
  38. package/node_modules/pi-mono-figma/__tests__/code-connect.test.ts +32 -0
  39. package/node_modules/pi-mono-figma/__tests__/figma-assets.test.ts +38 -0
  40. package/node_modules/pi-mono-figma/__tests__/figma-component-hints.test.ts +23 -0
  41. package/node_modules/pi-mono-figma/__tests__/figma-implementation-layout.test.ts +47 -0
  42. package/node_modules/pi-mono-figma/__tests__/figma-search.test.ts +51 -0
  43. package/node_modules/pi-mono-figma/__tests__/figma-summarizer.test.ts +65 -0
  44. package/node_modules/pi-mono-figma/__tests__/fixtures/complex-auto-layout.json +115 -0
  45. package/node_modules/pi-mono-figma/__tests__/fixtures/component-instance.json +50 -0
  46. package/node_modules/pi-mono-figma/__tests__/fixtures/hidden-and-vectors.json +28 -0
  47. package/node_modules/pi-mono-figma/__tests__/fixtures/variables-and-styles.json +40 -0
  48. package/node_modules/pi-mono-figma/docs/live-selection-bridge.md +16 -0
  49. package/node_modules/pi-mono-figma/index.ts +6 -0
  50. package/node_modules/pi-mono-figma/package.json +33 -0
  51. package/node_modules/pi-mono-figma/skills/figma/SKILL.md +143 -0
  52. package/node_modules/pi-mono-figma/src/code-connect.ts +110 -0
  53. package/node_modules/pi-mono-figma/src/figma-assets.ts +146 -0
  54. package/node_modules/pi-mono-figma/src/figma-cache.ts +6 -0
  55. package/node_modules/pi-mono-figma/src/figma-client.ts +471 -0
  56. package/node_modules/pi-mono-figma/src/figma-component-hints.ts +87 -0
  57. package/node_modules/pi-mono-figma/src/figma-implementation.ts +264 -0
  58. package/node_modules/pi-mono-figma/src/figma-schemas.ts +139 -0
  59. package/node_modules/pi-mono-figma/src/figma-search.ts +195 -0
  60. package/node_modules/pi-mono-figma/src/figma-summarizer.ts +673 -0
  61. package/node_modules/pi-mono-figma/src/figma-tokens.ts +57 -0
  62. package/node_modules/pi-mono-figma/src/figma-tools.ts +352 -0
  63. package/node_modules/pi-mono-linear/CHANGELOG.md +44 -0
  64. package/node_modules/pi-mono-linear/README.md +159 -0
  65. package/node_modules/pi-mono-linear/index.ts +6 -0
  66. package/node_modules/pi-mono-linear/package.json +30 -0
  67. package/node_modules/pi-mono-linear/skills/linear/SKILL.md +107 -0
  68. package/node_modules/pi-mono-linear/src/linear-client.ts +339 -0
  69. package/node_modules/pi-mono-linear/src/linear-queries.ts +101 -0
  70. package/node_modules/pi-mono-linear/src/linear-schemas.ts +90 -0
  71. package/node_modules/pi-mono-linear/src/linear-tools.ts +362 -0
  72. package/node_modules/pi-mono-loop/CHANGELOG.md +163 -0
  73. package/node_modules/pi-mono-loop/README.md +54 -0
  74. package/node_modules/pi-mono-loop/index.ts +291 -0
  75. package/node_modules/pi-mono-loop/package.json +26 -0
  76. package/node_modules/pi-mono-multi-edit/CHANGELOG.md +232 -0
  77. package/node_modules/pi-mono-multi-edit/README.md +244 -0
  78. package/node_modules/pi-mono-multi-edit/__tests__/classic.test.ts +277 -0
  79. package/node_modules/pi-mono-multi-edit/__tests__/diff.test.ts +77 -0
  80. package/node_modules/pi-mono-multi-edit/__tests__/patch.test.ts +287 -0
  81. package/node_modules/pi-mono-multi-edit/benchmark-edits.ts +966 -0
  82. package/node_modules/pi-mono-multi-edit/classic.ts +435 -0
  83. package/node_modules/pi-mono-multi-edit/diff.ts +143 -0
  84. package/node_modules/pi-mono-multi-edit/index.ts +266 -0
  85. package/node_modules/pi-mono-multi-edit/package.json +37 -0
  86. package/node_modules/pi-mono-multi-edit/patch.ts +463 -0
  87. package/node_modules/pi-mono-multi-edit/types.ts +53 -0
  88. package/node_modules/pi-mono-multi-edit/workspace.ts +85 -0
  89. package/node_modules/pi-mono-review/CHANGELOG.md +190 -0
  90. package/node_modules/pi-mono-review/README.md +30 -0
  91. package/node_modules/pi-mono-review/common.ts +930 -0
  92. package/node_modules/pi-mono-review/index.ts +8 -0
  93. package/node_modules/pi-mono-review/package.json +29 -0
  94. package/node_modules/pi-mono-review/review-tui.ts +194 -0
  95. package/node_modules/pi-mono-review/review.ts +119 -0
  96. package/node_modules/pi-mono-review/reviewer.ts +339 -0
  97. package/node_modules/pi-mono-sentinel/CHANGELOG.md +158 -0
  98. package/node_modules/pi-mono-sentinel/README.md +87 -0
  99. package/node_modules/pi-mono-sentinel/__tests__/output-scanner.test.ts +109 -0
  100. package/node_modules/pi-mono-sentinel/__tests__/permissions.test.ts +202 -0
  101. package/node_modules/pi-mono-sentinel/__tests__/whitelist.test.ts +59 -0
  102. package/node_modules/pi-mono-sentinel/guards/execution-tracker.ts +281 -0
  103. package/node_modules/pi-mono-sentinel/guards/output-scanner.ts +232 -0
  104. package/node_modules/pi-mono-sentinel/guards/permission-gate.ts +170 -0
  105. package/node_modules/pi-mono-sentinel/index.ts +43 -0
  106. package/node_modules/pi-mono-sentinel/package.json +26 -0
  107. package/node_modules/pi-mono-sentinel/patterns/permissions.ts +175 -0
  108. package/node_modules/pi-mono-sentinel/patterns/read-targets.ts +104 -0
  109. package/node_modules/pi-mono-sentinel/patterns/secrets.ts +143 -0
  110. package/node_modules/pi-mono-sentinel/session.ts +95 -0
  111. package/node_modules/pi-mono-sentinel/specs/2026/04/sentinel/001-permission-gate.md +145 -0
  112. package/node_modules/pi-mono-sentinel/types.ts +39 -0
  113. package/node_modules/pi-mono-sentinel/whitelist.ts +86 -0
  114. package/node_modules/pi-mono-simplify/CHANGELOG.md +163 -0
  115. package/node_modules/pi-mono-simplify/README.md +56 -0
  116. package/node_modules/pi-mono-simplify/index.ts +78 -0
  117. package/node_modules/pi-mono-simplify/package.json +29 -0
  118. package/node_modules/pi-mono-status-line/CHANGELOG.md +180 -0
  119. package/node_modules/pi-mono-status-line/README.md +96 -0
  120. package/node_modules/pi-mono-status-line/basic.ts +89 -0
  121. package/node_modules/pi-mono-status-line/expert.ts +689 -0
  122. package/node_modules/pi-mono-status-line/index.ts +54 -0
  123. package/node_modules/pi-mono-status-line/package.json +29 -0
  124. package/node_modules/pi-mono-team-mode/CHANGELOG.md +278 -0
  125. package/node_modules/pi-mono-team-mode/README.md +246 -0
  126. package/node_modules/pi-mono-team-mode/__tests__/agent-manager-transient.test.ts +75 -0
  127. package/node_modules/pi-mono-team-mode/__tests__/delegation-manager.test.ts +118 -0
  128. package/node_modules/pi-mono-team-mode/__tests__/formatters.test.ts +104 -0
  129. package/node_modules/pi-mono-team-mode/__tests__/model-config.test.ts +272 -0
  130. package/node_modules/pi-mono-team-mode/__tests__/notification-box.test.ts +34 -0
  131. package/node_modules/pi-mono-team-mode/__tests__/parallel-utils.test.ts +32 -0
  132. package/node_modules/pi-mono-team-mode/__tests__/pi-stream-parser.test.ts +64 -0
  133. package/node_modules/pi-mono-team-mode/__tests__/prompts.test.ts +106 -0
  134. package/node_modules/pi-mono-team-mode/__tests__/store.test.ts +164 -0
  135. package/node_modules/pi-mono-team-mode/__tests__/tasks.test.ts +267 -0
  136. package/node_modules/pi-mono-team-mode/__tests__/teammate-specs.test.ts +114 -0
  137. package/node_modules/pi-mono-team-mode/__tests__/widget.test.ts +41 -0
  138. package/node_modules/pi-mono-team-mode/__tests__/worktree.test.ts +78 -0
  139. package/node_modules/pi-mono-team-mode/core/chain-utils.ts +90 -0
  140. package/node_modules/pi-mono-team-mode/core/fs-utils.ts +44 -0
  141. package/node_modules/pi-mono-team-mode/core/model-config.ts +432 -0
  142. package/node_modules/pi-mono-team-mode/core/parallel-utils.ts +48 -0
  143. package/node_modules/pi-mono-team-mode/core/prompts.ts +158 -0
  144. package/node_modules/pi-mono-team-mode/core/store.ts +156 -0
  145. package/node_modules/pi-mono-team-mode/core/tasks.ts +99 -0
  146. package/node_modules/pi-mono-team-mode/core/teammate-specs.ts +124 -0
  147. package/node_modules/pi-mono-team-mode/core/types.ts +160 -0
  148. package/node_modules/pi-mono-team-mode/index.ts +825 -0
  149. package/node_modules/pi-mono-team-mode/managers/agent-manager.ts +654 -0
  150. package/node_modules/pi-mono-team-mode/managers/delegation-manager.ts +211 -0
  151. package/node_modules/pi-mono-team-mode/managers/task-manager.ts +238 -0
  152. package/node_modules/pi-mono-team-mode/managers/team-manager.ts +59 -0
  153. package/node_modules/pi-mono-team-mode/package.json +33 -0
  154. package/node_modules/pi-mono-team-mode/runtime/pi-stream-parser.ts +194 -0
  155. package/node_modules/pi-mono-team-mode/runtime/subprocess.ts +183 -0
  156. package/node_modules/pi-mono-team-mode/runtime/transient-session.ts +196 -0
  157. package/node_modules/pi-mono-team-mode/runtime/worktree.ts +90 -0
  158. package/node_modules/pi-mono-team-mode/ui/formatters.ts +149 -0
  159. package/node_modules/pi-mono-team-mode/ui/notification-box.ts +55 -0
  160. package/node_modules/pi-mono-team-mode/ui/widget.ts +94 -0
  161. package/package.json +76 -0
@@ -0,0 +1,281 @@
1
+ /**
2
+ * execution-tracker — Gap 3: Indirect execution attack mitigation.
3
+ *
4
+ * Two hooks working together:
5
+ *
6
+ * 1. **Write-time tracking** (`tool_call` on `write` / `edit`):
7
+ * Records every file written during the session and scans the content
8
+ * for dangerous execution patterns (curl|bash, eval, exfiltration, etc.).
9
+ * Does NOT block — only records metadata in the session write registry.
10
+ *
11
+ * 2. **Execution-time correlation** (`tool_call` on `bash`):
12
+ * Extracts script paths from bash commands and checks them against the
13
+ * session write registry. If a script was written this session and
14
+ * contains dangerous patterns, asks/denies before allowing execution.
15
+ */
16
+
17
+ import { readFile, stat } from "node:fs/promises";
18
+ import { resolve } from "node:path";
19
+
20
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
21
+ import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
22
+
23
+ import type { SentinelSession } from "../session.js";
24
+ import type { DangerousPattern, WriteEntry } from "../types.js";
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Dangerous content patterns (for scripts)
28
+ // ---------------------------------------------------------------------------
29
+
30
+ const DANGEROUS_PATTERNS: readonly DangerousPattern[] = [
31
+ {
32
+ label: "curl-pipe-exec",
33
+ pattern: /curl\s.*\|\s*(?:bash|sh|zsh)/,
34
+ },
35
+ {
36
+ label: "wget-pipe-exec",
37
+ pattern: /wget\s.*\|\s*(?:bash|sh|zsh)/,
38
+ },
39
+ {
40
+ label: "eval-subshell",
41
+ pattern: /eval\s+["'$]/,
42
+ },
43
+ {
44
+ label: "network-exfil",
45
+ pattern: /curl\s.*(?:-X\s*POST|--data\b|-d\s)/,
46
+ },
47
+ {
48
+ label: "rm-recursive",
49
+ pattern: /rm\s+-[a-zA-Z]*r[a-zA-Z]*f/,
50
+ },
51
+ {
52
+ label: "privilege-escalation",
53
+ pattern: /(?:chmod\s+777|sudo\s)/,
54
+ },
55
+ {
56
+ label: "persistence",
57
+ pattern: /(?:crontab|systemctl\s+enable|launchctl)/,
58
+ },
59
+ ];
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Script execution path extraction
63
+ // ---------------------------------------------------------------------------
64
+
65
+ const EXEC_PATTERNS: readonly RegExp[] = [
66
+ /\b(?:bash|sh|zsh|dash)\s+(\S+)/,
67
+ /\b(?:node|python3?|ruby|perl|tsx?)\s+(\S+)/,
68
+ /\bsource\s+(\S+)/,
69
+ /^\.\s+(\S+)/,
70
+ /^\.\/(\S+)/,
71
+ ];
72
+
73
+ /** Extract potential script file paths from a bash command. */
74
+ function extractScriptPaths(command: string): string[] {
75
+ const paths: string[] = [];
76
+ for (const pattern of EXEC_PATTERNS) {
77
+ const match = pattern.exec(command);
78
+ if (match?.[1]) {
79
+ const target = match[1];
80
+ // Skip flags
81
+ if (!target.startsWith("-")) {
82
+ paths.push(target);
83
+ }
84
+ }
85
+ }
86
+ return paths;
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Content scanning
91
+ // ---------------------------------------------------------------------------
92
+
93
+ /** Scan content for dangerous execution patterns. Returns matched labels. */
94
+ function scanForDangerousContent(content: string): string[] {
95
+ const matched: string[] = [];
96
+ for (const { label, pattern } of DANGEROUS_PATTERNS) {
97
+ if (pattern.test(content)) {
98
+ matched.push(label);
99
+ }
100
+ }
101
+ return matched;
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Guard registration
106
+ // ---------------------------------------------------------------------------
107
+
108
+ export function registerExecutionTracker(
109
+ pi: ExtensionAPI,
110
+ session: SentinelSession,
111
+ ): void {
112
+ // -----------------------------------------------------------------------
113
+ // 4a. Write-time tracking — record writes and flag dangerous content
114
+ // -----------------------------------------------------------------------
115
+
116
+ // Track `write` tool calls
117
+ pi.on("tool_call", async (event, ctx) => {
118
+ if (!isToolCallEventType("write", event)) return;
119
+
120
+ const rawPath = event.input.path;
121
+ const content = event.input.content;
122
+ if (!rawPath || typeof content !== "string") return;
123
+
124
+ const absolutePath = resolve(ctx.cwd, rawPath);
125
+ const dangerousPatterns = scanForDangerousContent(content);
126
+
127
+ session.registerWrite({
128
+ path: absolutePath,
129
+ timestamp: Date.now(),
130
+ hasDangerousContent: dangerousPatterns.length > 0,
131
+ dangerousPatterns,
132
+ });
133
+
134
+ if (dangerousPatterns.length > 0) {
135
+ ctx.ui.notify(
136
+ `[sentinel] Write tracked: ${rawPath} flagged (${dangerousPatterns.join(", ")})`,
137
+ "warning",
138
+ );
139
+ }
140
+ });
141
+
142
+ // Track `edit` tool calls
143
+ pi.on("tool_call", async (event, ctx) => {
144
+ if (!isToolCallEventType("edit", event)) return;
145
+
146
+ const rawPath = event.input.path;
147
+ const edits = event.input.edits as
148
+ | Array<{ oldText: string; newText: string }>
149
+ | undefined;
150
+ if (!rawPath || !edits?.length) return;
151
+
152
+ const absolutePath = resolve(ctx.cwd, rawPath);
153
+ const allNewText = edits.map((e) => e.newText).join("\n");
154
+ const dangerousPatterns = scanForDangerousContent(allNewText);
155
+
156
+ // For edits, merge with existing entry if present
157
+ const existing = session.getWrite(absolutePath);
158
+ const mergedPatterns = existing
159
+ ? [...new Set([...existing.dangerousPatterns, ...dangerousPatterns])]
160
+ : dangerousPatterns;
161
+
162
+ session.registerWrite({
163
+ path: absolutePath,
164
+ timestamp: Date.now(),
165
+ hasDangerousContent: mergedPatterns.length > 0,
166
+ dangerousPatterns: mergedPatterns,
167
+ });
168
+
169
+ if (dangerousPatterns.length > 0) {
170
+ ctx.ui.notify(
171
+ `[sentinel] Edit tracked: ${rawPath} flagged (${dangerousPatterns.join(", ")})`,
172
+ "warning",
173
+ );
174
+ }
175
+ });
176
+
177
+ // -----------------------------------------------------------------------
178
+ // 4b. Execution-time correlation — check bash against write registry
179
+ // -----------------------------------------------------------------------
180
+
181
+ pi.on("tool_call", async (event, ctx) => {
182
+ if (!isToolCallEventType("bash", event)) return;
183
+
184
+ const command = event.input.command ?? "";
185
+ const scriptPaths = extractScriptPaths(command);
186
+ if (scriptPaths.length === 0) return;
187
+
188
+ for (const scriptPath of scriptPaths) {
189
+ const absolutePath = resolve(ctx.cwd, scriptPath);
190
+ const writeEntry = session.getWrite(absolutePath);
191
+
192
+ // Not written this session — skip
193
+ if (!writeEntry) continue;
194
+
195
+ // Written this session but no dangerous content — notify only
196
+ if (!writeEntry.hasDangerousContent) {
197
+ ctx.ui.notify(
198
+ `[sentinel] Executing session-written file: ${scriptPath}`,
199
+ "info",
200
+ );
201
+ continue;
202
+ }
203
+
204
+ // Re-verify: file may have been modified externally since we tracked the write
205
+ const currentPatterns = await rescanFileIfChanged(
206
+ absolutePath,
207
+ writeEntry,
208
+ );
209
+
210
+ // File was modified and no longer dangerous
211
+ if (currentPatterns.length === 0) {
212
+ session.registerWrite({
213
+ ...writeEntry,
214
+ hasDangerousContent: false,
215
+ dangerousPatterns: [],
216
+ });
217
+ ctx.ui.notify(
218
+ `[sentinel] File ${scriptPath} was modified — no longer flagged`,
219
+ "info",
220
+ );
221
+ continue;
222
+ }
223
+
224
+ // Dangerous content confirmed — escalate
225
+ const message = [
226
+ `About to execute a file written earlier in this session:`,
227
+ ` Path: ${scriptPath}`,
228
+ ` Flagged patterns: ${currentPatterns.join(", ")}`,
229
+ "",
230
+ "Allow execution?",
231
+ ].join("\n");
232
+
233
+ if (ctx.hasUI) {
234
+ const allowed = await ctx.ui.confirm(
235
+ "[sentinel] Dangerous script execution",
236
+ message,
237
+ );
238
+ if (allowed) continue;
239
+ }
240
+
241
+ // No UI or user denied — block
242
+ return {
243
+ block: true,
244
+ reason:
245
+ `[sentinel] Blocked: bash executes ${scriptPath}, written this session ` +
246
+ `with dangerous patterns: ${currentPatterns.join(", ")}.`,
247
+ };
248
+ }
249
+ });
250
+ }
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // Helpers
254
+ // ---------------------------------------------------------------------------
255
+
256
+ /**
257
+ * Re-read and re-scan a file to verify dangerous content is still present.
258
+ * Handles the edge case where the file was modified externally between
259
+ * write-time tracking and execution-time correlation.
260
+ */
261
+ async function rescanFileIfChanged(
262
+ absolutePath: string,
263
+ writeEntry: WriteEntry,
264
+ ): Promise<string[]> {
265
+ try {
266
+ const fileStat = await stat(absolutePath);
267
+
268
+ // If the file was modified after we tracked the write, re-scan
269
+ if (fileStat.mtimeMs > writeEntry.timestamp) {
270
+ const content = await readFile(absolutePath, "utf-8");
271
+ return scanForDangerousContent(content);
272
+ }
273
+
274
+ // File unchanged — trust the original scan
275
+ return writeEntry.dangerousPatterns;
276
+ } catch {
277
+ // File gone or unreadable — no longer dangerous
278
+ return [];
279
+ }
280
+ }
281
+
@@ -0,0 +1,232 @@
1
+ /**
2
+ * output-scanner — Gap 2: Content-in-location attack mitigation.
3
+ *
4
+ * Pre-reads files before `read` tool calls execute and scans for secret
5
+ * patterns. If secrets are found, asks the user before allowing the read.
6
+ *
7
+ * Since `tool_result` is read-only in the pi extension API, we intercept
8
+ * at `tool_call` time — read the file ourselves, scan it, and make an
9
+ * ASK/DENY decision before the actual read proceeds.
10
+ *
11
+ * Also intercepts `bash` commands that read files (cat, head, tail, less)
12
+ * and pre-scans the target files.
13
+ */
14
+
15
+ import { readFile, stat } from "node:fs/promises";
16
+ import { resolve } from "node:path";
17
+
18
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
19
+ import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
20
+
21
+ import type { SentinelSession } from "../session.js";
22
+ import type { ScanMatch } from "../types.js";
23
+ import {
24
+ expandPaths,
25
+ extractReadTargets,
26
+ } from "../patterns/read-targets.js";
27
+ import {
28
+ isBinaryContent,
29
+ MAX_SCAN_BYTES,
30
+ scanForSecrets,
31
+ } from "../patterns/secrets.js";
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Helpers
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /** Format scan matches into a human-readable confirmation message. */
38
+ function formatConfirmMessage(matches: ScanMatch[]): string {
39
+ const lines = matches.map(
40
+ (m) => ` - ${m.label} (line ${m.line}): ${m.snippet}`,
41
+ );
42
+ return [
43
+ "File may contain secrets:",
44
+ ...lines,
45
+ "",
46
+ "Allow this read?",
47
+ ].join("\n");
48
+ }
49
+
50
+ /**
51
+ * Pre-read a file and scan for secrets. Uses the session scan cache to
52
+ * avoid redundant filesystem reads on unchanged files.
53
+ *
54
+ * Returns the scan matches, or an empty array if the file is binary,
55
+ * too large, or unreadable.
56
+ */
57
+ async function scanFile(
58
+ absolutePath: string,
59
+ session: SentinelSession,
60
+ ): Promise<ScanMatch[]> {
61
+ try {
62
+ const fileStat = await stat(absolutePath);
63
+
64
+ // Check cache first
65
+ const cached = session.getCachedScan(absolutePath, fileStat.mtimeMs);
66
+ if (cached) return cached.matches;
67
+
68
+ // Skip files larger than scan limit
69
+ if (fileStat.size > MAX_SCAN_BYTES) return [];
70
+
71
+ // Skip non-files (directories, symlinks, etc.)
72
+ if (!fileStat.isFile()) return [];
73
+
74
+ const content = await readFile(absolutePath, "utf-8");
75
+
76
+ // Skip binary files
77
+ if (isBinaryContent(content)) {
78
+ session.cacheScan(absolutePath, fileStat.mtimeMs, {
79
+ hasSecrets: false,
80
+ matches: [],
81
+ });
82
+ return [];
83
+ }
84
+
85
+ const result = scanForSecrets(content);
86
+ session.cacheScan(absolutePath, fileStat.mtimeMs, result);
87
+ return result.matches;
88
+ } catch {
89
+ // File unreadable (permissions, doesn't exist, etc.) — let the tool handle the error
90
+ return [];
91
+ }
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Guard registration
96
+ // ---------------------------------------------------------------------------
97
+
98
+ export function registerOutputScanner(
99
+ pi: ExtensionAPI,
100
+ session: SentinelSession,
101
+ ): void {
102
+ // -----------------------------------------------------------------------
103
+ // Intercept `read` tool calls
104
+ // -----------------------------------------------------------------------
105
+ pi.on("tool_call", async (event, ctx) => {
106
+ if (!isToolCallEventType("read", event)) return;
107
+
108
+ const rawPath = event.input.path;
109
+ if (!rawPath) return;
110
+
111
+ const absolutePath = resolve(
112
+ ctx.cwd,
113
+ rawPath.startsWith("@") ? rawPath.slice(1) : rawPath,
114
+ );
115
+
116
+ // Skip the dialog entirely if this file was previously allowed.
117
+ if (session.isReadWhitelisted(absolutePath)) {
118
+ return;
119
+ }
120
+
121
+ const matches = await scanFile(absolutePath, session);
122
+ if (matches.length === 0) return;
123
+
124
+ // Secrets found — escalate
125
+ if (ctx.hasUI) {
126
+ const choice = await ctx.ui.select(
127
+ `[sentinel] Secret detected\n\n${formatConfirmMessage(matches)}`,
128
+ [
129
+ "Allow once",
130
+ "Always allow this file",
131
+ "Deny",
132
+ ],
133
+ );
134
+
135
+ if (choice === "Allow once") return; // User approved — let the read proceed
136
+
137
+ if (choice === "Always allow this file") {
138
+ session.addToReadWhitelist(absolutePath);
139
+ return;
140
+ }
141
+ }
142
+
143
+ // No UI or user denied — block
144
+ return {
145
+ block: true,
146
+ reason:
147
+ `[sentinel] Blocked: file contains ${matches.length} potential secret(s). ` +
148
+ matches.map((m) => m.label).join(", ") +
149
+ ".",
150
+ };
151
+ });
152
+
153
+ // -----------------------------------------------------------------------
154
+ // Intercept `bash` commands that read files (cat, head, tail, less)
155
+ // -----------------------------------------------------------------------
156
+ pi.on("tool_call", async (event, ctx) => {
157
+ if (!isToolCallEventType("bash", event)) return;
158
+
159
+ const command = event.input.command ?? "";
160
+ const targets = extractReadTargets(command);
161
+ if (targets.length === 0) return;
162
+
163
+ // Scan all targeted files (with glob expansion)
164
+ const allMatches: Array<{
165
+ path: string;
166
+ absolutePath: string;
167
+ matches: ScanMatch[];
168
+ }> = [];
169
+ for (const target of targets) {
170
+ const absolutePaths = await expandPaths(ctx.cwd, target);
171
+ for (const absolutePath of absolutePaths) {
172
+ if (session.isReadWhitelisted(absolutePath)) continue;
173
+
174
+ const matches = await scanFile(absolutePath, session);
175
+ if (matches.length > 0) {
176
+ allMatches.push({ path: target, absolutePath, matches });
177
+ }
178
+ }
179
+ }
180
+
181
+ if (allMatches.length === 0) return;
182
+
183
+ // Build a combined confirmation message
184
+ const message = allMatches
185
+ .flatMap(({ path, matches }) =>
186
+ matches.map(
187
+ (m) => ` - ${m.label} in ${path} (line ${m.line}): ${m.snippet}`,
188
+ ),
189
+ )
190
+ .join("\n");
191
+
192
+ if (ctx.hasUI) {
193
+ const choice = await ctx.ui.select(
194
+ `[sentinel] Secret detected in bash target\n\nCommand reads file(s) that may contain secrets:\n${message}\n\nAllow execution?`,
195
+ [
196
+ "Allow once",
197
+ "Always allow these files",
198
+ "Deny",
199
+ ],
200
+ );
201
+
202
+ if (choice === "Allow once") return;
203
+
204
+ if (choice === "Always allow these files") {
205
+ for (const { absolutePath } of allMatches) {
206
+ session.addToReadWhitelist(absolutePath);
207
+ }
208
+ return;
209
+ }
210
+ }
211
+
212
+ const totalMatches = allMatches.reduce(
213
+ (sum, entry) => sum + entry.matches.length,
214
+ 0,
215
+ );
216
+ return {
217
+ block: true,
218
+ reason:
219
+ `[sentinel] Blocked: bash command reads file(s) with ${totalMatches} potential secret(s).`,
220
+ };
221
+ });
222
+
223
+ // -----------------------------------------------------------------------
224
+ // Invalidate scan cache when context-guard reports a file modification
225
+ // -----------------------------------------------------------------------
226
+ pi.events.on("context-guard:file-modified", (data: unknown) => {
227
+ const event = data as { path?: string } | undefined;
228
+ if (event?.path) {
229
+ session.invalidateScanCache(resolve(event.path));
230
+ }
231
+ });
232
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * permission-gate — proactive bash / write / edit guard.
3
+ *
4
+ * Complements `execution-tracker` (which only fires for *session-written*
5
+ * scripts) by intercepting raw bash commands and out-of-scope writes that
6
+ * perform system-level operations: `curl | bash`, `sudo`, `brew install`,
7
+ * `rm -rf /Library/...`, writes to `~/.zshrc`, `/usr/local/bin`, etc.
8
+ *
9
+ * Decision matrix:
10
+ * - UI available + user allows → proceed
11
+ * - UI available + user denies → block with reason
12
+ * - No UI + dangerous detected → block with reason (fail-safe)
13
+ * - No dangerous patterns → proceed
14
+ */
15
+
16
+ import type {
17
+ ExtensionAPI,
18
+ ExtensionContext,
19
+ } from "@mariozechner/pi-coding-agent";
20
+ import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
21
+
22
+ import type { SentinelSession } from "../session.js";
23
+ import {
24
+ BASH_RISK_DESCRIPTIONS,
25
+ PATH_CATEGORY_DESCRIPTIONS,
26
+ classifyBashCommand,
27
+ classifyPath,
28
+ resolveTargetPath,
29
+ } from "../patterns/permissions.js";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Bash gating
33
+ // ---------------------------------------------------------------------------
34
+
35
+ function registerBashGate(pi: ExtensionAPI): void {
36
+ pi.on("tool_call", async (event, ctx) => {
37
+ if (!isToolCallEventType("bash", event)) return;
38
+
39
+ const command = event.input.command ?? "";
40
+ if (!command) return;
41
+
42
+ const risks = classifyBashCommand(command);
43
+ if (risks.length === 0) return;
44
+
45
+ const labelLines = risks.map(
46
+ (risk) => ` - ${risk}: ${BASH_RISK_DESCRIPTIONS[risk]}`,
47
+ );
48
+ const message = [
49
+ "Bash command matched permission-gate risk classes:",
50
+ ...labelLines,
51
+ "",
52
+ `Command:`,
53
+ ` ${command}`,
54
+ "",
55
+ "Allow execution?",
56
+ ].join("\n");
57
+
58
+ if (ctx.hasUI) {
59
+ const allowed = await ctx.ui.confirm(
60
+ "[sentinel] Permission gate — bash",
61
+ message,
62
+ );
63
+ if (allowed) return;
64
+ }
65
+
66
+ return {
67
+ block: true,
68
+ reason:
69
+ `[sentinel] Blocked bash command (${risks.join(", ")}). ` +
70
+ `Command: ${command}`,
71
+ };
72
+ });
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Write / edit gating
77
+ // ---------------------------------------------------------------------------
78
+
79
+ function registerPathGate(pi: ExtensionAPI, session: SentinelSession): void {
80
+ const handler = async (
81
+ rawPath: string | undefined,
82
+ toolName: "write" | "edit",
83
+ ctx: ExtensionContext,
84
+ ): Promise<{ block: true; reason: string } | undefined> => {
85
+ if (!rawPath) return;
86
+
87
+ const absolute = resolveTargetPath(rawPath, ctx.cwd);
88
+ const category = classifyPath(absolute, ctx.cwd);
89
+ if (!category) return;
90
+
91
+ // Skip the dialog entirely if this path was previously whitelisted
92
+ if (session.isWhitelisted(absolute)) {
93
+ return;
94
+ }
95
+
96
+ const contextLine =
97
+ category === "shell-config"
98
+ ? "This is a persistent user shell configuration change."
99
+ : category === "system-directory"
100
+ ? "This modifies a system directory and may affect other applications."
101
+ : "This path is outside the current project root.";
102
+
103
+ const title = [
104
+ `[sentinel] Permission gate — ${toolName}`,
105
+ `Path: ${absolute}`,
106
+ `Category: ${PATH_CATEGORY_DESCRIPTIONS[category]}`,
107
+ contextLine,
108
+ ].join("\n");
109
+
110
+ if (ctx.hasUI) {
111
+ const choice = await ctx.ui.select(title, [
112
+ "Allow once",
113
+ "Always allow this path",
114
+ "Deny",
115
+ ]);
116
+
117
+ if (choice === "Allow once") {
118
+ return;
119
+ }
120
+
121
+ if (choice === "Always allow this path") {
122
+ session.addToWhitelist(absolute);
123
+ return;
124
+ }
125
+
126
+ // choice === "Deny" or undefined (user cancelled)
127
+ return {
128
+ block: true,
129
+ reason:
130
+ `[sentinel] Blocked ${toolName} to ${PATH_CATEGORY_DESCRIPTIONS[category]}: ${absolute}`,
131
+ };
132
+ }
133
+
134
+ return {
135
+ block: true,
136
+ reason:
137
+ `[sentinel] Blocked ${toolName} to ${PATH_CATEGORY_DESCRIPTIONS[category]}: ${absolute}`,
138
+ };
139
+ };
140
+
141
+ pi.on("tool_call", async (event, ctx) => {
142
+ if (!isToolCallEventType("write", event)) return;
143
+ const rawPath = event.input.path;
144
+ if (rawPath?.startsWith("~")) {
145
+ event.input.path = resolveTargetPath(rawPath, ctx.cwd);
146
+ }
147
+ return handler(event.input.path, "write", ctx);
148
+ });
149
+
150
+ pi.on("tool_call", async (event, ctx) => {
151
+ if (!isToolCallEventType("edit", event)) return;
152
+ const rawPath = event.input.path;
153
+ if (rawPath?.startsWith("~")) {
154
+ event.input.path = resolveTargetPath(rawPath, ctx.cwd);
155
+ }
156
+ return handler(event.input.path, "edit", ctx);
157
+ });
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Public registration
162
+ // ---------------------------------------------------------------------------
163
+
164
+ export function registerPermissionGate(
165
+ pi: ExtensionAPI,
166
+ session: SentinelSession,
167
+ ): void {
168
+ registerBashGate(pi);
169
+ registerPathGate(pi, session);
170
+ }