fullcourtdefense-cli 1.26.7 → 1.26.9

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.
@@ -233,10 +233,14 @@ function extractPromptText(p) {
233
233
  function buildToolCall(event, p) {
234
234
  switch (event) {
235
235
  case 'shell': {
236
- const command = str(p.command) || str(p.cmd);
236
+ const nested = (p.tool_input && typeof p.tool_input === 'object')
237
+ ? p.tool_input
238
+ : undefined;
239
+ const command = str(p.command) || str(p.cmd) || str(p.commandLine)
240
+ || str(nested?.command) || str(nested?.cmd);
237
241
  if (!command)
238
242
  return null;
239
- return { toolName: 'shell', toolArgs: { command, cwd: str(p.cwd) || undefined } };
243
+ return { toolName: 'shell', toolArgs: { command, cwd: str(p.cwd) || str(nested?.cwd) || undefined } };
240
244
  }
241
245
  case 'mcp': {
242
246
  const toolName = str(p.tool_name) || str(p.toolName) || str(p.name) || 'mcp';
@@ -288,6 +292,15 @@ function summarizeToolCall(toolName, toolArgs) {
288
292
  }
289
293
  return parts.join(' ').slice(0, 500);
290
294
  }
295
+ function spoolCallEvent(call, rest) {
296
+ const { operation } = (0, actionPolicyEngine_1.inferToolContext)(call.toolName, call.toolArgs);
297
+ (0, telemetry_1.spoolEvent)({
298
+ ...rest,
299
+ toolName: call.toolName,
300
+ operation: rest.operation || operation,
301
+ evidence: rest.evidence || (0, telemetry_1.evidenceFromToolArgs)(call.toolArgs),
302
+ });
303
+ }
291
304
  function safeJson(v) {
292
305
  try {
293
306
  return JSON.stringify(v);
@@ -409,18 +422,18 @@ function tryLocalPolicyEnforcement(ctx, call, detail, opts = {}) {
409
422
  if (ctx.shadow) {
410
423
  // Truth-in-reporting: the action RAN — 'warn' (advisory), never 'allow'
411
424
  // (which hides the would-block from the console) and never 'block'.
412
- (0, telemetry_1.spoolEvent)({ decision: 'warn', toolName: call.toolName, reason: `[monitor] local policy would ${local.verdict} (offline): ${reason}`, offlineEnforced: true });
425
+ spoolCallEvent(call, { decision: 'warn', reason: `[monitor] local policy would ${local.verdict} (offline): ${reason}`, offlineEnforced: true });
413
426
  (0, telemetry_1.triggerFlush)(false);
414
427
  ctx.respond(false, undefined, `[FullCourtDefense shadow] would ${local.verdict === 'block' ? 'block' : 'require approval for'} ${call.toolName} (offline, locally cached policy): ${reason}`);
415
428
  }
416
429
  const approvalNote = local.verdict === 'require_approval'
417
430
  ? ' This action requires human approval, which is not possible while the policy service is unreachable.'
418
431
  : '';
419
- (0, telemetry_1.spoolEvent)({ decision: 'block', toolName: call.toolName, reason: `local policy (offline): ${reason}`, offlineEnforced: true });
432
+ spoolCallEvent(call, { decision: 'block', reason: `local policy (offline): ${reason}`, offlineEnforced: true });
420
433
  (0, telemetry_1.triggerFlush)(true);
421
434
  ctx.respond(true, `Blocked by FullCourtDefense${local.policyName ? ` (${local.policyName})` : ''} — enforced locally while the policy service is unreachable.${approvalNote}`, `FullCourtDefense blocked this ${ctx.event} using the locally cached org policy${local.policyName ? ` "${local.policyName}"` : ''} (${detail}).${approvalNote} Do not retry until the connection is restored or the policy allows it.`);
422
435
  }
423
- (0, telemetry_1.spoolEvent)({ decision: 'allow', toolName: call.toolName, reason: `local policy allow (offline): ${detail}`, offlineEnforced: true });
436
+ spoolCallEvent(call, { decision: 'allow', reason: `local policy allow (offline): ${detail}`, offlineEnforced: true });
424
437
  (0, telemetry_1.triggerFlush)(false);
425
438
  ctx.respond(false, undefined, `FullCourtDefense: policy service unreachable — this ${ctx.event} was checked against the locally cached org policies and allowed.`);
426
439
  }
@@ -1017,7 +1030,7 @@ async function hookCommandInner(args, config, io) {
1017
1030
  async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
1018
1031
  const { respond } = ctx;
1019
1032
  const hardBlock = () => {
1020
- (0, telemetry_1.spoolEvent)({ decision: 'block', toolName: call.toolName, reason: taintFinding.reason, ruleId: taintFinding.ruleId, offlineEnforced: true });
1033
+ spoolCallEvent(call, { decision: 'block', reason: taintFinding.reason, ruleId: taintFinding.ruleId, offlineEnforced: true });
1021
1034
  (0, telemetry_1.triggerFlush)(true);
1022
1035
  respond(true, `Blocked by FullCourtDefense taint guard — ${taintFinding.reason}`, `FullCourtDefense blocked this ${event} locally (${taintFinding.ruleId}): ${taintFinding.reason} Do not retry.`);
1023
1036
  return false;
@@ -1055,9 +1068,8 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
1055
1068
  const outcome = await (0, taintLedger_1.waitForTaintApproval)(approval.id, timeoutMs, Math.max(750, Math.min(ctx.approvalPollMs, 2000)));
1056
1069
  (0, hookIo_1.dbg)({ phase: 'taint_approve_once_outcome', approvalId: approval.id, outcome });
1057
1070
  if (outcome === 'approved') {
1058
- (0, telemetry_1.spoolEvent)({
1071
+ spoolCallEvent(call, {
1059
1072
  decision: 'allow',
1060
- toolName: call.toolName,
1061
1073
  reason: `Developer approved once after taint-guard hold (${taintFinding.ruleId}): ${taintFinding.reason}`,
1062
1074
  ruleId: taintFinding.ruleId,
1063
1075
  offlineEnforced: true,
@@ -1066,9 +1078,8 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
1066
1078
  (0, notify_1.notifyOs)({ title: 'FullCourtDefense — approved once', message: `${call.toolName} -> ${targetText} will continue.` });
1067
1079
  return true;
1068
1080
  }
1069
- (0, telemetry_1.spoolEvent)({
1081
+ spoolCallEvent(call, {
1070
1082
  decision: 'block',
1071
- toolName: call.toolName,
1072
1083
  reason: `${taintFinding.reason} Developer ${outcome === 'denied' ? 'denied the approve-once request' : 'did not respond to the approve-once request'}.`,
1073
1084
  ruleId: taintFinding.ruleId,
1074
1085
  offlineEnforced: true,
@@ -1087,6 +1098,7 @@ async function enforceActionPolicy(ctx) {
1087
1098
  respond(false);
1088
1099
  return;
1089
1100
  }
1101
+ (0, telemetry_1.setVerdictToolArgs)(call.toolArgs);
1090
1102
  const snapshot = await (0, localSafetySnapshot_1.loadLocalSafetySnapshot)({
1091
1103
  apiUrl,
1092
1104
  shieldId,
@@ -1124,7 +1136,7 @@ async function enforceActionPolicy(ctx) {
1124
1136
  respond(false, undefined, `[FullCourtDefense shadow] would block ${call.toolName}: ${selfApproval.reason}`);
1125
1137
  return;
1126
1138
  }
1127
- (0, telemetry_1.spoolEvent)({ decision: 'block', toolName: call.toolName, reason: selfApproval.reason, ruleId: 'local-taint-self-approval', offlineEnforced: true });
1139
+ spoolCallEvent(call, { decision: 'block', reason: selfApproval.reason, ruleId: 'local-taint-self-approval', offlineEnforced: true });
1128
1140
  (0, telemetry_1.triggerFlush)(true);
1129
1141
  respond(true, `Blocked by FullCourtDefense self-protection — ${selfApproval.reason}`, `FullCourtDefense blocked this ${event} locally (local-taint-self-approval): ${selfApproval.reason} Do not retry.`);
1130
1142
  return;
@@ -1164,7 +1176,7 @@ async function enforceActionPolicy(ctx) {
1164
1176
  (0, hookIo_1.dbg)({ phase: 'policy_skip_monitor', event, tool: call.toolName });
1165
1177
  const detail = 'monitor mode — synchronous policy check skipped by design.';
1166
1178
  if (!tryLocalPolicyEnforcement(ctx, call, detail, { countAsGateFailure: false })) {
1167
- (0, telemetry_1.spoolEvent)({ decision: 'allow', toolName: call.toolName, reason: `monitor: ${detail}`, offlineEnforced: true });
1179
+ spoolCallEvent(call, { decision: 'allow', reason: `monitor: ${detail}`, offlineEnforced: true });
1168
1180
  (0, telemetry_1.triggerFlush)(false);
1169
1181
  respond(false);
1170
1182
  }
@@ -1210,7 +1222,7 @@ async function enforceActionPolicy(ctx) {
1210
1222
  void fetch(gateUrl, { method: 'POST', headers, body: gateBody, signal: AbortSignal.timeout(4000) })
1211
1223
  .then(() => (0, policyGateHealth_1.recordGateSuccess)())
1212
1224
  .catch(() => {
1213
- (0, telemetry_1.spoolEvent)({ decision: 'allow', toolName: call.toolName, reason: 'local policy allow (block mode, local-first); gate report failed — spooled', offlineEnforced: true });
1225
+ spoolCallEvent(call, { decision: 'allow', reason: 'local policy allow (block mode, local-first); gate report failed — spooled', offlineEnforced: true });
1214
1226
  (0, telemetry_1.triggerFlush)(false);
1215
1227
  });
1216
1228
  respond(false);
@@ -1218,7 +1230,7 @@ async function enforceActionPolicy(ctx) {
1218
1230
  }
1219
1231
  if (localVerdict.verdict === 'block') {
1220
1232
  const reason = localVerdict.reason || `${call.toolName}: blocked by org Action Policy`;
1221
- (0, telemetry_1.spoolEvent)({ decision: 'block', toolName: call.toolName, reason: `local policy (block mode, local-first): ${reason}`, offlineEnforced: true });
1233
+ spoolCallEvent(call, { decision: 'block', reason: `local policy (block mode, local-first): ${reason}`, offlineEnforced: true });
1222
1234
  (0, telemetry_1.triggerFlush)(true);
1223
1235
  respond(true, `Blocked by FullCourtDefense${localVerdict.policyName ? ` (${localVerdict.policyName})` : ''} — ${reason}`, `FullCourtDefense blocked this ${event} by org Action Policy${localVerdict.policyName ? ` "${localVerdict.policyName}"` : ''}: ${reason}. This is a policy decision, not an error — do not retry.`);
1224
1236
  return;
@@ -1009,6 +1009,7 @@ class McpGatewayServer {
1009
1009
  if (!toolName)
1010
1010
  throw new Error('Missing MCP tool name.');
1011
1011
  const toolArgs = params.arguments && typeof params.arguments === 'object' ? params.arguments : {};
1012
+ (0, telemetry_1.setVerdictToolArgs)(toolArgs);
1012
1013
  let approvalActionId;
1013
1014
  let operation = typeof params.operation === 'string' && params.operation.trim() ? params.operation.trim() : undefined;
1014
1015
  // Tracks the downstream call state so the top-level catch can fail open
@@ -36,9 +36,20 @@ export interface SpoolEvent {
36
36
  * - `fail_open` the hook crashed and allowed by contract
37
37
  */
38
38
  export type VerdictPath = 'ipc' | 'local' | 'fail_open' | 'gateway';
39
+ /**
40
+ * Compact JSON of the command/path/query/args the agent attempted. The console
41
+ * Details column renders this as labeled Command / Query / URL / File / Args
42
+ * rows. Truncated to 500 chars so it survives backend sanitize().
43
+ *
44
+ * Known keys are preferred. Other primitive args (action, selector, prompt, …)
45
+ * are included so MCP tools that are not a shell/file still show what ran.
46
+ */
47
+ export declare function evidenceFromToolArgs(toolArgs: Record<string, unknown> | undefined): string | undefined;
39
48
  interface VerdictTiming {
40
49
  startedAt: number;
41
50
  path: VerdictPath;
51
+ /** Tool args for this evaluation — stamps `evidence` onto every spooled event. */
52
+ toolArgs?: Record<string, unknown>;
42
53
  }
43
54
  /** Run one hook evaluation with timing attached to everything it spools. */
44
55
  export declare function markVerdictTiming<T>(timing: VerdictTiming, fn: () => T): T;
@@ -47,6 +58,8 @@ export declare function markVerdictTiming<T>(timing: VerdictTiming, fn: () => T)
47
58
  * Keeps the original start time so latency still covers the full wait.
48
59
  */
49
60
  export declare function setVerdictPath(path: VerdictPath): void;
61
+ /** Attach the current tool call's args so every spool in this evaluation carries the command. */
62
+ export declare function setVerdictToolArgs(toolArgs: Record<string, unknown>): void;
50
63
  /**
51
64
  * Restart the current evaluation's clock. Used by the MCP gateway right after
52
65
  * the downstream tool returns: everything spooled from then on (response
package/dist/telemetry.js CHANGED
@@ -33,8 +33,10 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.evidenceFromToolArgs = evidenceFromToolArgs;
36
37
  exports.markVerdictTiming = markVerdictTiming;
37
38
  exports.setVerdictPath = setVerdictPath;
39
+ exports.setVerdictToolArgs = setVerdictToolArgs;
38
40
  exports.restartVerdictTiming = restartVerdictTiming;
39
41
  exports.spoolEvent = spoolEvent;
40
42
  exports.flushSpool = flushSpool;
@@ -67,6 +69,67 @@ const MAX_BATCH = 200;
67
69
  const MAX_SPOOL_EVENTS = 2_000;
68
70
  /** Cheap pre-check: skip the trim parse entirely until the file is plausibly over cap. */
69
71
  const SPOOL_TRIM_BYTES = 2 * 1024 * 1024;
72
+ /** Args keys that carry the action a human wants to see in the console. */
73
+ const ACTION_EVIDENCE_KEYS = [
74
+ 'command', 'cmd', 'commandLine', 'script', 'query', 'sql', 'url',
75
+ 'path', 'file_path', 'filePath', 'file', 'target',
76
+ 'pattern', 'glob', 'search', 'q',
77
+ ];
78
+ /** Noise / secrets — never stamp these as activity evidence. */
79
+ const EVIDENCE_SKIP_KEYS = /^(cwd|pwd|env|environment|headers|cookie|cookies|authorization|password|passwd|secret|token|api[_-]?key|private[_-]?key|session|retryCount|timeoutMs|timeout)$/i;
80
+ function stringifyEvidenceValue(value) {
81
+ if (typeof value === 'string' && value.trim())
82
+ return value.trim().slice(0, 480);
83
+ if (typeof value === 'number' && Number.isFinite(value))
84
+ return String(value);
85
+ if (typeof value === 'boolean')
86
+ return value ? 'true' : 'false';
87
+ if (Array.isArray(value) || (value && typeof value === 'object')) {
88
+ try {
89
+ const json = JSON.stringify(value);
90
+ return json && json !== '{}' && json !== '[]' ? json.slice(0, 240) : undefined;
91
+ }
92
+ catch {
93
+ return undefined;
94
+ }
95
+ }
96
+ return undefined;
97
+ }
98
+ /**
99
+ * Compact JSON of the command/path/query/args the agent attempted. The console
100
+ * Details column renders this as labeled Command / Query / URL / File / Args
101
+ * rows. Truncated to 500 chars so it survives backend sanitize().
102
+ *
103
+ * Known keys are preferred. Other primitive args (action, selector, prompt, …)
104
+ * are included so MCP tools that are not a shell/file still show what ran.
105
+ */
106
+ function evidenceFromToolArgs(toolArgs) {
107
+ if (!toolArgs)
108
+ return undefined;
109
+ const picked = {};
110
+ for (const key of ACTION_EVIDENCE_KEYS) {
111
+ const text = stringifyEvidenceValue(toolArgs[key]);
112
+ if (text)
113
+ picked[key] = text;
114
+ }
115
+ for (const [key, value] of Object.entries(toolArgs)) {
116
+ if (picked[key] || EVIDENCE_SKIP_KEYS.test(key))
117
+ continue;
118
+ if (Object.keys(picked).length >= 8)
119
+ break;
120
+ const text = stringifyEvidenceValue(value);
121
+ if (text)
122
+ picked[key] = text;
123
+ }
124
+ if (Object.keys(picked).length === 0)
125
+ return undefined;
126
+ try {
127
+ return JSON.stringify(picked).slice(0, 500);
128
+ }
129
+ catch {
130
+ return Object.values(picked)[0]?.slice(0, 500);
131
+ }
132
+ }
70
133
  /**
71
134
  * Per-evaluation timing context.
72
135
  *
@@ -93,6 +156,12 @@ function setVerdictPath(path) {
93
156
  if (store)
94
157
  store.path = path;
95
158
  }
159
+ /** Attach the current tool call's args so every spool in this evaluation carries the command. */
160
+ function setVerdictToolArgs(toolArgs) {
161
+ const store = verdictTiming.getStore();
162
+ if (store)
163
+ store.toolArgs = toolArgs;
164
+ }
96
165
  /**
97
166
  * Restart the current evaluation's clock. Used by the MCP gateway right after
98
167
  * the downstream tool returns: everything spooled from then on (response
@@ -125,7 +194,7 @@ function spoolEvent(event) {
125
194
  itemId: event.itemId,
126
195
  severity: event.severity,
127
196
  source: event.source,
128
- evidence: event.evidence,
197
+ evidence: event.evidence || evidenceFromToolArgs(timing?.toolArgs),
129
198
  explanation: event.explanation,
130
199
  policyHash: event.policyHash,
131
200
  offlineEnforced: event.offlineEnforced,
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.26.7"
2
+ "version": "1.26.9"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.26.7",
3
+ "version": "1.26.9",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {