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.
- package/CHANGELOG.md +13 -0
- package/LICENCE.md +7 -0
- package/node_modules/pi-common/package.json +22 -0
- package/node_modules/pi-common/src/auth-config.ts +290 -0
- package/node_modules/pi-common/src/auth.ts +63 -0
- package/node_modules/pi-common/src/cache.ts +60 -0
- package/node_modules/pi-common/src/errors.ts +47 -0
- package/node_modules/pi-common/src/http-client.ts +118 -0
- package/node_modules/pi-common/src/index.ts +7 -0
- package/node_modules/pi-common/src/rate-limiter.ts +32 -0
- package/node_modules/pi-common/src/tool-result.ts +27 -0
- package/node_modules/pi-mono-ask-user-question/CHANGELOG.md +185 -0
- package/node_modules/pi-mono-ask-user-question/README.md +226 -0
- package/node_modules/pi-mono-ask-user-question/index.ts +923 -0
- package/node_modules/pi-mono-ask-user-question/package.json +29 -0
- package/node_modules/pi-mono-auto-fix/CHANGELOG.md +59 -0
- package/node_modules/pi-mono-auto-fix/README.md +77 -0
- package/node_modules/pi-mono-auto-fix/index.ts +488 -0
- package/node_modules/pi-mono-auto-fix/package.json +23 -0
- package/node_modules/pi-mono-btw/CHANGELOG.md +180 -0
- package/node_modules/pi-mono-btw/README.md +24 -0
- package/node_modules/pi-mono-btw/index.ts +499 -0
- package/node_modules/pi-mono-btw/package.json +29 -0
- package/node_modules/pi-mono-clear/CHANGELOG.md +180 -0
- package/node_modules/pi-mono-clear/README.md +40 -0
- package/node_modules/pi-mono-clear/index.ts +45 -0
- package/node_modules/pi-mono-clear/package.json +29 -0
- package/node_modules/pi-mono-context/CHANGELOG.md +12 -0
- package/node_modules/pi-mono-context/README.md +74 -0
- package/node_modules/pi-mono-context/index.ts +641 -0
- package/node_modules/pi-mono-context/package.json +29 -0
- package/node_modules/pi-mono-context-guard/CHANGELOG.md +195 -0
- package/node_modules/pi-mono-context-guard/README.md +81 -0
- package/node_modules/pi-mono-context-guard/index.ts +212 -0
- package/node_modules/pi-mono-context-guard/package.json +23 -0
- package/node_modules/pi-mono-figma/CHANGELOG.md +59 -0
- package/node_modules/pi-mono-figma/README.md +236 -0
- package/node_modules/pi-mono-figma/__tests__/code-connect.test.ts +32 -0
- package/node_modules/pi-mono-figma/__tests__/figma-assets.test.ts +38 -0
- package/node_modules/pi-mono-figma/__tests__/figma-component-hints.test.ts +23 -0
- package/node_modules/pi-mono-figma/__tests__/figma-implementation-layout.test.ts +47 -0
- package/node_modules/pi-mono-figma/__tests__/figma-search.test.ts +51 -0
- package/node_modules/pi-mono-figma/__tests__/figma-summarizer.test.ts +65 -0
- package/node_modules/pi-mono-figma/__tests__/fixtures/complex-auto-layout.json +115 -0
- package/node_modules/pi-mono-figma/__tests__/fixtures/component-instance.json +50 -0
- package/node_modules/pi-mono-figma/__tests__/fixtures/hidden-and-vectors.json +28 -0
- package/node_modules/pi-mono-figma/__tests__/fixtures/variables-and-styles.json +40 -0
- package/node_modules/pi-mono-figma/docs/live-selection-bridge.md +16 -0
- package/node_modules/pi-mono-figma/index.ts +6 -0
- package/node_modules/pi-mono-figma/package.json +33 -0
- package/node_modules/pi-mono-figma/skills/figma/SKILL.md +143 -0
- package/node_modules/pi-mono-figma/src/code-connect.ts +110 -0
- package/node_modules/pi-mono-figma/src/figma-assets.ts +146 -0
- package/node_modules/pi-mono-figma/src/figma-cache.ts +6 -0
- package/node_modules/pi-mono-figma/src/figma-client.ts +471 -0
- package/node_modules/pi-mono-figma/src/figma-component-hints.ts +87 -0
- package/node_modules/pi-mono-figma/src/figma-implementation.ts +264 -0
- package/node_modules/pi-mono-figma/src/figma-schemas.ts +139 -0
- package/node_modules/pi-mono-figma/src/figma-search.ts +195 -0
- package/node_modules/pi-mono-figma/src/figma-summarizer.ts +673 -0
- package/node_modules/pi-mono-figma/src/figma-tokens.ts +57 -0
- package/node_modules/pi-mono-figma/src/figma-tools.ts +352 -0
- package/node_modules/pi-mono-linear/CHANGELOG.md +44 -0
- package/node_modules/pi-mono-linear/README.md +159 -0
- package/node_modules/pi-mono-linear/index.ts +6 -0
- package/node_modules/pi-mono-linear/package.json +30 -0
- package/node_modules/pi-mono-linear/skills/linear/SKILL.md +107 -0
- package/node_modules/pi-mono-linear/src/linear-client.ts +339 -0
- package/node_modules/pi-mono-linear/src/linear-queries.ts +101 -0
- package/node_modules/pi-mono-linear/src/linear-schemas.ts +90 -0
- package/node_modules/pi-mono-linear/src/linear-tools.ts +362 -0
- package/node_modules/pi-mono-loop/CHANGELOG.md +163 -0
- package/node_modules/pi-mono-loop/README.md +54 -0
- package/node_modules/pi-mono-loop/index.ts +291 -0
- package/node_modules/pi-mono-loop/package.json +26 -0
- package/node_modules/pi-mono-multi-edit/CHANGELOG.md +232 -0
- package/node_modules/pi-mono-multi-edit/README.md +244 -0
- package/node_modules/pi-mono-multi-edit/__tests__/classic.test.ts +277 -0
- package/node_modules/pi-mono-multi-edit/__tests__/diff.test.ts +77 -0
- package/node_modules/pi-mono-multi-edit/__tests__/patch.test.ts +287 -0
- package/node_modules/pi-mono-multi-edit/benchmark-edits.ts +966 -0
- package/node_modules/pi-mono-multi-edit/classic.ts +435 -0
- package/node_modules/pi-mono-multi-edit/diff.ts +143 -0
- package/node_modules/pi-mono-multi-edit/index.ts +266 -0
- package/node_modules/pi-mono-multi-edit/package.json +37 -0
- package/node_modules/pi-mono-multi-edit/patch.ts +463 -0
- package/node_modules/pi-mono-multi-edit/types.ts +53 -0
- package/node_modules/pi-mono-multi-edit/workspace.ts +85 -0
- package/node_modules/pi-mono-review/CHANGELOG.md +190 -0
- package/node_modules/pi-mono-review/README.md +30 -0
- package/node_modules/pi-mono-review/common.ts +930 -0
- package/node_modules/pi-mono-review/index.ts +8 -0
- package/node_modules/pi-mono-review/package.json +29 -0
- package/node_modules/pi-mono-review/review-tui.ts +194 -0
- package/node_modules/pi-mono-review/review.ts +119 -0
- package/node_modules/pi-mono-review/reviewer.ts +339 -0
- package/node_modules/pi-mono-sentinel/CHANGELOG.md +158 -0
- package/node_modules/pi-mono-sentinel/README.md +87 -0
- package/node_modules/pi-mono-sentinel/__tests__/output-scanner.test.ts +109 -0
- package/node_modules/pi-mono-sentinel/__tests__/permissions.test.ts +202 -0
- package/node_modules/pi-mono-sentinel/__tests__/whitelist.test.ts +59 -0
- package/node_modules/pi-mono-sentinel/guards/execution-tracker.ts +281 -0
- package/node_modules/pi-mono-sentinel/guards/output-scanner.ts +232 -0
- package/node_modules/pi-mono-sentinel/guards/permission-gate.ts +170 -0
- package/node_modules/pi-mono-sentinel/index.ts +43 -0
- package/node_modules/pi-mono-sentinel/package.json +26 -0
- package/node_modules/pi-mono-sentinel/patterns/permissions.ts +175 -0
- package/node_modules/pi-mono-sentinel/patterns/read-targets.ts +104 -0
- package/node_modules/pi-mono-sentinel/patterns/secrets.ts +143 -0
- package/node_modules/pi-mono-sentinel/session.ts +95 -0
- package/node_modules/pi-mono-sentinel/specs/2026/04/sentinel/001-permission-gate.md +145 -0
- package/node_modules/pi-mono-sentinel/types.ts +39 -0
- package/node_modules/pi-mono-sentinel/whitelist.ts +86 -0
- package/node_modules/pi-mono-simplify/CHANGELOG.md +163 -0
- package/node_modules/pi-mono-simplify/README.md +56 -0
- package/node_modules/pi-mono-simplify/index.ts +78 -0
- package/node_modules/pi-mono-simplify/package.json +29 -0
- package/node_modules/pi-mono-status-line/CHANGELOG.md +180 -0
- package/node_modules/pi-mono-status-line/README.md +96 -0
- package/node_modules/pi-mono-status-line/basic.ts +89 -0
- package/node_modules/pi-mono-status-line/expert.ts +689 -0
- package/node_modules/pi-mono-status-line/index.ts +54 -0
- package/node_modules/pi-mono-status-line/package.json +29 -0
- package/node_modules/pi-mono-team-mode/CHANGELOG.md +278 -0
- package/node_modules/pi-mono-team-mode/README.md +246 -0
- package/node_modules/pi-mono-team-mode/__tests__/agent-manager-transient.test.ts +75 -0
- package/node_modules/pi-mono-team-mode/__tests__/delegation-manager.test.ts +118 -0
- package/node_modules/pi-mono-team-mode/__tests__/formatters.test.ts +104 -0
- package/node_modules/pi-mono-team-mode/__tests__/model-config.test.ts +272 -0
- package/node_modules/pi-mono-team-mode/__tests__/notification-box.test.ts +34 -0
- package/node_modules/pi-mono-team-mode/__tests__/parallel-utils.test.ts +32 -0
- package/node_modules/pi-mono-team-mode/__tests__/pi-stream-parser.test.ts +64 -0
- package/node_modules/pi-mono-team-mode/__tests__/prompts.test.ts +106 -0
- package/node_modules/pi-mono-team-mode/__tests__/store.test.ts +164 -0
- package/node_modules/pi-mono-team-mode/__tests__/tasks.test.ts +267 -0
- package/node_modules/pi-mono-team-mode/__tests__/teammate-specs.test.ts +114 -0
- package/node_modules/pi-mono-team-mode/__tests__/widget.test.ts +41 -0
- package/node_modules/pi-mono-team-mode/__tests__/worktree.test.ts +78 -0
- package/node_modules/pi-mono-team-mode/core/chain-utils.ts +90 -0
- package/node_modules/pi-mono-team-mode/core/fs-utils.ts +44 -0
- package/node_modules/pi-mono-team-mode/core/model-config.ts +432 -0
- package/node_modules/pi-mono-team-mode/core/parallel-utils.ts +48 -0
- package/node_modules/pi-mono-team-mode/core/prompts.ts +158 -0
- package/node_modules/pi-mono-team-mode/core/store.ts +156 -0
- package/node_modules/pi-mono-team-mode/core/tasks.ts +99 -0
- package/node_modules/pi-mono-team-mode/core/teammate-specs.ts +124 -0
- package/node_modules/pi-mono-team-mode/core/types.ts +160 -0
- package/node_modules/pi-mono-team-mode/index.ts +825 -0
- package/node_modules/pi-mono-team-mode/managers/agent-manager.ts +654 -0
- package/node_modules/pi-mono-team-mode/managers/delegation-manager.ts +211 -0
- package/node_modules/pi-mono-team-mode/managers/task-manager.ts +238 -0
- package/node_modules/pi-mono-team-mode/managers/team-manager.ts +59 -0
- package/node_modules/pi-mono-team-mode/package.json +33 -0
- package/node_modules/pi-mono-team-mode/runtime/pi-stream-parser.ts +194 -0
- package/node_modules/pi-mono-team-mode/runtime/subprocess.ts +183 -0
- package/node_modules/pi-mono-team-mode/runtime/transient-session.ts +196 -0
- package/node_modules/pi-mono-team-mode/runtime/worktree.ts +90 -0
- package/node_modules/pi-mono-team-mode/ui/formatters.ts +149 -0
- package/node_modules/pi-mono-team-mode/ui/notification-box.ts +55 -0
- package/node_modules/pi-mono-team-mode/ui/widget.ts +94 -0
- 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
|
+
}
|