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.
package/dist/commands/hook.js
CHANGED
|
@@ -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullcourtdefense-cli",
|
|
3
|
-
"version": "1.6.
|
|
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": [
|