pi-mono-sentinel 1.6.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 +7 -0
- package/LICENCE.md +7 -0
- package/guards/execution-tracker.ts +281 -0
- package/guards/output-scanner.ts +211 -0
- package/index.ts +34 -0
- package/package.json +23 -0
- package/patterns/secrets.ts +143 -0
- package/session.ts +59 -0
- package/types.ts +39 -0
package/CHANGELOG.md
ADDED
package/LICENCE.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2026 Emanuel Casco
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -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,211 @@
|
|
|
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
|
+
isBinaryContent,
|
|
25
|
+
MAX_SCAN_BYTES,
|
|
26
|
+
scanForSecrets,
|
|
27
|
+
} from "../patterns/secrets.js";
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Helpers
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/** Format scan matches into a human-readable confirmation message. */
|
|
34
|
+
function formatConfirmMessage(matches: ScanMatch[]): string {
|
|
35
|
+
const lines = matches.map(
|
|
36
|
+
(m) => ` - ${m.label} (line ${m.line}): ${m.snippet}`,
|
|
37
|
+
);
|
|
38
|
+
return [
|
|
39
|
+
"File may contain secrets:",
|
|
40
|
+
...lines,
|
|
41
|
+
"",
|
|
42
|
+
"Allow this read?",
|
|
43
|
+
].join("\n");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Extract file paths from bash commands that read file content.
|
|
48
|
+
* Targets: cat, head, tail, less, more.
|
|
49
|
+
*/
|
|
50
|
+
function extractReadTargets(command: string): string[] {
|
|
51
|
+
const pattern = /\b(?:cat|head|tail|less|more)\s+([^\s|;&]+)/g;
|
|
52
|
+
const paths: string[] = [];
|
|
53
|
+
let match: RegExpExecArray | null;
|
|
54
|
+
while ((match = pattern.exec(command)) !== null) {
|
|
55
|
+
const target = match[1];
|
|
56
|
+
// Skip flags (start with -)
|
|
57
|
+
if (!target.startsWith("-")) {
|
|
58
|
+
paths.push(target);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return paths;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Pre-read a file and scan for secrets. Uses the session scan cache to
|
|
66
|
+
* avoid redundant filesystem reads on unchanged files.
|
|
67
|
+
*
|
|
68
|
+
* Returns the scan matches, or an empty array if the file is binary,
|
|
69
|
+
* too large, or unreadable.
|
|
70
|
+
*/
|
|
71
|
+
async function scanFile(
|
|
72
|
+
absolutePath: string,
|
|
73
|
+
session: SentinelSession,
|
|
74
|
+
): Promise<ScanMatch[]> {
|
|
75
|
+
try {
|
|
76
|
+
const fileStat = await stat(absolutePath);
|
|
77
|
+
|
|
78
|
+
// Check cache first
|
|
79
|
+
const cached = session.getCachedScan(absolutePath, fileStat.mtimeMs);
|
|
80
|
+
if (cached) return cached.matches;
|
|
81
|
+
|
|
82
|
+
// Skip files larger than scan limit
|
|
83
|
+
if (fileStat.size > MAX_SCAN_BYTES) return [];
|
|
84
|
+
|
|
85
|
+
// Skip non-files (directories, symlinks, etc.)
|
|
86
|
+
if (!fileStat.isFile()) return [];
|
|
87
|
+
|
|
88
|
+
const content = await readFile(absolutePath, "utf-8");
|
|
89
|
+
|
|
90
|
+
// Skip binary files
|
|
91
|
+
if (isBinaryContent(content)) {
|
|
92
|
+
session.cacheScan(absolutePath, fileStat.mtimeMs, {
|
|
93
|
+
hasSecrets: false,
|
|
94
|
+
matches: [],
|
|
95
|
+
});
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const result = scanForSecrets(content);
|
|
100
|
+
session.cacheScan(absolutePath, fileStat.mtimeMs, result);
|
|
101
|
+
return result.matches;
|
|
102
|
+
} catch {
|
|
103
|
+
// File unreadable (permissions, doesn't exist, etc.) — let the tool handle the error
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Guard registration
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
export function registerOutputScanner(
|
|
113
|
+
pi: ExtensionAPI,
|
|
114
|
+
session: SentinelSession,
|
|
115
|
+
): void {
|
|
116
|
+
// -----------------------------------------------------------------------
|
|
117
|
+
// Intercept `read` tool calls
|
|
118
|
+
// -----------------------------------------------------------------------
|
|
119
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
120
|
+
if (!isToolCallEventType("read", event)) return;
|
|
121
|
+
|
|
122
|
+
const rawPath = event.input.path;
|
|
123
|
+
if (!rawPath) return;
|
|
124
|
+
|
|
125
|
+
const absolutePath = resolve(
|
|
126
|
+
ctx.cwd,
|
|
127
|
+
rawPath.startsWith("@") ? rawPath.slice(1) : rawPath,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const matches = await scanFile(absolutePath, session);
|
|
131
|
+
if (matches.length === 0) return;
|
|
132
|
+
|
|
133
|
+
// Secrets found — escalate
|
|
134
|
+
if (ctx.hasUI) {
|
|
135
|
+
const allowed = await ctx.ui.confirm(
|
|
136
|
+
"[sentinel] Secret detected",
|
|
137
|
+
formatConfirmMessage(matches),
|
|
138
|
+
);
|
|
139
|
+
if (allowed) return; // User approved — let the read proceed
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// No UI or user denied — block
|
|
143
|
+
return {
|
|
144
|
+
block: true,
|
|
145
|
+
reason:
|
|
146
|
+
`[sentinel] Blocked: file contains ${matches.length} potential secret(s). ` +
|
|
147
|
+
matches.map((m) => m.label).join(", ") +
|
|
148
|
+
".",
|
|
149
|
+
};
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// -----------------------------------------------------------------------
|
|
153
|
+
// Intercept `bash` commands that read files (cat, head, tail, less)
|
|
154
|
+
// -----------------------------------------------------------------------
|
|
155
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
156
|
+
if (!isToolCallEventType("bash", event)) return;
|
|
157
|
+
|
|
158
|
+
const command = event.input.command ?? "";
|
|
159
|
+
const targets = extractReadTargets(command);
|
|
160
|
+
if (targets.length === 0) return;
|
|
161
|
+
|
|
162
|
+
// Scan all targeted files
|
|
163
|
+
const allMatches: Array<{ path: string; matches: ScanMatch[] }> = [];
|
|
164
|
+
for (const target of targets) {
|
|
165
|
+
const absolutePath = resolve(ctx.cwd, target);
|
|
166
|
+
const matches = await scanFile(absolutePath, session);
|
|
167
|
+
if (matches.length > 0) {
|
|
168
|
+
allMatches.push({ path: target, matches });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (allMatches.length === 0) return;
|
|
173
|
+
|
|
174
|
+
// Build a combined confirmation message
|
|
175
|
+
const message = allMatches
|
|
176
|
+
.flatMap(({ path, matches }) =>
|
|
177
|
+
matches.map(
|
|
178
|
+
(m) => ` - ${m.label} in ${path} (line ${m.line}): ${m.snippet}`,
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
.join("\n");
|
|
182
|
+
|
|
183
|
+
if (ctx.hasUI) {
|
|
184
|
+
const allowed = await ctx.ui.confirm(
|
|
185
|
+
"[sentinel] Secret detected in bash target",
|
|
186
|
+
`Command reads file(s) that may contain secrets:\n${message}\n\nAllow execution?`,
|
|
187
|
+
);
|
|
188
|
+
if (allowed) return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const totalMatches = allMatches.reduce(
|
|
192
|
+
(sum, entry) => sum + entry.matches.length,
|
|
193
|
+
0,
|
|
194
|
+
);
|
|
195
|
+
return {
|
|
196
|
+
block: true,
|
|
197
|
+
reason:
|
|
198
|
+
`[sentinel] Blocked: bash command reads file(s) with ${totalMatches} potential secret(s).`,
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// -----------------------------------------------------------------------
|
|
203
|
+
// Invalidate scan cache when context-guard reports a file modification
|
|
204
|
+
// -----------------------------------------------------------------------
|
|
205
|
+
pi.events.on("context-guard:file-modified", (data: unknown) => {
|
|
206
|
+
const event = data as { path?: string } | undefined;
|
|
207
|
+
if (event?.path) {
|
|
208
|
+
session.invalidateScanCache(resolve(event.path));
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sentinel — content-aware security guard for pi coding agents.
|
|
3
|
+
*
|
|
4
|
+
* Two guards addressing cross-cutting security gaps:
|
|
5
|
+
*
|
|
6
|
+
* 1. **output-scanner** (Gap 2 — content-in-location):
|
|
7
|
+
* Pre-reads files before `read` tool calls and scans for secret patterns.
|
|
8
|
+
* Asks the user before allowing reads that contain credentials.
|
|
9
|
+
*
|
|
10
|
+
* 2. **execution-tracker** (Gap 3 — indirect execution):
|
|
11
|
+
* Tracks files written during the session and scans for dangerous patterns.
|
|
12
|
+
* When `bash` executes a file written this session, correlates the write
|
|
13
|
+
* with the execution and asks/denies based on flagged content.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
17
|
+
|
|
18
|
+
import { SentinelSession } from "./session.js";
|
|
19
|
+
import { registerOutputScanner } from "./guards/output-scanner.js";
|
|
20
|
+
import { registerExecutionTracker } from "./guards/execution-tracker.js";
|
|
21
|
+
|
|
22
|
+
export default function (pi: ExtensionAPI): void {
|
|
23
|
+
const session = new SentinelSession();
|
|
24
|
+
|
|
25
|
+
pi.on("session_start", async () => {
|
|
26
|
+
session.reset();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Gap 2: scan file content before reads
|
|
30
|
+
registerOutputScanner(pi, session);
|
|
31
|
+
|
|
32
|
+
// Gap 3: track writes + correlate with bash execution
|
|
33
|
+
registerExecutionTracker(pi, session);
|
|
34
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-mono-sentinel",
|
|
3
|
+
"version": "1.6.0",
|
|
4
|
+
"description": "Pi extension that guards against content-based secret leaks and indirect script execution",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension"
|
|
8
|
+
],
|
|
9
|
+
"peerDependencies": {
|
|
10
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
11
|
+
"@sinclair/typebox": "*"
|
|
12
|
+
},
|
|
13
|
+
"pi": {
|
|
14
|
+
"extensions": [
|
|
15
|
+
"./index.ts"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/emanuelcasco/pi-mono-extensions.git",
|
|
21
|
+
"directory": "extensions/sentinel"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { ScanMatch, ScanResult } from "../types.js";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Known secret patterns
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
type SecretPattern = {
|
|
8
|
+
label: string;
|
|
9
|
+
regex: RegExp;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const SECRET_PATTERNS: readonly SecretPattern[] = [
|
|
13
|
+
{ label: "AWS Access Key", regex: /AKIA[A-Z0-9]{16}/ },
|
|
14
|
+
{
|
|
15
|
+
label: "AWS Secret Key",
|
|
16
|
+
regex: /(?:aws_secret_access_key|secret_key)\s*[=:]\s*\S{20,}/i,
|
|
17
|
+
},
|
|
18
|
+
{ label: "GitHub Token", regex: /gh[ps]_[a-zA-Z0-9]{36,}/ },
|
|
19
|
+
{ label: "GitHub OAuth Token", regex: /gho_[a-zA-Z0-9]{36,}/ },
|
|
20
|
+
{ label: "Anthropic Key", regex: /sk-ant-[a-zA-Z0-9_-]{20,}/ },
|
|
21
|
+
{ label: "OpenAI Key", regex: /sk-[a-zA-Z0-9]{40,}/ },
|
|
22
|
+
{
|
|
23
|
+
label: "PEM Private Key",
|
|
24
|
+
regex: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----/,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
label: "Generic Secret",
|
|
28
|
+
regex: /(?:secret|password|token|api_key|apikey|api-key)\s*[=:]\s*['"][^'"]{8,}['"]/i,
|
|
29
|
+
},
|
|
30
|
+
{ label: "Slack Token", regex: /xox[bpsa]-[a-zA-Z0-9-]{10,}/ },
|
|
31
|
+
{
|
|
32
|
+
label: "Stripe Key",
|
|
33
|
+
regex: /[sr]k_(?:live|test)_[a-zA-Z0-9]{20,}/,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
label: "Google OAuth Secret",
|
|
37
|
+
regex: /GOCSPX-[a-zA-Z0-9_-]{28,}/,
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Shannon entropy helper
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/** Compute Shannon entropy (bits per character) of a string. */
|
|
46
|
+
function shannonEntropy(s: string): number {
|
|
47
|
+
const freq = new Map<string, number>();
|
|
48
|
+
for (const ch of s) {
|
|
49
|
+
freq.set(ch, (freq.get(ch) ?? 0) + 1);
|
|
50
|
+
}
|
|
51
|
+
let entropy = 0;
|
|
52
|
+
for (const count of freq.values()) {
|
|
53
|
+
const p = count / s.length;
|
|
54
|
+
entropy -= p * Math.log2(p);
|
|
55
|
+
}
|
|
56
|
+
return entropy;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ENTROPY_THRESHOLD = 4.0;
|
|
60
|
+
const ENTROPY_MIN_LENGTH = 16;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Match high-entropy values in `.env`-style assignments:
|
|
64
|
+
* KEY=value or KEY="value" or KEY='value'
|
|
65
|
+
*/
|
|
66
|
+
const ENV_ASSIGNMENT = /^([A-Z_][A-Z0-9_]*)\s*=\s*['"]?([^'"#\s]+)['"]?/gm;
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Masking
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
/** Mask a secret value, keeping the first 4 and last 4 characters. */
|
|
73
|
+
function mask(value: string): string {
|
|
74
|
+
if (value.length <= 10) return `${value.slice(0, 3)}****`;
|
|
75
|
+
return `${value.slice(0, 4)}****${value.slice(-4)}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Public API
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/** Maximum bytes to scan (1 MB). */
|
|
83
|
+
export const MAX_SCAN_BYTES = 1_048_576;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Scan text content for known secret patterns and high-entropy values.
|
|
87
|
+
* Returns all matches with line numbers and masked snippets.
|
|
88
|
+
*/
|
|
89
|
+
export function scanForSecrets(content: string): ScanResult {
|
|
90
|
+
const matches: ScanMatch[] = [];
|
|
91
|
+
const lines = content.split("\n");
|
|
92
|
+
|
|
93
|
+
for (let i = 0; i < lines.length; i++) {
|
|
94
|
+
const line = lines[i];
|
|
95
|
+
const lineNum = i + 1;
|
|
96
|
+
|
|
97
|
+
// Check each known pattern
|
|
98
|
+
for (const { label, regex } of SECRET_PATTERNS) {
|
|
99
|
+
const match = regex.exec(line);
|
|
100
|
+
if (match) {
|
|
101
|
+
matches.push({
|
|
102
|
+
label,
|
|
103
|
+
line: lineNum,
|
|
104
|
+
snippet: mask(match[0]),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Check high-entropy env-style assignments
|
|
110
|
+
let envMatch: RegExpExecArray | null;
|
|
111
|
+
ENV_ASSIGNMENT.lastIndex = 0;
|
|
112
|
+
while ((envMatch = ENV_ASSIGNMENT.exec(line)) !== null) {
|
|
113
|
+
const value = envMatch[2];
|
|
114
|
+
if (
|
|
115
|
+
value.length >= ENTROPY_MIN_LENGTH &&
|
|
116
|
+
shannonEntropy(value) >= ENTROPY_THRESHOLD
|
|
117
|
+
) {
|
|
118
|
+
// Avoid duplicate if already matched by a known pattern
|
|
119
|
+
const alreadyMatched = matches.some(
|
|
120
|
+
(m) => m.line === lineNum,
|
|
121
|
+
);
|
|
122
|
+
if (!alreadyMatched) {
|
|
123
|
+
matches.push({
|
|
124
|
+
label: "High-Entropy Value",
|
|
125
|
+
line: lineNum,
|
|
126
|
+
snippet: `${envMatch[1]}=${mask(value)}`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { hasSecrets: matches.length > 0, matches };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Returns true if the buffer likely contains binary data.
|
|
138
|
+
* Checks the first 512 bytes for null bytes.
|
|
139
|
+
*/
|
|
140
|
+
export function isBinaryContent(content: string): boolean {
|
|
141
|
+
const sample = content.slice(0, 512);
|
|
142
|
+
return sample.includes("\0");
|
|
143
|
+
}
|
package/session.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ScanResult, WriteEntry } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Session-scoped state for Sentinel guards.
|
|
5
|
+
*
|
|
6
|
+
* Tracks files written during the session (Gap 3) and caches scan results
|
|
7
|
+
* to avoid redundant filesystem reads (Gap 2).
|
|
8
|
+
* Reset on every `session_start`.
|
|
9
|
+
*/
|
|
10
|
+
export class SentinelSession {
|
|
11
|
+
/** Files written during this session, keyed by absolute path. */
|
|
12
|
+
private writeRegistry = new Map<string, WriteEntry>();
|
|
13
|
+
|
|
14
|
+
/** Scan-result cache keyed by absolute path. Invalidated when mtime changes. */
|
|
15
|
+
private scanCache = new Map<string, { mtimeMs: number; result: ScanResult }>();
|
|
16
|
+
|
|
17
|
+
/** Clear all session state (called on session_start). */
|
|
18
|
+
reset(): void {
|
|
19
|
+
this.writeRegistry.clear();
|
|
20
|
+
this.scanCache.clear();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// -- Write registry (Gap 3) ------------------------------------------------
|
|
24
|
+
|
|
25
|
+
registerWrite(entry: WriteEntry): void {
|
|
26
|
+
this.writeRegistry.set(entry.path, entry);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
getWrite(absolutePath: string): WriteEntry | undefined {
|
|
30
|
+
return this.writeRegistry.get(absolutePath);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// -- Scan cache (Gap 2) ----------------------------------------------------
|
|
34
|
+
|
|
35
|
+
getCachedScan(
|
|
36
|
+
absolutePath: string,
|
|
37
|
+
currentMtimeMs: number,
|
|
38
|
+
): ScanResult | undefined {
|
|
39
|
+
const cached = this.scanCache.get(absolutePath);
|
|
40
|
+
if (!cached) return undefined;
|
|
41
|
+
if (cached.mtimeMs !== currentMtimeMs) {
|
|
42
|
+
this.scanCache.delete(absolutePath);
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
return cached.result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
cacheScan(
|
|
49
|
+
absolutePath: string,
|
|
50
|
+
mtimeMs: number,
|
|
51
|
+
result: ScanResult,
|
|
52
|
+
): void {
|
|
53
|
+
this.scanCache.set(absolutePath, { mtimeMs, result });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
invalidateScanCache(absolutePath: string): void {
|
|
57
|
+
this.scanCache.delete(absolutePath);
|
|
58
|
+
}
|
|
59
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Tri-state guard decision. */
|
|
2
|
+
export type Decision =
|
|
3
|
+
| { action: "allow" }
|
|
4
|
+
| { action: "deny"; reason: string }
|
|
5
|
+
| { action: "ask"; title: string; message: string };
|
|
6
|
+
|
|
7
|
+
/** A single secret match found during content scanning. */
|
|
8
|
+
export type ScanMatch = {
|
|
9
|
+
/** Human-readable label, e.g. "AWS Access Key". */
|
|
10
|
+
label: string;
|
|
11
|
+
/** 1-based line number where the match was found. */
|
|
12
|
+
line: number;
|
|
13
|
+
/** Masked excerpt of the matched value. */
|
|
14
|
+
snippet: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Aggregated result of scanning content for secrets. */
|
|
18
|
+
export type ScanResult = {
|
|
19
|
+
hasSecrets: boolean;
|
|
20
|
+
matches: ScanMatch[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** A dangerous-content pattern detected in written file content (Gap 3). */
|
|
24
|
+
export type DangerousPattern = {
|
|
25
|
+
label: string;
|
|
26
|
+
pattern: RegExp;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Entry in the session write registry (Gap 3). */
|
|
30
|
+
export type WriteEntry = {
|
|
31
|
+
/** Absolute path of the written file. */
|
|
32
|
+
path: string;
|
|
33
|
+
/** Timestamp (Date.now()) of the write. */
|
|
34
|
+
timestamp: number;
|
|
35
|
+
/** Whether dangerous execution patterns were detected in the content. */
|
|
36
|
+
hasDangerousContent: boolean;
|
|
37
|
+
/** Labels of detected dangerous patterns. */
|
|
38
|
+
dangerousPatterns: string[];
|
|
39
|
+
};
|