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
- /** Record the developer's prompt text so sink actions can be checked against it. */
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
- * block IF session.tainted AND is_sensitive_sink AND NOT user_authorized
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 — when the agent later performs a SENSITIVE action (network
64
- * command, git push, new remote) that the USER never asked for,
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
- * Trust is "min-trust" (Tessera): one untrusted source drags the whole session
68
- * to the floor, after which the agent may only do what the human explicitly
69
- * authorized in their prompt — not what the ingested data suggests.
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(), `${safeSessionFile(sessionId)}.json`);
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
- const file = ledgerPath(sessionId);
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: Array.isArray(parsed.sources) ? parsed.sources.slice(-MAX_SOURCES) : [],
139
- userPrompts: Array.isArray(parsed.userPrompts) ? parsed.userPrompts.slice(-MAX_PROMPTS) : [],
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
- /** Record the developer's prompt text so sink actions can be checked against it. */
167
+ /** Retained API for old hook callers. Prose is not an authorization grant. */
159
168
  function recordUserPrompt(sessionId, text) {
160
- if (!taintEnabled())
161
- return;
162
- const trimmed = (text || '').trim();
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 command = typeof toolArgs.command === 'string' ? toolArgs.command : collectStringValues(toolArgs).join(' ');
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 strings = collectStringValues(toolArgs);
317
- const targets = strings.flatMap(extractHosts);
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
- * block IF session.tainted AND is_sensitive_sink AND NOT user_authorized
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: `Blocked ${describeSink(sink)} to ${targetText} that you did not request, after the agent `
361
- + `ingested untrusted content (${source.type}: ${source.detail}). Likely indirect prompt injection.`,
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
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.34.19"
2
+ "version": "1.34.20"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.34.19",
3
+ "version": "1.34.20",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {