fullcourtdefense-cli 1.21.37 → 1.21.39

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.
@@ -602,11 +602,21 @@ async function runDaemon(args, config) {
602
602
  .split(/\r?\n/).map(line => line.trim()).filter(Boolean).slice(-200).map(line => line.slice(0, 400));
603
603
  }
604
604
  catch { /* log unreadable — the bundle reports everything else */ }
605
+ // The hook's own log — what the developer's IDE actually experienced
606
+ // (verdicts, timeouts, fail-closed decisions). Metadata-only by default;
607
+ // content previews exist only when support opted into FCD_HOOK_DEBUG.
608
+ let hookLogTail = [];
609
+ try {
610
+ hookLogTail = fs.readFileSync(path.join(os.homedir(), '.fullcourtdefense-hook.log'), 'utf8')
611
+ .split(/\r?\n/).map(line => line.trim()).filter(Boolean).slice(-80).map(line => line.slice(0, 400));
612
+ }
613
+ catch { /* no hook activity on this machine yet */ }
605
614
  return {
606
615
  cliVersion: cliVersion(),
607
616
  platform: process.platform,
608
617
  collectedAt: new Date().toISOString(),
609
618
  daemonLogTail,
619
+ hookLogTail,
610
620
  updaterLogTail: (0, selfUpdate_1.readUpdaterLogTail)(60),
611
621
  distress: (0, distress_1.readDistressLedger)(),
612
622
  credentialTrace: (0, config_1.getCredentialResolutionTrace)(),
@@ -26,6 +26,13 @@ export interface LocalSafetyScanOptions {
26
26
  inspectScripts?: boolean;
27
27
  /** Absolute paths of planted honeypot decoy files — ANY reference blocks. */
28
28
  honeypotPaths?: string[];
29
+ /**
30
+ * Org-managed trusted script paths (substring match on the normalized path).
31
+ * Content scanning is skipped for referenced scripts under these paths — the
32
+ * admin-controlled exception for repos whose test suites legitimately contain
33
+ * attack fixtures (security tooling, guard regression tests).
34
+ */
35
+ trustedScriptPaths?: string[];
29
36
  }
30
37
  export declare function scanDeterministicToolCall(toolName: string, toolArgs: Record<string, unknown>, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
31
38
  export declare function scanDeterministicTextResponse(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
@@ -372,8 +372,16 @@ function destructiveCommandReason(value) {
372
372
  return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
373
373
  if (/\b(?:del|erase)\s+\/[a-z]*s[a-z]*\s+\/[a-z]*q[a-z]*\s+[a-z]:\\(?:\s|$)/i.test(text))
374
374
  return { itemId: 'windows_drive_delete', reason: 'recursive Windows drive delete' };
375
- if (lower.includes('remove-item') && lower.includes('-recurse') && lower.includes('-force') && /[a-z]:\\?(?:\s|$)/i.test(text))
375
+ // Drive-ROOT Remove-Item only: the bare drive (C:\) must be an argument of
376
+ // the SAME Remove-Item command (no ;|& between them). The previous loose
377
+ // drive-letter match flagged ANY text containing Remove-Item -Recurse
378
+ // -Force plus an unrelated "x: " pattern — e.g. our own installer's
379
+ // legitimate cleanup script, blocking a customer's git command that merely
380
+ // referenced it.
381
+ if (lower.includes('remove-item') && lower.includes('-recurse') && lower.includes('-force')
382
+ && /remove-item\b[^;|&]{0,80}?["']?\b[a-z]:[\\/]?["']?(?=\s|$|[;&|])/i.test(text)) {
376
383
  return { itemId: 'windows_drive_delete', reason: 'recursive Windows drive delete' };
384
+ }
377
385
  if (/: *\(\) *\{ *: *\| *: *& *\} *; *:/.test(text))
378
386
  return { itemId: 'fork_bomb', reason: 'fork bomb' };
379
387
  if (/\bmkfs(?:\.[a-z0-9]+)?\s+\/dev\//i.test(text))
@@ -438,9 +446,27 @@ function infraDestroyReason(value) {
438
446
  return { itemId: 'az_group_delete_yes', reason: 'Azure resource group deletion' };
439
447
  return undefined;
440
448
  }
449
+ /**
450
+ * Local file editors (Write / StrReplace / edit_file / EditNotebook / apply_patch ...)
451
+ * write to DISK — that is not egress. Their contents/new_string arguments legitimately
452
+ * carry secret-shaped fixtures (test suites, docs, security tooling source), and the
453
+ * `content(s)` key name otherwise matches the outbound-payload heuristic, which blocked
454
+ * editing our own guard test files. Real exfiltration is still caught at the actual
455
+ * egress point (outbound/HTTP tools, shell commands), where secret rules apply unchanged.
456
+ */
457
+ function isLocalFileEditor(toolName, candidates) {
458
+ if (!/(?:write|edit|replace|patch|notebook|create)/i.test(toolName))
459
+ return false;
460
+ return candidates.some(candidate => {
461
+ const lastKey = (candidate.keyPath.split('.').pop() || '').toLowerCase();
462
+ return ['path', 'file', 'filename', 'file_path', 'filepath', 'target_file', 'target_notebook'].includes(lastKey);
463
+ });
464
+ }
441
465
  function isOutboundContext(toolName, candidates) {
442
466
  if (OUTBOUND_TOOL_HINT.test(toolName))
443
467
  return true;
468
+ if (isLocalFileEditor(toolName, candidates))
469
+ return false;
444
470
  return candidates.some(candidate => /(?:url|uri|webhook|endpoint|recipient|channel|email|to|body|payload|message|content)/i.test(candidate.keyPath));
445
471
  }
446
472
  function isCommandContext(toolName, candidate) {
@@ -485,13 +511,20 @@ function scanTextValue(toolName, value, options) {
485
511
  const custom = customBlock(value, options);
486
512
  if (custom)
487
513
  return custom;
514
+ // NOTE: a disabled item must FALL THROUGH to the later rules, not end the
515
+ // scan — otherwise turning off (or gating) a path rule would mask a real
516
+ // reverse shell / destructive command in the same value.
488
517
  const sensitivePath = containsSensitiveCredentialPath(value);
489
518
  if (sensitivePath) {
490
- return builtIn(sensitivePath.itemId, 'sensitive_files', 'sensitive_file', 'local-sensitive-credential-path', `Blocked local agent access to ${sensitivePath.label}.`, value, sensitivePath.label, options);
519
+ const finding = builtIn(sensitivePath.itemId, 'sensitive_files', 'sensitive_file', 'local-sensitive-credential-path', `Blocked local agent access to ${sensitivePath.label}.`, value, sensitivePath.label, options);
520
+ if (finding)
521
+ return finding;
491
522
  }
492
523
  const metadata = containsMetadataEndpoint(value);
493
524
  if (metadata) {
494
- return builtIn(metadata.itemId, 'metadata_ssrf', 'metadata_ssrf', 'local-cloud-metadata-ssrf', `Blocked request to ${metadata.label}.`, value, metadata.value, options);
525
+ const finding = builtIn(metadata.itemId, 'metadata_ssrf', 'metadata_ssrf', 'local-cloud-metadata-ssrf', `Blocked request to ${metadata.label}.`, value, metadata.value, options);
526
+ if (finding)
527
+ return finding;
495
528
  }
496
529
  const reverseShell = reverseShellReason(value);
497
530
  if (reverseShell) {
@@ -699,8 +732,48 @@ function referencedScripts(command, cwd) {
699
732
  }
700
733
  return Array.from(new Set(scripts));
701
734
  }
735
+ /**
736
+ * Verbs that make a sensitive path / metadata endpoint on a script line ACTIONABLE:
737
+ * reading, copying, uploading, or requesting it. A mere string mention (test fixture,
738
+ * detector regex, doc comment) has none of these on the same line.
739
+ */
740
+ // Collision-hardened forms: `ssh` must be a command start (NOT the `.ssh` of the
741
+ // flagged path itself), `type` the cmd.exe read form (`type file`, not `type:` in
742
+ // JSON/TS fixtures), `open` a call (`open(`), `requests.*` a python HTTP call.
743
+ const SCRIPT_ACCESS_VERB = /\b(?:cat|gc|get-content|get-item|copy(?:-item)?|cp|xcopy|robocopy|move(?:-item)?|mv|scp|sftp|rsync|curl|wget|invoke-webrequest|invoke-restmethod|iwr|irm|openssl|tar|zip|7z|certutil|base64|readfile(?:sync)?|createreadstream|read_text|read_bytes|fopen|urlopen|fetch|upload|download)\b|\btype\s+\S|(?:^|[^.\w])ssh(?:-add|-keygen|\.exe)?\s|\bopen\s*\(|\brequests\.\w+\s*\(/i;
744
+ /**
745
+ * A path/metadata finding inside a referenced script only counts when some LINE both
746
+ * contains the flagged pattern AND acts on it. Scripts (and their tests, and security
747
+ * tooling) routinely mention credential paths as inert strings — "found inside
748
+ * referenced script" used to block running our own guard test suite over fixtures.
749
+ */
750
+ function scriptActsOnFinding(content, category) {
751
+ const matcher = category === 'sensitive_file'
752
+ ? (line) => Boolean(containsSensitiveCredentialPath(line))
753
+ : (line) => Boolean(containsMetadataEndpoint(line));
754
+ for (const rawLine of content.split(/\r?\n/)) {
755
+ if (!rawLine || rawLine.length > 4000)
756
+ continue;
757
+ if (matcher(rawLine) && SCRIPT_ACCESS_VERB.test(rawLine))
758
+ return true;
759
+ }
760
+ return false;
761
+ }
762
+ /** Org-trusted script paths (from the Local Safety snapshot): skip content scanning. */
763
+ function isTrustedScriptPath(script, options) {
764
+ const trusted = options?.trustedScriptPaths;
765
+ if (!trusted || trusted.length === 0)
766
+ return false;
767
+ const normalized = normalizeForPath(script);
768
+ return trusted.some(entry => {
769
+ const pattern = normalizeForPath(String(entry || '')).replace(/\/+$/, '');
770
+ return pattern.length >= 3 && normalized.includes(pattern);
771
+ });
772
+ }
702
773
  function scanReferencedScript(command, cwd, options) {
703
774
  for (const script of referencedScripts(command, cwd)) {
775
+ if (isTrustedScriptPath(script, options))
776
+ continue;
704
777
  let content = '';
705
778
  if (script.startsWith('npm:')) {
706
779
  content = script;
@@ -716,7 +789,18 @@ function scanReferencedScript(command, cwd, options) {
716
789
  continue;
717
790
  }
718
791
  }
719
- const finding = scanTextValue('script_file', content, options);
792
+ // Path/metadata findings must be ACTIONABLE inside the script; suppress inert
793
+ // string mentions and rescan so command-shaped rules (reverse shell, destructive
794
+ // delete...) in the SAME file still surface.
795
+ let scanOptions = options;
796
+ let finding = scanTextValue('script_file', content, scanOptions);
797
+ while (finding
798
+ && (finding.category === 'sensitive_file' || finding.category === 'metadata_ssrf')
799
+ && !scriptActsOnFinding(content, finding.category)) {
800
+ const baseDisabled = scanOptions?.disabledBuiltInItemIds || Array.from(DEFAULT_DISABLED_BUILT_INS);
801
+ scanOptions = { ...(scanOptions || {}), disabledBuiltInItemIds: [...baseDisabled, finding.itemId] };
802
+ finding = scanTextValue('script_file', content, scanOptions);
803
+ }
720
804
  if (!finding)
721
805
  continue;
722
806
  return {
@@ -533,6 +533,16 @@ function machineMetadata(client = 'cursor') {
533
533
  agentClient: client,
534
534
  };
535
535
  }
536
+ /**
537
+ * The developer-facing block line must answer "WHO blocked me and where do I
538
+ * see/manage it" in one glance — a bare reason string left developers unable
539
+ * to tell a built-in rule from an org policy or to find the console entry.
540
+ */
541
+ function localBlockUserMessage(finding) {
542
+ const origin = finding.source === 'custom' ? 'org custom rule' : 'built-in Local Safety rule';
543
+ return `Blocked by FullCourtDefense local guard — ${finding.reason}`
544
+ + ` [${origin} "${finding.itemId}" — logged to your org's console (machine timeline); admins manage rules under Shield → Local Safety]`;
545
+ }
536
546
  function spoolLocalFinding(input) {
537
547
  (0, telemetry_1.spoolEvent)({
538
548
  decision: 'block',
@@ -767,7 +777,7 @@ async function hookCommand(args, config) {
767
777
  return;
768
778
  }
769
779
  spoolLocalFinding({ finding: localBlock, toolName: 'prompt', operation: 'prompt' });
770
- respond(true, `Blocked by FullCourtDefense local guard — ${localBlock.reason}`, `FullCourtDefense blocked this prompt locally (${localBlock.ruleId}). Do not retry.`);
780
+ respond(true, localBlockUserMessage(localBlock), `FullCourtDefense blocked this prompt locally (${localBlock.ruleId}). Do not retry.`);
771
781
  return;
772
782
  }
773
783
  if (localOnly) {
@@ -811,7 +821,7 @@ async function enforceActionPolicy(ctx) {
811
821
  return;
812
822
  }
813
823
  spoolLocalFinding({ finding: localBlock, toolName: call.toolName, operation: event });
814
- respond(true, `Blocked by FullCourtDefense local guard — ${localBlock.reason}`, `FullCourtDefense blocked this ${event} locally (${localBlock.ruleId}). Do not retry.`);
824
+ respond(true, localBlockUserMessage(localBlock), `FullCourtDefense blocked this ${event} locally (${localBlock.ruleId}). Do not retry.`);
815
825
  return;
816
826
  }
817
827
  // --- Deterministic taint tracking (local, no backend) ---
@@ -836,7 +836,8 @@ class McpGatewayServer {
836
836
  const localBlock = (0, deterministicGuard_1.scanDeterministicToolCall)(toolName, toolArgs, (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
837
837
  if (localBlock) {
838
838
  this.spoolLocalFinding(localBlock, toolName, operation);
839
- throw new Error(`${localBlock.reason} (${localBlock.ruleId}: ${localBlock.evidence})`);
839
+ const origin = localBlock.source === 'custom' ? 'org custom rule' : 'built-in Local Safety rule';
840
+ throw new Error(`${localBlock.reason} (${origin} "${localBlock.itemId}", ${localBlock.ruleId}: ${localBlock.evidence}) — logged to your org's console; admins manage rules under Shield → Local Safety.`);
840
841
  }
841
842
  let preflight;
842
843
  try {
@@ -936,7 +937,8 @@ class McpGatewayServer {
936
937
  const localResponseBlock = (0, deterministicGuard_1.scanDeterministicTextResponse)(contentToText(rawResult), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
937
938
  if (localResponseBlock) {
938
939
  this.spoolLocalFinding(localResponseBlock, toolName, operation);
939
- throw new Error(`${localResponseBlock.reason} (${localResponseBlock.ruleId}: ${localResponseBlock.evidence})`);
940
+ const responseOrigin = localResponseBlock.source === 'custom' ? 'org custom rule' : 'built-in Local Safety rule';
941
+ throw new Error(`${localResponseBlock.reason} (${responseOrigin} "${localResponseBlock.itemId}", ${localResponseBlock.ruleId}: ${localResponseBlock.evidence}) — logged to your org's console; admins manage rules under Shield → Local Safety.`);
940
942
  }
941
943
  const finalResult = this.gatewayConfig.scanResponse
942
944
  ? await this.api.scanToolResponse({ toolName, operation, toolArgs, result: rawResult })
@@ -3,6 +3,8 @@ export interface LocalSafetySnapshot {
3
3
  policyHash: string;
4
4
  disabledBuiltInItemIds: string[];
5
5
  customBlocks: LocalSafetyCustomBlock[];
6
+ /** Org-managed trusted script paths — content scanning skipped under these. */
7
+ trustedScriptPaths?: string[];
6
8
  updatedAt?: string;
7
9
  }
8
10
  export interface LocalSafetySnapshotInput {
@@ -85,6 +85,9 @@ function toSnapshot(data) {
85
85
  explanation: typeof item.explanation === 'string' ? item.explanation : undefined,
86
86
  }))
87
87
  : [],
88
+ trustedScriptPaths: Array.isArray(data.trustedScriptPaths)
89
+ ? data.trustedScriptPaths.filter((item) => typeof item === 'string' && item.trim().length >= 3)
90
+ : undefined,
88
91
  updatedAt: typeof data.updatedAt === 'string' ? data.updatedAt : undefined,
89
92
  };
90
93
  }
@@ -132,6 +135,7 @@ function snapshotToScanOptions(snapshot, extra = {}) {
132
135
  return {
133
136
  disabledBuiltInItemIds: snapshot?.disabledBuiltInItemIds,
134
137
  customBlocks: snapshot?.customBlocks,
138
+ trustedScriptPaths: snapshot?.trustedScriptPaths,
135
139
  policyHash: snapshot?.policyHash,
136
140
  // Machine-local decoy paths planted by the daemon — every enforcement
137
141
  // surface (hooks, MCP gateway, desktop chat guard) gets honeypot
@@ -329,7 +329,7 @@ function maybeSelfUpdate(input) {
329
329
  if (attempts >= UPDATE_LOOP_THRESHOLD) {
330
330
  const updaterTail = readUpdaterLogTail(3);
331
331
  (0, distress_1.reportDistress)('self-update', distress_1.DISTRESS.UPDATE_LOOP, `target ${input.targetVersion} triggered ${attempts}x, still on ${input.currentVersion || 'unknown'}`
332
- + (updaterTail.length ? ` — updater.log: ${updaterTail[updaterTail.length - 1]}` : ' — updater.log unreadable (task may never have run)'));
332
+ + (updaterTail.length ? ` — updater.log: ${updaterTail.join(' | ')}` : ' — updater.log unreadable (task may never have run)'));
333
333
  }
334
334
  const kind = detectInstallKind();
335
335
  const result = kind === 'msi'
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.21.37"
2
+ "version": "1.21.39"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.21.37",
3
+ "version": "1.21.39",
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,7 +15,10 @@
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:drive-delete-guard": "npm run build && node scripts/test-drive-delete-guard.js",
19
+ "test:msi-stop-filter": "node scripts/test-msi-stop-filter.js",
18
20
  "test:guard-content-context": "npm run build && node scripts/test-guard-content-context.js",
21
+ "test:guard-fp-fixes": "npm run build && node scripts/test-guard-fp-fixes.js",
19
22
  "test:honeypot": "npm run build && node scripts/test-honeypot.js",
20
23
  "test:browser-credentials-rule": "npm run build && node scripts/test-browser-credentials-rule.js",
21
24
  "test:taint-ledger": "npm run build && node scripts/test-taint-ledger.js",
@@ -54,6 +57,7 @@
54
57
  "test:policy-gate-health": "npm run build && node scripts/test-policy-gate-health.js",
55
58
  "test:offline-policy": "npm run build && node scripts/test-offline-policy-enforcement.js",
56
59
  "test:real-scenario": "npm run build && node scripts/test-real-scenario-drill.js",
60
+ "test:blocking-approval": "npm run build && node scripts/test-blocking-approval-drill.js",
57
61
  "test:clipboard-scan": "npm run build && node scripts/test-clipboard-scan.js",
58
62
  "build:msi": "powershell -NoProfile -ExecutionPolicy Bypass -File installer/windows/Build-Msi.ps1",
59
63
  "prepublishOnly": "npm run build"