fullcourtdefense-cli 1.34.19 → 1.34.20
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.
|
@@ -6985,6 +6985,48 @@ function explicitGitHubWriteRepository(command) {
|
|
|
6985
6985
|
} else if (positional && !/^[1-9][0-9]*$/.test(positional)) return void 0;
|
|
6986
6986
|
return repository;
|
|
6987
6987
|
}
|
|
6988
|
+
function withoutUnattributedJavaScriptUrls(segment) {
|
|
6989
|
+
if (segment.length > 32768) return segment;
|
|
6990
|
+
const inline = /^\s*node(?:\.exe)?\s+(?:-e|--eval)\s+(?:"([^"$`\\]*)"|'([^']*)')\s*$/i.exec(segment);
|
|
6991
|
+
if (!inline) return segment;
|
|
6992
|
+
const code = inline[1] ?? inline[2];
|
|
6993
|
+
let budget = 4e3;
|
|
6994
|
+
try {
|
|
6995
|
+
const tree = parse3(code, { ecmaVersion: "latest", sourceType: "script", allowAwaitOutsideFunction: true });
|
|
6996
|
+
const data2 = (node, names) => {
|
|
6997
|
+
if (!node || --budget < 0) return false;
|
|
6998
|
+
if (node.type === "Literal") return !node.regex;
|
|
6999
|
+
if (node.type === "Identifier") return names.has(node.name);
|
|
7000
|
+
return node.type === "ArrayExpression" && node.elements.every((item) => data2(item, names));
|
|
7001
|
+
};
|
|
7002
|
+
const statement = (node, names) => {
|
|
7003
|
+
if (!node || --budget < 0) return false;
|
|
7004
|
+
if (node.type === "Program" || node.type === "BlockStatement") {
|
|
7005
|
+
const scope = new Set(names);
|
|
7006
|
+
return node.body.every((item) => statement(item, scope));
|
|
7007
|
+
}
|
|
7008
|
+
if (node.type === "EmptyStatement") return true;
|
|
7009
|
+
if (node.type === "VariableDeclaration" && node.kind === "const") {
|
|
7010
|
+
return node.declarations.every((item) => {
|
|
7011
|
+
if (item.id?.type !== "Identifier" || item.id.name === "console" || names.has(item.id.name) || !data2(item.init, names)) return false;
|
|
7012
|
+
names.add(item.id.name);
|
|
7013
|
+
return true;
|
|
7014
|
+
});
|
|
7015
|
+
}
|
|
7016
|
+
if (node.type === "ForOfStatement" && !node.await && data2(node.right, names)) {
|
|
7017
|
+
const binding = node.left;
|
|
7018
|
+
const id = binding?.declarations?.[0]?.id;
|
|
7019
|
+
if (binding?.type !== "VariableDeclaration" || binding.kind !== "const" || binding.declarations.length !== 1 || binding.declarations[0].init || id?.type !== "Identifier" || id.name === "console" || names.has(id.name)) return false;
|
|
7020
|
+
return statement(node.body, /* @__PURE__ */ new Set([...names, id.name]));
|
|
7021
|
+
}
|
|
7022
|
+
const call = node.type === "ExpressionStatement" ? node.expression : void 0;
|
|
7023
|
+
return call?.type === "CallExpression" && !call.optional && call.callee?.type === "MemberExpression" && !call.callee.computed && !call.callee.optional && call.callee.object?.type === "Identifier" && call.callee.object.name === "console" && call.callee.property?.name === "log" && call.arguments.every((item) => data2(item, names));
|
|
7024
|
+
};
|
|
7025
|
+
return statement(tree, /* @__PURE__ */ new Set()) ? "node -e fcd_literal_console_output" : segment;
|
|
7026
|
+
} catch {
|
|
7027
|
+
return segment;
|
|
7028
|
+
}
|
|
7029
|
+
}
|
|
6988
7030
|
function extractUrl(args, _argsText) {
|
|
6989
7031
|
const direct = args.url || args.uri || args.endpoint || args.webhookUrl || args.callbackUrl || args.href || args.link;
|
|
6990
7032
|
if (typeof direct === "string" && direct.trim()) return direct.trim();
|
|
@@ -6992,7 +7034,7 @@ function extractUrl(args, _argsText) {
|
|
|
6992
7034
|
const raw = args[field];
|
|
6993
7035
|
if (typeof raw !== "string") continue;
|
|
6994
7036
|
const shellField = ["command", "cmd", "script", "input"].includes(field);
|
|
6995
|
-
const value = shellField ? shellUrlActionText(raw) : raw;
|
|
7037
|
+
const value = shellField ? splitShellSegments(shellUrlActionText(raw)).map(withoutUnattributedJavaScriptUrls).join("\n") : raw;
|
|
6996
7038
|
const firstUrl = (text) => text.match(/https?:\/\/[^\s"'<>]+/i)?.[0] || text.match(/\b(?:s3|gs|ftp|sftp|smb):\/\/[^\s"'<>]+/i)?.[0];
|
|
6997
7039
|
const outboundUrls = shellField ? splitShellSegments(value).filter((segment) => /^(?:curl(?:\.exe)?|wget(?:\.exe)?|invoke-restmethod|invoke-webrequest|irm|iwr)$/i.test(shellSegmentLead(segment)) && classifyShellSegment(segment) === "write").map(firstUrl).filter((url) => Boolean(url)) : [];
|
|
6998
7040
|
const externalOutbound = outboundUrls.find((url) => {
|
|
@@ -35,7 +35,7 @@ export interface TaintFinding {
|
|
|
35
35
|
*/
|
|
36
36
|
export declare function taintEnabled(): boolean;
|
|
37
37
|
export declare function loadLedger(sessionId: string): TaintLedger;
|
|
38
|
-
/**
|
|
38
|
+
/** Retained API for old hook callers. Prose is not an authorization grant. */
|
|
39
39
|
export declare function recordUserPrompt(sessionId: string, text: string): void;
|
|
40
40
|
/** Mark the session as having ingested untrusted content. Reading is never blocked. */
|
|
41
41
|
export declare function markTaint(sessionId: string, source: TaintSource): void;
|
|
@@ -56,7 +56,7 @@ export declare function detectSink(event: EventKind, toolName: string, toolArgs:
|
|
|
56
56
|
* The deterministic 3-rule check, evaluated against the CURRENT ledger state
|
|
57
57
|
* (call this BEFORE recording any ingress for the same event):
|
|
58
58
|
*
|
|
59
|
-
*
|
|
59
|
+
* require approval IF session.tainted AND is_sensitive_sink
|
|
60
60
|
*/
|
|
61
61
|
export declare function checkTaintedSink(sessionId: string, event: EventKind, toolName: string, toolArgs: Record<string, unknown>): TaintFinding | undefined;
|
|
62
62
|
/** Record ingress for an event if applicable. Safe to call on every event. */
|
|
@@ -51,6 +51,8 @@ const crypto = __importStar(require("crypto"));
|
|
|
51
51
|
const fs = __importStar(require("fs"));
|
|
52
52
|
const os = __importStar(require("os"));
|
|
53
53
|
const path = __importStar(require("path"));
|
|
54
|
+
const actionPolicyEngine_1 = require("../actionPolicyEngine");
|
|
55
|
+
const deterministicGuard_1 = require("./deterministicGuard");
|
|
54
56
|
/**
|
|
55
57
|
* Deterministic taint tracking for local IDE coding agents.
|
|
56
58
|
*
|
|
@@ -60,19 +62,17 @@ const path = __importStar(require("path"));
|
|
|
60
62
|
* 1. INGRESS — when the agent reads UNTRUSTED content (web fetch, MCP tool
|
|
61
63
|
* output, a file outside the workspace), the session is marked
|
|
62
64
|
* "tainted". Reading is NEVER blocked.
|
|
63
|
-
* 2. SINK —
|
|
64
|
-
*
|
|
65
|
-
* AND the session is tainted, the action is blocked.
|
|
65
|
+
* 2. SINK — an outbound action after untrusted input requires an explicit
|
|
66
|
+
* one-time decision when this optional guard is enabled.
|
|
66
67
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
68
|
+
* This is a session-level precaution, not proof of data flow or injection.
|
|
69
|
+
* Prompt/domain mentions never grant permission. Policy checks still run after
|
|
70
|
+
* one-time approval, and plain reads are classified by the shared action engine.
|
|
70
71
|
*
|
|
71
72
|
* Everything here is pure local logic over a per-session JSON file under
|
|
72
73
|
* ~/.fullcourtdefense/taint/<sessionId>.json. No model, no network call.
|
|
73
74
|
*/
|
|
74
75
|
const LEDGER_TTL_MS = 24 * 60 * 60 * 1000; // sessions older than 24h are pruned
|
|
75
|
-
const MAX_PROMPTS = 30;
|
|
76
76
|
const MAX_SOURCES = 50;
|
|
77
77
|
/**
|
|
78
78
|
* Whether taint tracking is enabled. OPT-IN for now (FCD_TAINT_ENFORCED=1):
|
|
@@ -90,7 +90,7 @@ function taintDir() {
|
|
|
90
90
|
return path.join(os.homedir(), '.fullcourtdefense', 'taint');
|
|
91
91
|
}
|
|
92
92
|
function ledgerPath(sessionId) {
|
|
93
|
-
return path.join(taintDir(), `${
|
|
93
|
+
return path.join(taintDir(), `${crypto.createHash('sha256').update(sessionId).digest('hex')}.json`);
|
|
94
94
|
}
|
|
95
95
|
/** Make a session id safe to use as a filename. */
|
|
96
96
|
function safeSessionFile(sessionId) {
|
|
@@ -124,19 +124,28 @@ function pruneStale() {
|
|
|
124
124
|
catch { /* ignore */ }
|
|
125
125
|
}
|
|
126
126
|
function loadLedger(sessionId) {
|
|
127
|
-
|
|
127
|
+
let file = ledgerPath(sessionId);
|
|
128
128
|
try {
|
|
129
|
+
// Read old installations only after verifying the embedded session identity.
|
|
130
|
+
if (!fs.existsSync(file))
|
|
131
|
+
file = path.join(taintDir(), `${safeSessionFile(sessionId)}.json`);
|
|
129
132
|
if (!fs.existsSync(file))
|
|
130
133
|
return emptyLedger(sessionId);
|
|
131
134
|
const stat = fs.statSync(file);
|
|
132
135
|
if (Date.now() - stat.mtimeMs > LEDGER_TTL_MS)
|
|
133
136
|
return emptyLedger(sessionId);
|
|
134
137
|
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
138
|
+
if (parsed.sessionId !== sessionId)
|
|
139
|
+
return emptyLedger(sessionId);
|
|
140
|
+
const sources = Array.isArray(parsed.sources) ? parsed.sources.filter(source => source
|
|
141
|
+
&& ['mcp_output', 'web_fetch', 'external_file', 'remote_pull'].includes(source.type)
|
|
142
|
+
&& typeof source.detail === 'string' && Number.isFinite(Date.parse(source.at))
|
|
143
|
+
&& Date.parse(source.at) <= Date.now() && Date.parse(source.at) > Date.now() - LEDGER_TTL_MS).slice(-MAX_SOURCES) : [];
|
|
135
144
|
return {
|
|
136
145
|
sessionId,
|
|
137
|
-
tainted: Boolean(parsed.tainted),
|
|
138
|
-
sources
|
|
139
|
-
userPrompts:
|
|
146
|
+
tainted: Boolean(parsed.tainted) && sources.length > 0,
|
|
147
|
+
sources,
|
|
148
|
+
userPrompts: [],
|
|
140
149
|
createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : nowIso(),
|
|
141
150
|
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : nowIso(),
|
|
142
151
|
};
|
|
@@ -155,18 +164,11 @@ function saveLedger(ledger) {
|
|
|
155
164
|
}
|
|
156
165
|
catch { /* best effort — never break the hook on disk errors */ }
|
|
157
166
|
}
|
|
158
|
-
/**
|
|
167
|
+
/** Retained API for old hook callers. Prose is not an authorization grant. */
|
|
159
168
|
function recordUserPrompt(sessionId, text) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (!trimmed)
|
|
164
|
-
return;
|
|
165
|
-
const ledger = loadLedger(sessionId);
|
|
166
|
-
ledger.userPrompts.push(trimmed.slice(0, 4000));
|
|
167
|
-
if (ledger.userPrompts.length > MAX_PROMPTS)
|
|
168
|
-
ledger.userPrompts = ledger.userPrompts.slice(-MAX_PROMPTS);
|
|
169
|
-
saveLedger(ledger);
|
|
169
|
+
// Do not retain raw business prompts or infer consent from mentioned domains.
|
|
170
|
+
void sessionId;
|
|
171
|
+
void text;
|
|
170
172
|
}
|
|
171
173
|
/** Mark the session as having ingested untrusted content. Reading is never blocked. */
|
|
172
174
|
function markTaint(sessionId, source) {
|
|
@@ -237,13 +239,6 @@ function isLocalHost(host) {
|
|
|
237
239
|
|| host === '0.0.0.0'
|
|
238
240
|
|| host.endsWith('.local');
|
|
239
241
|
}
|
|
240
|
-
/** Strip a host to its registrable-ish tail (last two labels) for loose matching. */
|
|
241
|
-
function registrableDomain(host) {
|
|
242
|
-
const parts = host.split('.').filter(Boolean);
|
|
243
|
-
if (parts.length <= 2)
|
|
244
|
-
return host;
|
|
245
|
-
return parts.slice(-2).join('.');
|
|
246
|
-
}
|
|
247
242
|
function workspaceRoot(workspacePath) {
|
|
248
243
|
return workspacePath || process.env.WORKSPACE_PATH || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
249
244
|
}
|
|
@@ -258,6 +253,20 @@ function isExternalFile(filePath, workspacePath) {
|
|
|
258
253
|
return false;
|
|
259
254
|
}
|
|
260
255
|
}
|
|
256
|
+
function provenPlainRead(inferred, targets) {
|
|
257
|
+
return (['read', 'GET', 'HEAD'].includes(inferred.operation) || inferred.context['source.type'] === 'external')
|
|
258
|
+
&& inferred.context['destination.type'] !== 'external'
|
|
259
|
+
&& inferred.context['toolArgs.containsSecret'] !== 'true'
|
|
260
|
+
&& targets.length > 0 && targets.every(value => {
|
|
261
|
+
try {
|
|
262
|
+
const u = new URL(value);
|
|
263
|
+
return !u.search && !u.username && !u.password;
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
}
|
|
261
270
|
/**
|
|
262
271
|
* Classify an event as untrusted INGRESS. Returns the source to record, or
|
|
263
272
|
* undefined if this event does not ingest untrusted content.
|
|
@@ -295,7 +304,8 @@ function classifyIngress(event, toolName, toolArgs, workspacePath) {
|
|
|
295
304
|
*/
|
|
296
305
|
function detectSink(event, toolName, toolArgs) {
|
|
297
306
|
if (event === 'shell') {
|
|
298
|
-
const
|
|
307
|
+
const raw = String(toolArgs.command || toolArgs.cmd || toolArgs.script || toolArgs.input || '');
|
|
308
|
+
const command = (0, deterministicGuard_1.stripInertDataSegments)(raw);
|
|
299
309
|
if (/\bgit\s+remote\s+add\b/i.test(command)) {
|
|
300
310
|
return { kind: 'git_remote_add', targets: extractHosts(command), detail: command.slice(0, 200) };
|
|
301
311
|
}
|
|
@@ -304,6 +314,12 @@ function detectSink(event, toolName, toolArgs) {
|
|
|
304
314
|
}
|
|
305
315
|
if (NETWORK_CMD_HINT.test(command)) {
|
|
306
316
|
const targets = extractHosts(command);
|
|
317
|
+
const inferred = (0, actionPolicyEngine_1.inferToolContext)(toolName, { command });
|
|
318
|
+
// Reuse the policy engine's structural read proof. A query, userinfo or
|
|
319
|
+
// credential-bearing request still carries outbound data; keep those guarded.
|
|
320
|
+
const urls = command.match(/https?:\/\/[^\s'"<>]+/gi) || [];
|
|
321
|
+
if (provenPlainRead(inferred, urls))
|
|
322
|
+
return undefined;
|
|
307
323
|
if (targets.length > 0)
|
|
308
324
|
return { kind: 'network', targets, detail: command.slice(0, 200) };
|
|
309
325
|
}
|
|
@@ -313,33 +329,25 @@ function detectSink(event, toolName, toolArgs) {
|
|
|
313
329
|
const outboundTool = /(?:http|fetch|request|curl|wget|webhook|upload|send|email|mail|slack|discord|teams|post|put|publish|notify|sms|message)/i.test(toolName);
|
|
314
330
|
if (!outboundTool)
|
|
315
331
|
return undefined;
|
|
316
|
-
const
|
|
317
|
-
|
|
332
|
+
const inferred = (0, actionPolicyEngine_1.inferToolContext)(toolName, toolArgs);
|
|
333
|
+
// Content fields are not destinations. Only explicit request targets qualify.
|
|
334
|
+
const targetValues = ['url', 'uri', 'endpoint', 'webhookUrl', 'callbackUrl', 'destination', 'host']
|
|
335
|
+
.flatMap(key => typeof toolArgs[key] === 'string' ? [toolArgs[key]] : []);
|
|
336
|
+
const targets = targetValues.flatMap(extractHosts);
|
|
337
|
+
const carriesData = ['body', 'data', 'payload', 'json', 'headers', 'query', 'params'].some(key => toolArgs[key] !== undefined);
|
|
338
|
+
if (!carriesData && provenPlainRead(inferred, targetValues))
|
|
339
|
+
return undefined;
|
|
318
340
|
if (targets.length > 0)
|
|
319
341
|
return { kind: 'mcp_outbound', targets, detail: `${toolName} -> ${targets.join(', ')}`.slice(0, 200) };
|
|
320
342
|
return undefined;
|
|
321
343
|
}
|
|
322
344
|
return undefined;
|
|
323
345
|
}
|
|
324
|
-
/** Did the developer's prompt authorize reaching this target host? */
|
|
325
|
-
function authorizedByPrompt(ledger, targets) {
|
|
326
|
-
if (targets.length === 0)
|
|
327
|
-
return true; // no external target to authorize
|
|
328
|
-
const haystack = ledger.userPrompts.join('\n').toLowerCase();
|
|
329
|
-
if (!haystack)
|
|
330
|
-
return false;
|
|
331
|
-
// Authorized only if EVERY external target the action contacts was mentioned
|
|
332
|
-
// by the user (host or its registrable domain appears in some prompt).
|
|
333
|
-
return targets.every(host => {
|
|
334
|
-
const h = host.toLowerCase();
|
|
335
|
-
return haystack.includes(h) || haystack.includes(registrableDomain(h));
|
|
336
|
-
});
|
|
337
|
-
}
|
|
338
346
|
/**
|
|
339
347
|
* The deterministic 3-rule check, evaluated against the CURRENT ledger state
|
|
340
348
|
* (call this BEFORE recording any ingress for the same event):
|
|
341
349
|
*
|
|
342
|
-
*
|
|
350
|
+
* require approval IF session.tainted AND is_sensitive_sink
|
|
343
351
|
*/
|
|
344
352
|
function checkTaintedSink(sessionId, event, toolName, toolArgs) {
|
|
345
353
|
if (!taintEnabled())
|
|
@@ -350,15 +358,14 @@ function checkTaintedSink(sessionId, event, toolName, toolArgs) {
|
|
|
350
358
|
const ledger = loadLedger(sessionId);
|
|
351
359
|
if (!ledger.tainted)
|
|
352
360
|
return undefined;
|
|
353
|
-
if (authorizedByPrompt(ledger, sink.targets))
|
|
354
|
-
return undefined;
|
|
355
361
|
const source = ledger.sources[ledger.sources.length - 1] || { type: 'mcp_output', detail: 'untrusted content', at: nowIso() };
|
|
356
362
|
const targetText = sink.targets.join(', ') || 'an external destination';
|
|
357
363
|
return {
|
|
358
364
|
blocked: true,
|
|
359
365
|
ruleId: 'local-taint-unrequested-sink',
|
|
360
|
-
reason: `
|
|
361
|
-
+ `
|
|
366
|
+
reason: `Review ${describeSink(sink)} to ${targetText} after this session accessed untrusted content `
|
|
367
|
+
+ `(${source.type}). The opt-in session guard requires approval for this action. `
|
|
368
|
+
+ 'This sequence alone does not prove prompt injection or data theft.',
|
|
362
369
|
evidence: sink.detail,
|
|
363
370
|
source,
|
|
364
371
|
sink,
|
package/dist/version.json
CHANGED