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 ADDED
@@ -0,0 +1,7 @@
1
+ # pi-mono-sentinel
2
+
3
+ ## 1.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ Initial release of sentinel extension, replacing the previous `grep` extension with a security-focused monitoring and guarding system for sensitive operations.
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
+ };