fullcourtdefense-cli 1.6.12 → 1.6.14

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.
@@ -39,6 +39,7 @@ const os = __importStar(require("os"));
39
39
  const path = __importStar(require("path"));
40
40
  const config_1 = require("../config");
41
41
  const deterministicGuard_1 = require("./deterministicGuard");
42
+ const taintLedger_1 = require("./taintLedger");
42
43
  const DEBUG_LOG = path.join(os.homedir(), '.fullcourtdefense-hook.log');
43
44
  /** Append a diagnostic line so we can see exactly what Cursor invoked + the verdict. */
44
45
  function dbg(obj) {
@@ -400,6 +401,9 @@ async function hookCommand(args, config) {
400
401
  dbg({ phase: 'prompt_text', textLen: text.length, textPreview: text.slice(0, 300) });
401
402
  if (!text)
402
403
  respond(false);
404
+ // Record the developer's prompt so taint-tracking can later tell whether a
405
+ // sensitive action (network/git) was actually requested by the human.
406
+ (0, taintLedger_1.recordUserPrompt)(sessionId(payload), text);
403
407
  const localBlock = (0, deterministicGuard_1.scanDeterministicPrompt)(text);
404
408
  if (localBlock) {
405
409
  dbg({ phase: 'local_deterministic_prompt_block', event, ruleId: localBlock.ruleId, category: localBlock.category });
@@ -436,6 +440,24 @@ async function enforceActionPolicy(ctx) {
436
440
  respond(true, `Blocked by FullCourtDefense local guard — ${localBlock.reason}`, `FullCourtDefense blocked this ${event} locally (${localBlock.ruleId}). Do not retry.`);
437
441
  return;
438
442
  }
443
+ // --- Deterministic taint tracking (local, no backend) ---
444
+ // Evaluate the sink against the PRIOR ledger state first, BEFORE this event
445
+ // records its own ingress (so a lone remote pull doesn't self-taint then
446
+ // self-block). Reads / web / MCP output are never blocked here — only a
447
+ // sensitive sink the user never requested, after untrusted ingestion.
448
+ const sessId = sessionId(payload);
449
+ const taintFinding = (0, taintLedger_1.checkTaintedSink)(sessId, event, call.toolName, call.toolArgs);
450
+ if (taintFinding) {
451
+ dbg({ phase: 'local_taint_block', event, tool: call.toolName, ruleId: taintFinding.ruleId, sink: taintFinding.sink.kind, targets: taintFinding.sink.targets });
452
+ if (shadow) {
453
+ respond(false, undefined, `[FullCourtDefense shadow] would block ${call.toolName}: ${taintFinding.reason}`);
454
+ return;
455
+ }
456
+ respond(true, `Blocked by FullCourtDefense taint guard — ${taintFinding.reason}`, `FullCourtDefense blocked this ${event} locally (${taintFinding.ruleId}): ${taintFinding.reason} Do not retry.`);
457
+ return;
458
+ }
459
+ // Record untrusted ingress for this event (never blocks).
460
+ (0, taintLedger_1.noteIngress)(sessId, event, call.toolName, call.toolArgs);
439
461
  const headers = { 'Content-Type': 'application/json' };
440
462
  if (shieldKey)
441
463
  headers['x-shield-key'] = shieldKey;
@@ -2,108 +2,13 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.printRestartNotice = printRestartNotice;
4
4
  exports.promptRestartNotice = promptRestartNotice;
5
- const child_process_1 = require("child_process");
6
5
  function printRestartNotice(clients = 'Cursor, Claude, Codex, VS Code, Windsurf, Gemini') {
7
6
  console.log('');
8
7
  console.log('\x1b[33m\x1b[1mACTION REQUIRED: restart your AI clients\x1b[0m');
9
8
  console.log(`\x1b[33mRestart/reload ${clients} so they load the protected MCP gateway and hooks.\x1b[0m`);
10
9
  console.log('\x1b[2mAlready-running MCP servers may keep using old unprotected processes until the client restarts.\x1b[0m');
11
- }
12
- function shouldPrompt() {
13
- if (!process.stdin.isTTY || !process.stdout.isTTY)
14
- return false;
15
- if (process.env.CI === 'true' || process.env.FCD_NO_RESTART_PROMPT === 'true')
16
- return false;
17
- return true;
18
- }
19
- function parseJsonArray(raw) {
20
- const trimmed = raw.trim();
21
- if (!trimmed)
22
- return [];
23
- const parsed = JSON.parse(trimmed);
24
- const rows = Array.isArray(parsed) ? parsed : [parsed];
25
- return rows
26
- .map((row) => row)
27
- .map((row) => ({
28
- pid: Number(row.ProcessId || row.pid),
29
- name: String(row.Name || row.name || ''),
30
- executablePath: typeof row.ExecutablePath === 'string' ? row.ExecutablePath : undefined,
31
- }))
32
- .filter((row) => Number.isFinite(row.pid) && row.pid > 0 && row.name);
33
- }
34
- function detectWindowsClients() {
35
- const script = [
36
- "$names=@('Cursor.exe','Claude.exe','Code.exe','Windsurf.exe');",
37
- 'Get-CimInstance Win32_Process |',
38
- 'Where-Object { $names -contains $_.Name } |',
39
- 'Select-Object ProcessId,Name,ExecutablePath |',
40
- 'ConvertTo-Json -Compress',
41
- ].join(' ');
42
- try {
43
- return parseJsonArray((0, child_process_1.execFileSync)('powershell.exe', ['-NoProfile', '-Command', script], { encoding: 'utf8' }));
44
- }
45
- catch {
46
- return [];
47
- }
48
- }
49
- function detectRestartableClients() {
50
- if (process.platform === 'win32')
51
- return detectWindowsClients();
52
- return [];
53
- }
54
- async function askRestart(clients) {
55
- const names = [...new Set(clients.map(client => client.name.replace(/\.exe$/i, '')))].join(', ');
56
- return new Promise((resolve) => {
57
- process.stdout.write(`\n\x1b[1mRestart detected AI clients now (${names})? Press r then Enter to restart, or Enter to skip.\x1b[0m `);
58
- process.stdin.resume();
59
- process.stdin.once('data', (chunk) => {
60
- process.stdin.pause();
61
- resolve(String(chunk).trim().toLowerCase() === 'r');
62
- });
63
- });
64
- }
65
- async function restartWindowsClients(clients) {
66
- const launchPaths = [...new Set(clients.map(client => client.executablePath).filter((value) => !!value))];
67
- for (const client of clients) {
68
- try {
69
- (0, child_process_1.execFileSync)('taskkill.exe', ['/PID', String(client.pid), '/T', '/F'], { stdio: 'ignore' });
70
- }
71
- catch {
72
- // The process may already be gone.
73
- }
74
- }
75
- await new Promise(resolve => setTimeout(resolve, 1200));
76
- for (const exePath of launchPaths) {
77
- try {
78
- const child = (0, child_process_1.spawn)(exePath, [], { detached: true, stdio: 'ignore' });
79
- child.unref();
80
- console.log(`Restarted ${exePath}`);
81
- }
82
- catch (error) {
83
- console.log(`Could not restart ${exePath}: ${error instanceof Error ? error.message : String(error)}`);
84
- }
85
- }
86
- }
87
- async function restartClients(clients) {
88
- if (process.platform === 'win32') {
89
- await restartWindowsClients(clients);
90
- return;
91
- }
92
- console.log('Automatic restart is not supported on this OS yet. Please restart your AI clients manually.');
10
+ console.log('\x1b[2mClose and reopen each client you use, then run: fullcourtdefense discover --surface mcp\x1b[0m');
93
11
  }
94
12
  async function promptRestartNotice(clients = 'Cursor, Claude, Codex, VS Code, Windsurf, Gemini') {
95
13
  printRestartNotice(clients);
96
- if (!shouldPrompt())
97
- return;
98
- const runningClients = detectRestartableClients();
99
- if (runningClients.length === 0) {
100
- console.log('\n\x1b[2mNo running GUI AI clients detected. Start them again to load protection.\x1b[0m');
101
- return;
102
- }
103
- if (await askRestart(runningClients)) {
104
- await restartClients(runningClients);
105
- }
106
- else {
107
- console.log('Skipped automatic restart. Protection applies after you restart/reload the clients.');
108
- }
109
14
  }
@@ -0,0 +1,58 @@
1
+ export type TaintSourceType = 'mcp_output' | 'web_fetch' | 'external_file' | 'remote_pull';
2
+ export interface TaintSource {
3
+ type: TaintSourceType;
4
+ detail: string;
5
+ at: string;
6
+ }
7
+ export interface TaintLedger {
8
+ sessionId: string;
9
+ tainted: boolean;
10
+ sources: TaintSource[];
11
+ userPrompts: string[];
12
+ createdAt: string;
13
+ updatedAt: string;
14
+ }
15
+ export interface TaintSink {
16
+ kind: 'network' | 'git_push' | 'git_remote_add' | 'mcp_outbound';
17
+ /** External hosts/destinations referenced by the action. */
18
+ targets: string[];
19
+ detail: string;
20
+ }
21
+ export interface TaintFinding {
22
+ blocked: true;
23
+ ruleId: string;
24
+ reason: string;
25
+ evidence: string;
26
+ source: TaintSource;
27
+ sink: TaintSink;
28
+ }
29
+ /** Whether taint tracking is enabled (opt-out via env). */
30
+ export declare function taintEnabled(): boolean;
31
+ export declare function loadLedger(sessionId: string): TaintLedger;
32
+ /** Record the developer's prompt text so sink actions can be checked against it. */
33
+ export declare function recordUserPrompt(sessionId: string, text: string): void;
34
+ /** Mark the session as having ingested untrusted content. Reading is never blocked. */
35
+ export declare function markTaint(sessionId: string, source: TaintSource): void;
36
+ type EventKind = 'prompt' | 'shell' | 'mcp' | 'file' | 'read' | 'unknown';
37
+ /** Extract hostnames from any URLs / git/scp targets present in a string. */
38
+ export declare function extractHosts(text: string): string[];
39
+ /**
40
+ * Classify an event as untrusted INGRESS. Returns the source to record, or
41
+ * undefined if this event does not ingest untrusted content.
42
+ */
43
+ export declare function classifyIngress(event: EventKind, toolName: string, toolArgs: Record<string, unknown>, workspacePath?: string): TaintSource | undefined;
44
+ /**
45
+ * Detect whether an event is a SENSITIVE SINK (an action that can exfiltrate or
46
+ * reach attacker-controlled infrastructure). Returns sink info or undefined.
47
+ */
48
+ export declare function detectSink(event: EventKind, toolName: string, toolArgs: Record<string, unknown>): TaintSink | undefined;
49
+ /**
50
+ * The deterministic 3-rule check, evaluated against the CURRENT ledger state
51
+ * (call this BEFORE recording any ingress for the same event):
52
+ *
53
+ * block IF session.tainted AND is_sensitive_sink AND NOT user_authorized
54
+ */
55
+ export declare function checkTaintedSink(sessionId: string, event: EventKind, toolName: string, toolArgs: Record<string, unknown>): TaintFinding | undefined;
56
+ /** Record ingress for an event if applicable. Safe to call on every event. */
57
+ export declare function noteIngress(sessionId: string, event: EventKind, toolName: string, toolArgs: Record<string, unknown>, workspacePath?: string): TaintSource | undefined;
58
+ export {};
@@ -0,0 +1,370 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.taintEnabled = taintEnabled;
37
+ exports.loadLedger = loadLedger;
38
+ exports.recordUserPrompt = recordUserPrompt;
39
+ exports.markTaint = markTaint;
40
+ exports.extractHosts = extractHosts;
41
+ exports.classifyIngress = classifyIngress;
42
+ exports.detectSink = detectSink;
43
+ exports.checkTaintedSink = checkTaintedSink;
44
+ exports.noteIngress = noteIngress;
45
+ const fs = __importStar(require("fs"));
46
+ const os = __importStar(require("os"));
47
+ const path = __importStar(require("path"));
48
+ /**
49
+ * Deterministic taint tracking for local IDE coding agents.
50
+ *
51
+ * Models indirect prompt injection as an information-flow problem, the same way
52
+ * Microsoft FIDES / Tessera / ShisaD do — but local and zero-backend. The idea:
53
+ *
54
+ * 1. INGRESS — when the agent reads UNTRUSTED content (web fetch, MCP tool
55
+ * output, a file outside the workspace), the session is marked
56
+ * "tainted". Reading is NEVER blocked.
57
+ * 2. SINK — when the agent later performs a SENSITIVE action (network
58
+ * command, git push, new remote) that the USER never asked for,
59
+ * AND the session is tainted, the action is blocked.
60
+ *
61
+ * Trust is "min-trust" (Tessera): one untrusted source drags the whole session
62
+ * to the floor, after which the agent may only do what the human explicitly
63
+ * authorized in their prompt — not what the ingested data suggests.
64
+ *
65
+ * Everything here is pure local logic over a per-session JSON file under
66
+ * ~/.fullcourtdefense/taint/<sessionId>.json. No model, no network call.
67
+ */
68
+ const LEDGER_TTL_MS = 24 * 60 * 60 * 1000; // sessions older than 24h are pruned
69
+ const MAX_PROMPTS = 30;
70
+ const MAX_SOURCES = 50;
71
+ /** Whether taint tracking is enabled (opt-out via env). */
72
+ function taintEnabled() {
73
+ return process.env.FCD_TAINT_DISABLED !== 'true' && process.env.FCD_TAINT_DISABLED !== '1';
74
+ }
75
+ function taintDir() {
76
+ return path.join(os.homedir(), '.fullcourtdefense', 'taint');
77
+ }
78
+ function ledgerPath(sessionId) {
79
+ return path.join(taintDir(), `${safeSessionFile(sessionId)}.json`);
80
+ }
81
+ /** Make a session id safe to use as a filename. */
82
+ function safeSessionFile(sessionId) {
83
+ const cleaned = sessionId.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 120);
84
+ return cleaned || 'default-session';
85
+ }
86
+ function nowIso() {
87
+ return new Date().toISOString();
88
+ }
89
+ function emptyLedger(sessionId) {
90
+ const ts = nowIso();
91
+ return { sessionId, tainted: false, sources: [], userPrompts: [], createdAt: ts, updatedAt: ts };
92
+ }
93
+ /** Best-effort prune of stale ledger files so old sessions don't pile up. */
94
+ function pruneStale() {
95
+ try {
96
+ const dir = taintDir();
97
+ if (!fs.existsSync(dir))
98
+ return;
99
+ const cutoff = Date.now() - LEDGER_TTL_MS;
100
+ for (const name of fs.readdirSync(dir)) {
101
+ const file = path.join(dir, name);
102
+ try {
103
+ const stat = fs.statSync(file);
104
+ if (stat.mtimeMs < cutoff)
105
+ fs.unlinkSync(file);
106
+ }
107
+ catch { /* ignore individual file errors */ }
108
+ }
109
+ }
110
+ catch { /* ignore */ }
111
+ }
112
+ function loadLedger(sessionId) {
113
+ const file = ledgerPath(sessionId);
114
+ try {
115
+ if (!fs.existsSync(file))
116
+ return emptyLedger(sessionId);
117
+ const stat = fs.statSync(file);
118
+ if (Date.now() - stat.mtimeMs > LEDGER_TTL_MS)
119
+ return emptyLedger(sessionId);
120
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
121
+ return {
122
+ sessionId,
123
+ tainted: Boolean(parsed.tainted),
124
+ sources: Array.isArray(parsed.sources) ? parsed.sources.slice(-MAX_SOURCES) : [],
125
+ userPrompts: Array.isArray(parsed.userPrompts) ? parsed.userPrompts.slice(-MAX_PROMPTS) : [],
126
+ createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : nowIso(),
127
+ updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : nowIso(),
128
+ };
129
+ }
130
+ catch {
131
+ return emptyLedger(sessionId);
132
+ }
133
+ }
134
+ function saveLedger(ledger) {
135
+ try {
136
+ const dir = taintDir();
137
+ fs.mkdirSync(dir, { recursive: true });
138
+ ledger.updatedAt = nowIso();
139
+ fs.writeFileSync(ledgerPath(ledger.sessionId), JSON.stringify(ledger, null, 2), 'utf8');
140
+ pruneStale();
141
+ }
142
+ catch { /* best effort — never break the hook on disk errors */ }
143
+ }
144
+ /** Record the developer's prompt text so sink actions can be checked against it. */
145
+ function recordUserPrompt(sessionId, text) {
146
+ if (!taintEnabled())
147
+ return;
148
+ const trimmed = (text || '').trim();
149
+ if (!trimmed)
150
+ return;
151
+ const ledger = loadLedger(sessionId);
152
+ ledger.userPrompts.push(trimmed.slice(0, 4000));
153
+ if (ledger.userPrompts.length > MAX_PROMPTS)
154
+ ledger.userPrompts = ledger.userPrompts.slice(-MAX_PROMPTS);
155
+ saveLedger(ledger);
156
+ }
157
+ /** Mark the session as having ingested untrusted content. Reading is never blocked. */
158
+ function markTaint(sessionId, source) {
159
+ if (!taintEnabled())
160
+ return;
161
+ const ledger = loadLedger(sessionId);
162
+ ledger.tainted = true;
163
+ ledger.sources.push(source);
164
+ if (ledger.sources.length > MAX_SOURCES)
165
+ ledger.sources = ledger.sources.slice(-MAX_SOURCES);
166
+ saveLedger(ledger);
167
+ }
168
+ // ---------------------------------------------------------------------------
169
+ // Ingress + sink classification
170
+ // ---------------------------------------------------------------------------
171
+ const WEB_MCP_TOOL_HINT = /(?:web|fetch|browse|browser|http|url|scrape|crawl|search|google|bing|read[_-]?page|open[_-]?url|wikipedia|news)/i;
172
+ const NETWORK_CMD_HINT = /\b(?:curl|wget|nc|ncat|netcat|telnet|ftp|sftp|scp|ssh|socat|http|httpie|invoke-webrequest|invoke-restmethod|iwr|irm)\b/i;
173
+ const REMOTE_PULL_HINT = /\b(?:curl|wget|git\s+clone|git\s+fetch|git\s+pull|svn\s+checkout|npx|pip\s+install|npm\s+install)\b/i;
174
+ /** Pull every string value out of an args object (shallow-ish, bounded). */
175
+ function collectStringValues(value, out = [], depth = 0) {
176
+ if (depth > 5 || value === null || value === undefined)
177
+ return out;
178
+ if (typeof value === 'string') {
179
+ if (value.trim())
180
+ out.push(value);
181
+ return out;
182
+ }
183
+ if (typeof value === 'number' || typeof value === 'boolean') {
184
+ out.push(String(value));
185
+ return out;
186
+ }
187
+ if (Array.isArray(value)) {
188
+ for (const v of value.slice(0, 40))
189
+ collectStringValues(v, out, depth + 1);
190
+ return out;
191
+ }
192
+ if (typeof value === 'object') {
193
+ for (const v of Object.values(value).slice(0, 60))
194
+ collectStringValues(v, out, depth + 1);
195
+ }
196
+ return out;
197
+ }
198
+ /** Extract hostnames from any URLs / git/scp targets present in a string. */
199
+ function extractHosts(text) {
200
+ const hosts = new Set();
201
+ if (!text)
202
+ return [];
203
+ const urlRe = /\bhttps?:\/\/([^/\s'"`)]+)/gi;
204
+ let m;
205
+ while ((m = urlRe.exec(text)) !== null) {
206
+ const host = m[1].replace(/^.*@/, '').replace(/:.*$/, '').toLowerCase();
207
+ if (host)
208
+ hosts.add(host);
209
+ }
210
+ // git@host:path and scp user@host:path
211
+ const sshRe = /\b[\w.-]+@([\w.-]+):/g;
212
+ while ((m = sshRe.exec(text)) !== null) {
213
+ const host = m[1].toLowerCase();
214
+ if (host)
215
+ hosts.add(host);
216
+ }
217
+ return [...hosts].filter(h => !isLocalHost(h));
218
+ }
219
+ function isLocalHost(host) {
220
+ return host === 'localhost'
221
+ || host === '127.0.0.1'
222
+ || host === '::1'
223
+ || host === '0.0.0.0'
224
+ || host.endsWith('.local');
225
+ }
226
+ /** Strip a host to its registrable-ish tail (last two labels) for loose matching. */
227
+ function registrableDomain(host) {
228
+ const parts = host.split('.').filter(Boolean);
229
+ if (parts.length <= 2)
230
+ return host;
231
+ return parts.slice(-2).join('.');
232
+ }
233
+ function workspaceRoot(workspacePath) {
234
+ return workspacePath || process.env.WORKSPACE_PATH || process.env.CLAUDE_PROJECT_DIR || process.cwd();
235
+ }
236
+ function isExternalFile(filePath, workspacePath) {
237
+ try {
238
+ const root = path.resolve(workspaceRoot(workspacePath));
239
+ const resolved = path.resolve(root, filePath);
240
+ const rel = path.relative(root, resolved);
241
+ return rel === '' ? false : (rel.startsWith('..') || path.isAbsolute(rel));
242
+ }
243
+ catch {
244
+ return false;
245
+ }
246
+ }
247
+ /**
248
+ * Classify an event as untrusted INGRESS. Returns the source to record, or
249
+ * undefined if this event does not ingest untrusted content.
250
+ */
251
+ function classifyIngress(event, toolName, toolArgs, workspacePath) {
252
+ const at = nowIso();
253
+ if (event === 'read') {
254
+ const p = typeof toolArgs.path === 'string' ? toolArgs.path : '';
255
+ if (p && isExternalFile(p, workspacePath)) {
256
+ return { type: 'external_file', detail: p.slice(0, 200), at };
257
+ }
258
+ return undefined;
259
+ }
260
+ if (event === 'mcp') {
261
+ if (WEB_MCP_TOOL_HINT.test(toolName)) {
262
+ const strings = collectStringValues(toolArgs);
263
+ const hosts = strings.flatMap(extractHosts);
264
+ const detail = hosts.length ? `${toolName} -> ${hosts.join(', ')}` : toolName;
265
+ return { type: 'web_fetch', detail: detail.slice(0, 200), at };
266
+ }
267
+ return undefined;
268
+ }
269
+ if (event === 'shell') {
270
+ const command = typeof toolArgs.command === 'string' ? toolArgs.command : collectStringValues(toolArgs).join(' ');
271
+ if (REMOTE_PULL_HINT.test(command) && extractHosts(command).length > 0) {
272
+ return { type: 'remote_pull', detail: command.slice(0, 200), at };
273
+ }
274
+ return undefined;
275
+ }
276
+ return undefined;
277
+ }
278
+ /**
279
+ * Detect whether an event is a SENSITIVE SINK (an action that can exfiltrate or
280
+ * reach attacker-controlled infrastructure). Returns sink info or undefined.
281
+ */
282
+ function detectSink(event, toolName, toolArgs) {
283
+ if (event === 'shell') {
284
+ const command = typeof toolArgs.command === 'string' ? toolArgs.command : collectStringValues(toolArgs).join(' ');
285
+ if (/\bgit\s+remote\s+add\b/i.test(command)) {
286
+ return { kind: 'git_remote_add', targets: extractHosts(command), detail: command.slice(0, 200) };
287
+ }
288
+ if (/\bgit\s+push\b/i.test(command) && extractHosts(command).length > 0) {
289
+ return { kind: 'git_push', targets: extractHosts(command), detail: command.slice(0, 200) };
290
+ }
291
+ if (NETWORK_CMD_HINT.test(command)) {
292
+ const targets = extractHosts(command);
293
+ if (targets.length > 0)
294
+ return { kind: 'network', targets, detail: command.slice(0, 200) };
295
+ }
296
+ return undefined;
297
+ }
298
+ if (event === 'mcp') {
299
+ const outboundTool = /(?:http|fetch|request|curl|wget|webhook|upload|send|email|mail|slack|discord|teams|post|put|publish|notify|sms|message)/i.test(toolName);
300
+ if (!outboundTool)
301
+ return undefined;
302
+ const strings = collectStringValues(toolArgs);
303
+ const targets = strings.flatMap(extractHosts);
304
+ if (targets.length > 0)
305
+ return { kind: 'mcp_outbound', targets, detail: `${toolName} -> ${targets.join(', ')}`.slice(0, 200) };
306
+ return undefined;
307
+ }
308
+ return undefined;
309
+ }
310
+ /** Did the developer's prompt authorize reaching this target host? */
311
+ function authorizedByPrompt(ledger, targets) {
312
+ if (targets.length === 0)
313
+ return true; // no external target to authorize
314
+ const haystack = ledger.userPrompts.join('\n').toLowerCase();
315
+ if (!haystack)
316
+ return false;
317
+ // Authorized only if EVERY external target the action contacts was mentioned
318
+ // by the user (host or its registrable domain appears in some prompt).
319
+ return targets.every(host => {
320
+ const h = host.toLowerCase();
321
+ return haystack.includes(h) || haystack.includes(registrableDomain(h));
322
+ });
323
+ }
324
+ /**
325
+ * The deterministic 3-rule check, evaluated against the CURRENT ledger state
326
+ * (call this BEFORE recording any ingress for the same event):
327
+ *
328
+ * block IF session.tainted AND is_sensitive_sink AND NOT user_authorized
329
+ */
330
+ function checkTaintedSink(sessionId, event, toolName, toolArgs) {
331
+ if (!taintEnabled())
332
+ return undefined;
333
+ const sink = detectSink(event, toolName, toolArgs);
334
+ if (!sink)
335
+ return undefined;
336
+ const ledger = loadLedger(sessionId);
337
+ if (!ledger.tainted)
338
+ return undefined;
339
+ if (authorizedByPrompt(ledger, sink.targets))
340
+ return undefined;
341
+ const source = ledger.sources[ledger.sources.length - 1] || { type: 'mcp_output', detail: 'untrusted content', at: nowIso() };
342
+ const targetText = sink.targets.join(', ') || 'an external destination';
343
+ return {
344
+ blocked: true,
345
+ ruleId: 'local-taint-unrequested-sink',
346
+ reason: `Blocked ${describeSink(sink)} to ${targetText} that you did not request, after the agent `
347
+ + `ingested untrusted content (${source.type}: ${source.detail}). Likely indirect prompt injection.`,
348
+ evidence: sink.detail,
349
+ source,
350
+ sink,
351
+ };
352
+ }
353
+ function describeSink(sink) {
354
+ switch (sink.kind) {
355
+ case 'git_push': return 'a git push';
356
+ case 'git_remote_add': return 'a new git remote';
357
+ case 'mcp_outbound': return 'an outbound MCP action';
358
+ case 'network':
359
+ default: return 'a network command';
360
+ }
361
+ }
362
+ /** Record ingress for an event if applicable. Safe to call on every event. */
363
+ function noteIngress(sessionId, event, toolName, toolArgs, workspacePath) {
364
+ if (!taintEnabled())
365
+ return undefined;
366
+ const source = classifyIngress(event, toolName, toolArgs, workspacePath);
367
+ if (source)
368
+ markTaint(sessionId, source);
369
+ return source;
370
+ }
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.6.12"
2
+ "version": "1.6.14"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.6.12",
3
+ "version": "1.6.14",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -15,6 +15,7 @@
15
15
  "scripts": {
16
16
  "build": "tsc && node scripts/copy-attack-corpus.js",
17
17
  "test:deterministic-guard": "npm run build && node scripts/test-deterministic-guard.js",
18
+ "test:taint-ledger": "npm run build && node scripts/test-taint-ledger.js",
18
19
  "prepublishOnly": "npm run build"
19
20
  },
20
21
  "keywords": [