fullcourtdefense-cli 1.21.38 → 1.21.40

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.
@@ -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;
@@ -272,6 +272,12 @@ function builtIn(itemId, categoryId, category, ruleId, reason, findingEvidence,
272
272
  * references these files at all.
273
273
  */
274
274
  function honeypotTouch(value, options) {
275
+ // Honeypot is a catalog item (id 'honeypot_decoy_access') so admins can see it
276
+ // in the console and, if they must, turn it off — nothing enforces invisibly.
277
+ // It is zero-FP by design (daemon-planted decoy paths only), so it defaults ON
278
+ // and disabling is discouraged, but the toggle is honored like any other rule.
279
+ if (!isEnabled('honeypot_decoy_access', options))
280
+ return undefined;
275
281
  const paths = options?.honeypotPaths;
276
282
  if (!paths || paths.length === 0)
277
283
  return undefined;
@@ -372,8 +378,16 @@ function destructiveCommandReason(value) {
372
378
  return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
373
379
  if (/\b(?:del|erase)\s+\/[a-z]*s[a-z]*\s+\/[a-z]*q[a-z]*\s+[a-z]:\\(?:\s|$)/i.test(text))
374
380
  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))
381
+ // Drive-ROOT Remove-Item only: the bare drive (C:\) must be an argument of
382
+ // the SAME Remove-Item command (no ;|& between them). The previous loose
383
+ // drive-letter match flagged ANY text containing Remove-Item -Recurse
384
+ // -Force plus an unrelated "x: " pattern — e.g. our own installer's
385
+ // legitimate cleanup script, blocking a customer's git command that merely
386
+ // referenced it.
387
+ if (lower.includes('remove-item') && lower.includes('-recurse') && lower.includes('-force')
388
+ && /remove-item\b[^;|&]{0,80}?["']?\b[a-z]:[\\/]?["']?(?=\s|$|[;&|])/i.test(text)) {
376
389
  return { itemId: 'windows_drive_delete', reason: 'recursive Windows drive delete' };
390
+ }
377
391
  if (/: *\(\) *\{ *: *\| *: *& *\} *; *:/.test(text))
378
392
  return { itemId: 'fork_bomb', reason: 'fork bomb' };
379
393
  if (/\bmkfs(?:\.[a-z0-9]+)?\s+\/dev\//i.test(text))
@@ -438,9 +452,27 @@ function infraDestroyReason(value) {
438
452
  return { itemId: 'az_group_delete_yes', reason: 'Azure resource group deletion' };
439
453
  return undefined;
440
454
  }
455
+ /**
456
+ * Local file editors (Write / StrReplace / edit_file / EditNotebook / apply_patch ...)
457
+ * write to DISK — that is not egress. Their contents/new_string arguments legitimately
458
+ * carry secret-shaped fixtures (test suites, docs, security tooling source), and the
459
+ * `content(s)` key name otherwise matches the outbound-payload heuristic, which blocked
460
+ * editing our own guard test files. Real exfiltration is still caught at the actual
461
+ * egress point (outbound/HTTP tools, shell commands), where secret rules apply unchanged.
462
+ */
463
+ function isLocalFileEditor(toolName, candidates) {
464
+ if (!/(?:write|edit|replace|patch|notebook|create)/i.test(toolName))
465
+ return false;
466
+ return candidates.some(candidate => {
467
+ const lastKey = (candidate.keyPath.split('.').pop() || '').toLowerCase();
468
+ return ['path', 'file', 'filename', 'file_path', 'filepath', 'target_file', 'target_notebook'].includes(lastKey);
469
+ });
470
+ }
441
471
  function isOutboundContext(toolName, candidates) {
442
472
  if (OUTBOUND_TOOL_HINT.test(toolName))
443
473
  return true;
474
+ if (isLocalFileEditor(toolName, candidates))
475
+ return false;
444
476
  return candidates.some(candidate => /(?:url|uri|webhook|endpoint|recipient|channel|email|to|body|payload|message|content)/i.test(candidate.keyPath));
445
477
  }
446
478
  function isCommandContext(toolName, candidate) {
@@ -485,13 +517,20 @@ function scanTextValue(toolName, value, options) {
485
517
  const custom = customBlock(value, options);
486
518
  if (custom)
487
519
  return custom;
520
+ // NOTE: a disabled item must FALL THROUGH to the later rules, not end the
521
+ // scan — otherwise turning off (or gating) a path rule would mask a real
522
+ // reverse shell / destructive command in the same value.
488
523
  const sensitivePath = containsSensitiveCredentialPath(value);
489
524
  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);
525
+ const finding = builtIn(sensitivePath.itemId, 'sensitive_files', 'sensitive_file', 'local-sensitive-credential-path', `Blocked local agent access to ${sensitivePath.label}.`, value, sensitivePath.label, options);
526
+ if (finding)
527
+ return finding;
491
528
  }
492
529
  const metadata = containsMetadataEndpoint(value);
493
530
  if (metadata) {
494
- return builtIn(metadata.itemId, 'metadata_ssrf', 'metadata_ssrf', 'local-cloud-metadata-ssrf', `Blocked request to ${metadata.label}.`, value, metadata.value, options);
531
+ const finding = builtIn(metadata.itemId, 'metadata_ssrf', 'metadata_ssrf', 'local-cloud-metadata-ssrf', `Blocked request to ${metadata.label}.`, value, metadata.value, options);
532
+ if (finding)
533
+ return finding;
495
534
  }
496
535
  const reverseShell = reverseShellReason(value);
497
536
  if (reverseShell) {
@@ -699,8 +738,48 @@ function referencedScripts(command, cwd) {
699
738
  }
700
739
  return Array.from(new Set(scripts));
701
740
  }
741
+ /**
742
+ * Verbs that make a sensitive path / metadata endpoint on a script line ACTIONABLE:
743
+ * reading, copying, uploading, or requesting it. A mere string mention (test fixture,
744
+ * detector regex, doc comment) has none of these on the same line.
745
+ */
746
+ // Collision-hardened forms: `ssh` must be a command start (NOT the `.ssh` of the
747
+ // flagged path itself), `type` the cmd.exe read form (`type file`, not `type:` in
748
+ // JSON/TS fixtures), `open` a call (`open(`), `requests.*` a python HTTP call.
749
+ 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;
750
+ /**
751
+ * A path/metadata finding inside a referenced script only counts when some LINE both
752
+ * contains the flagged pattern AND acts on it. Scripts (and their tests, and security
753
+ * tooling) routinely mention credential paths as inert strings — "found inside
754
+ * referenced script" used to block running our own guard test suite over fixtures.
755
+ */
756
+ function scriptActsOnFinding(content, category) {
757
+ const matcher = category === 'sensitive_file'
758
+ ? (line) => Boolean(containsSensitiveCredentialPath(line))
759
+ : (line) => Boolean(containsMetadataEndpoint(line));
760
+ for (const rawLine of content.split(/\r?\n/)) {
761
+ if (!rawLine || rawLine.length > 4000)
762
+ continue;
763
+ if (matcher(rawLine) && SCRIPT_ACCESS_VERB.test(rawLine))
764
+ return true;
765
+ }
766
+ return false;
767
+ }
768
+ /** Org-trusted script paths (from the Local Safety snapshot): skip content scanning. */
769
+ function isTrustedScriptPath(script, options) {
770
+ const trusted = options?.trustedScriptPaths;
771
+ if (!trusted || trusted.length === 0)
772
+ return false;
773
+ const normalized = normalizeForPath(script);
774
+ return trusted.some(entry => {
775
+ const pattern = normalizeForPath(String(entry || '')).replace(/\/+$/, '');
776
+ return pattern.length >= 3 && normalized.includes(pattern);
777
+ });
778
+ }
702
779
  function scanReferencedScript(command, cwd, options) {
703
780
  for (const script of referencedScripts(command, cwd)) {
781
+ if (isTrustedScriptPath(script, options))
782
+ continue;
704
783
  let content = '';
705
784
  if (script.startsWith('npm:')) {
706
785
  content = script;
@@ -716,7 +795,18 @@ function scanReferencedScript(command, cwd, options) {
716
795
  continue;
717
796
  }
718
797
  }
719
- const finding = scanTextValue('script_file', content, options);
798
+ // Path/metadata findings must be ACTIONABLE inside the script; suppress inert
799
+ // string mentions and rescan so command-shaped rules (reverse shell, destructive
800
+ // delete...) in the SAME file still surface.
801
+ let scanOptions = options;
802
+ let finding = scanTextValue('script_file', content, scanOptions);
803
+ while (finding
804
+ && (finding.category === 'sensitive_file' || finding.category === 'metadata_ssrf')
805
+ && !scriptActsOnFinding(content, finding.category)) {
806
+ const baseDisabled = scanOptions?.disabledBuiltInItemIds || Array.from(DEFAULT_DISABLED_BUILT_INS);
807
+ scanOptions = { ...(scanOptions || {}), disabledBuiltInItemIds: [...baseDisabled, finding.itemId] };
808
+ finding = scanTextValue('script_file', content, scanOptions);
809
+ }
720
810
  if (!finding)
721
811
  continue;
722
812
  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
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.21.38"
2
+ "version": "1.21.40"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.21.38",
3
+ "version": "1.21.40",
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,11 @@
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",
22
+ "test:catalog-toggles": "npm run build && node scripts/test-catalog-toggles.js",
19
23
  "test:honeypot": "npm run build && node scripts/test-honeypot.js",
20
24
  "test:browser-credentials-rule": "npm run build && node scripts/test-browser-credentials-rule.js",
21
25
  "test:taint-ledger": "npm run build && node scripts/test-taint-ledger.js",
@@ -54,6 +58,7 @@
54
58
  "test:policy-gate-health": "npm run build && node scripts/test-policy-gate-health.js",
55
59
  "test:offline-policy": "npm run build && node scripts/test-offline-policy-enforcement.js",
56
60
  "test:real-scenario": "npm run build && node scripts/test-real-scenario-drill.js",
61
+ "test:blocking-approval": "npm run build && node scripts/test-blocking-approval-drill.js",
57
62
  "test:clipboard-scan": "npm run build && node scripts/test-clipboard-scan.js",
58
63
  "build:msi": "powershell -NoProfile -ExecutionPolicy Bypass -File installer/windows/Build-Msi.ps1",
59
64
  "prepublishOnly": "npm run build"