fullcourtdefense-cli 1.25.3 → 1.25.5

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.
@@ -112,19 +112,26 @@ function batQuote(value) {
112
112
  function buildGuardJs(nodePath, cliEntry) {
113
113
  return [
114
114
  `'use strict';`,
115
- `const fs=require('fs');const os=require('os');const path=require('path');const{spawnSync}=require('child_process');const crypto=require('crypto');`,
115
+ `const fs=require('fs');const os=require('os');const path=require('path');const{spawn,spawnSync}=require('child_process');const crypto=require('crypto');`,
116
116
  `const RULES_PATH=${JSON.stringify(GUARD_RULES_PATH)};`,
117
117
  `const SPOOL_PATH=${JSON.stringify(path.join(os.homedir(), '.fullcourtdefense-spool.jsonl'))};`,
118
118
  `const NODE_PATH=${JSON.stringify(nodePath)};`,
119
119
  `const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
120
120
  `function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,action:r.action==='warn'?'warn':'block',re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='block'?'block':'monitor',rules};}catch{return{mode:'monitor',rules:[]};}}`,
121
- `function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();let warnHit=null;for(const r of rules){try{if(r.re.test(n)||r.re.test(line)){if(r.action!=='warn')return r;if(!warnHit)warnHit=r;}}catch{}}return warnHit;}`,
122
- `function spoolEvent(rule,line,decision){try{const ev=line.trim();const evidence=ev.length>180?ev.slice(0,180)+'...':ev;const event={eventId:crypto.randomUUID(),type:'verdict',decision,toolName:'cmd_terminal',operation:'shell_command',reason:'Shell guard: '+rule.reason,ruleId:rule.id,category:rule.category,severity:rule.severity,source:rule.source,evidence,occurredAt:new Date().toISOString()};fs.appendFileSync(SPOOL_PATH,JSON.stringify(event)+'\\n',{encoding:'utf8',mode:0o600});if(fs.existsSync(NODE_PATH)&&fs.existsSync(CLI_ENTRY)){spawnSync(NODE_PATH,[CLI_ENTRY,'flush-spool','--heartbeat','true'],{stdio:'ignore',windowsHide:true});}}catch{}}`,
121
+ `// Execution view (aligned with the CLI's stripInertDataSegments): quoted spans containing whitespace are`,
122
+ `// prose (inert) unless in a code position — interpreter -c/-Command flags, remote executors (ssh/wsl/docker`,
123
+ `// exec), $( ) substitution, or real backtick command substitution (>=2 chars; a 1-char pair is a PS escape).`,
124
+ `function execView(line){if(/\\b(?:iex|invoke-expression|invoke-command)\\b|\\beval\\b|\\bxargs\\b|\\bbase64\\b[^\\n]*(?:-d\\b|--decode\\b)|frombase64string|-encodedcommand\\b|\\s-enc\\s|\\|\\s*&?\\s*(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\\.exe)?|cmd(?:\\.exe)?|node|python[0-9.]*|perl|ruby)\\b|\\b(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\\.exe)?|cmd(?:\\.exe)?|node|python[0-9.]*|perl|ruby)\\s+(?:-c|\\/c|-e|-command|-scriptblock)\\b|\\b(?:ssh|wsl(?:\\.exe)?|chroot)\\b|\\b(?:docker|kubectl|podman|nerdctl)(?:\\.exe)?\\s+(?:exec|run)\\b/i.test(line))return line;const K=/\\$\\(|\`[^\`]{2,}\`/;const C=/(?:(?:^|[;&|(]|\\s)(?:-c|\\/c|-e|-command|-scriptblock|-filter)[= ]\\s*|(?:^|[;&|(]\\s*)(?:\\S*[\\\\\\/])?(?:sudo\\s+|doas\\s+)?(?:ssh|wsl(?:\\.exe)?|chroot|su|screen|tmux)(?:\\s[^;&|"']*)?\\s|(?:\\S*[\\\\\\/])?(?:docker|kubectl|podman|nerdctl)(?:\\.exe)?\\s+(?:exec|run)\\b[^;&|"']*\\s)$/i;let o=line;o=o.replace(/@'[\\s\\S]*?'@/g,' fcd_inert_data ');o=o.replace(/@"[\\s\\S]*?"@/g,m=>m.includes('$(')?m:' fcd_inert_data ');o=o.replace(/<<-?\\s*'(\\w+)'[\\s\\S]*?(?:\\n\\1\\b|$)/g,' fcd_inert_data ');o=o.replace(/<<-?\\s*"?(\\w+)"?[\\s\\S]*?(?:\\n\\1\\b|$)/g,m=>K.test(m)?m:' fcd_inert_data ');const s=o;o=s.replace(/"((?:[^"\\\\]|\\\\.)*)"|'((?:[^'\\\\]|\\\\.)*)'/g,(w,dq,sq,i)=>{const b=dq!==undefined?dq:(sq||'');if(!/\\s/.test(b))return w;if(C.test(s.slice(Math.max(0,i-80),i)))return w;if(dq!==undefined&&K.test(b))return w;return ' fcd_inert_data ';});return o;}`,
125
+ `function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();const x=execView(line);const xn=x.replace(/\\s+/g,' ').trim();let warnHit=null;for(const r of rules){try{const t=r.source==='custom'?line:x;const tn=r.source==='custom'?n:xn;if(r.re.test(tn)||r.re.test(t)){if(r.action!=='warn')return r;if(!warnHit)warnHit=r;}}catch{}}return warnHit;}`,
126
+ `// Flush is DETACHED (spawn+unref, matching the posix guard) — a spawnSync here`,
127
+ `// made the user's terminal wait for a full CLI process INCLUDING the network`,
128
+ `// flush after every finding. Monitor mode must never add synchronous latency.`,
129
+ `function spoolEvent(rule,line,decision){try{const ev=line.trim();const evidence=ev.length>180?ev.slice(0,180)+'...':ev;const event={eventId:crypto.randomUUID(),type:'verdict',decision,toolName:'cmd_terminal',operation:'shell_command',reason:'Shell guard: '+rule.reason,ruleId:rule.id,category:rule.category,severity:rule.severity,source:rule.source,evidence,occurredAt:new Date().toISOString()};fs.appendFileSync(SPOOL_PATH,JSON.stringify(event)+'\\n',{encoding:'utf8',mode:0o600});if(fs.existsSync(NODE_PATH)&&fs.existsSync(CLI_ENTRY)){const c=spawn(NODE_PATH,[CLI_ENTRY,'flush-spool','--heartbeat','true'],{detached:true,stdio:'ignore',windowsHide:true});c.unref();}}catch{}}`,
123
130
  `function delegate(args){const r=spawnSync(process.env.ComSpec||'cmd.exe',['/d','/c',...args],{stdio:'inherit',windowsHide:true});process.exit(typeof r.status==='number'?r.status:1);}`,
124
131
  `const args=process.argv.slice(2);if(!args.length)process.exit(0);if(process.env.FCD_CMD_GUARD==='off')delegate(args);`,
125
132
  `const line=args.join(' ');const{mode,rules}=loadRules();const hit=matchLine(line,rules);`,
126
133
  `if(!hit)delegate(args);`,
127
- `if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'allow');delegate(args);}`,
134
+ `if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'warn');delegate(args);}`,
128
135
  `if(hit.action==='warn'){console.error('[FullCourtDefense] warning ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+') — allowed by org policy, reported to your security dashboard.');spoolEvent(hit,line,'warn');delegate(args);}`,
129
136
  `console.error('[FullCourtDefense] BLOCKED ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
130
137
  `console.error('This command was not executed. Reported to your security dashboard.');`,
@@ -55,6 +55,7 @@ export interface LocalSafetyScanOptions {
55
55
  */
56
56
  trustedScriptPaths?: string[];
57
57
  }
58
+ export declare function stripInertDataSegments(text: string): string;
58
59
  export declare function scanDeterministicToolCall(toolName: string, toolArgs: Record<string, unknown>, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
59
60
  export declare function scanDeterministicTextResponse(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
60
61
  export declare function scanDeterministicPrompt(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.stripInertDataSegments = stripInertDataSegments;
36
37
  exports.scanDeterministicToolCall = scanDeterministicToolCall;
37
38
  exports.scanDeterministicTextResponse = scanDeterministicTextResponse;
38
39
  exports.scanDeterministicPrompt = scanDeterministicPrompt;
@@ -615,10 +616,12 @@ function credentialCommandReason(value) {
615
616
  function destructiveCommandReason(value) {
616
617
  return matchCommand(value, (text, lower) => {
617
618
  // Flag cluster is order-independent (`-rf`, `-fr`, `-rfv`), and the target
618
- // may be the root `/`, the root wildcard `/*`, or a bare `*`.
619
- if (/\brm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+(?:["']?\/\*?["']?|\*)(?:\s|$|[;&|])/.test(lower))
619
+ // may be the root `/`, the root wildcard `/*`, or a bare `*`. Terminators
620
+ // include `)` so command substitution — `$(rm -rf /)` runs BEFORE the
621
+ // outer command — is caught too.
622
+ if (/\brm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+(?:["']?\/\*?["']?|\*)(?:\s|$|[;&|)])/.test(lower))
620
623
  return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
621
- if (/\bsudo\s+rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+(?:["']?\/\*?["']?|\*)(?:\s|$|[;&|])/.test(lower))
624
+ if (/\bsudo\s+rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+(?:["']?\/\*?["']?|\*)(?:\s|$|[;&|)])/.test(lower))
622
625
  return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
623
626
  // Windows recursive quiet drive delete via del/erase/rd/rmdir. Flag order
624
627
  // is independent (`/s /q` and `/q /s` both wipe), and the dequoted variant
@@ -779,13 +782,81 @@ function isSensitivePathContext(toolName, candidate) {
779
782
  const lastKey = candidate.keyPath.split('.').pop() || '';
780
783
  return /(?:path|file|filename|dir|directory|target|source|src|dest|location|glob|pattern|uri|url)/i.test(lastKey);
781
784
  }
782
- function scanTextValue(toolName, value, options) {
783
- const honeypot = honeypotTouch(value, options);
785
+ // ---- execution view: message/document DATA inside a command is inert ---------
786
+ // Industry-standard model (Sigma / EDR command-line parsing): detection matches
787
+ // what EXECUTES — the command word and its argv — never free text riding inside
788
+ // a data argument. Quoted spans containing whitespace are prose (commit
789
+ // messages, issue/PR bodies, file contents via Set-Content/-Value, docs) and
790
+ // are inert BY DEFAULT; code positions are the enumerated exception. This
791
+ // inverts the old text-sink flag allowlist (-m/--body/-Value…), which lost
792
+ // every time a new data flag appeared — code sinks are a small stable set,
793
+ // data flags are infinite.
794
+ //
795
+ // Safety invariants (false-negative guards):
796
+ // 1. NOTHING is stripped when the text can feed an interpreter (| sh, iex,
797
+ // eval, xargs, base64 -d, -EncodedCommand, FromBase64String) or invoke an
798
+ // interpreter code flag (bash -c, python -c) or a remote/nested executor
799
+ // (ssh, wsl, chroot, docker|kubectl|podman exec/run). A whole-line bailout
800
+ // is always FN-safe and also defeats quote-splitting obfuscation
801
+ // (`wsl -e bash -c ""r""m"…`) that per-span reasoning cannot survive.
802
+ // 2. A double-quoted span containing $( … ) or real backtick command
803
+ // substitution is kept — substitution executes BEFORE the outer command.
804
+ // A single-char backtick pair (`n, `t) is a PowerShell ESCAPE, not code.
805
+ // 3. Single-token quoted spans (rm -rf "/") are argv, not prose — kept.
806
+ // 4. Quoted spans in a CODE POSITION (interpreter -c/-Command/-e flags, or a
807
+ // remote executor earlier in the same statement) are kept.
808
+ // 5. Secret-literal rules and honeypot/custom rules never use this view —
809
+ // a token pasted into a commit message still leaks.
810
+ const DATA_TO_INTERPRETER = /\b(?:iex|invoke-expression|invoke-command)\b|\beval\b|\bxargs\b|\bbase64\b[^\n]*(?:-d\b|--decode\b)|frombase64string|-encodedcommand\b|\s-enc\s|\|\s*&?\s*(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\.exe)?|cmd(?:\.exe)?|node|python[0-9.]*|perl|ruby)\b|\b(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\.exe)?|cmd(?:\.exe)?|node|python[0-9.]*|perl|ruby)\s+(?:-c|\/c|-e|-command|-scriptblock)\b|\b(?:ssh|wsl(?:\.exe)?|chroot)\b|\b(?:docker|kubectl|podman|nerdctl)(?:\.exe)?\s+(?:exec|run)\b/i;
811
+ // $( … ) always executes; backtick pairs only with ≥2 chars of content
812
+ // (PowerShell escapes like `n / `t are single-char and inert).
813
+ const INTERPOLATES = /\$\(|`[^`]{2,}`/;
814
+ const INERT_DATA = ' fcd_inert_data ';
815
+ // Code position immediately before a quoted span: interpreter code flags or a
816
+ // remote/nested executor earlier in the same statement (no ; & | between).
817
+ const CODE_CONTEXT_BEFORE = /(?:(?:^|[;&|(]|\s)(?:-c|\/c|-e|-command|-scriptblock|-filter)[= ]\s*|(?:^|[;&|(]\s*)(?:\S*[\\/])?(?:sudo\s+|doas\s+)?(?:ssh|wsl(?:\.exe)?|chroot|su|screen|tmux)(?:\s[^;&|"']*)?\s|(?:\S*[\\/])?(?:docker|kubectl|podman|nerdctl)(?:\.exe)?\s+(?:exec|run)\b[^;&|"']*\s)$/i;
818
+ function stripInertDataSegments(text) {
819
+ if (DATA_TO_INTERPRETER.test(text))
820
+ return text;
821
+ let out = text;
822
+ // PowerShell here-strings: @'…'@ is fully literal; @"…"@ interpolates $( ).
823
+ out = out.replace(/@'[\s\S]*?'@/g, INERT_DATA);
824
+ out = out.replace(/@"[\s\S]*?"@/g, m => (m.includes('$(') ? m : INERT_DATA));
825
+ // POSIX heredocs: quoted delimiter (<<'EOF') is literal; unquoted interpolates.
826
+ out = out.replace(/<<-?\s*'(\w+)'[\s\S]*?(?:\n\1\b|$)/g, INERT_DATA);
827
+ out = out.replace(/<<-?\s*"?(\w+)"?[\s\S]*?(?:\n\1\b|$)/g, m => (INTERPOLATES.test(m) ? m : INERT_DATA));
828
+ // Generic quoted-span pass: whitespace inside quotes ⇒ free text ⇒ inert,
829
+ // unless the span sits in a code position (invariants 2–4 above).
830
+ const src = out;
831
+ out = src.replace(/"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'/g, (whole, dq, sq, offset) => {
832
+ const body = dq !== undefined ? dq : (sq || '');
833
+ if (!/\s/.test(body))
834
+ return whole; // single token: argv, not prose
835
+ const before = src.slice(Math.max(0, offset - 80), offset);
836
+ if (CODE_CONTEXT_BEFORE.test(before))
837
+ return whole; // code position: executes
838
+ if (dq !== undefined && INTERPOLATES.test(body))
839
+ return whole; // $( )/`cmd` executes first
840
+ return INERT_DATA;
841
+ });
842
+ return out;
843
+ }
844
+ function scanTextValue(toolName, rawValue, options) {
845
+ // Honeypot decoys and org custom patterns scan the RAW value: a decoy path
846
+ // even inside message data is exfiltration staging, and custom rules define
847
+ // their own scope.
848
+ const honeypot = honeypotTouch(rawValue, options);
784
849
  if (honeypot)
785
850
  return honeypot;
786
- const custom = customBlock(value, options);
851
+ const custom = customBlock(rawValue, options);
787
852
  if (custom)
788
853
  return custom;
854
+ // Every rule below asks "can this text ACT?" — so it sees the execution
855
+ // view: quoted message/document data (commit -m, issue --body, here-strings,
856
+ // heredocs) is inert unless it can flow into an interpreter (see
857
+ // stripInertDataSegments invariants). This is the generic FP guard for
858
+ // "wrote ABOUT a dangerous command" vs "ran one".
859
+ const value = stripInertDataSegments(rawValue);
789
860
  // NOTE: a disabled item must FALL THROUGH to the later rules, not end the
790
861
  // scan — otherwise turning off (or gating) a path rule would mask a real
791
862
  // reverse shell / destructive command in the same value.
@@ -348,9 +348,9 @@ function degradedAllowMessage(event, detail) {
348
348
  * field hit the timeout on transient jitter, and a single retry converts
349
349
  * nearly all of them into normal verdicts instead of degraded decisions.
350
350
  */
351
- async function fetchWithOneRetry(url, init, timeoutMs, onFailure) {
351
+ async function fetchWithOneRetry(url, init, timeoutMs, onFailure, attempts = 2) {
352
352
  let lastError;
353
- for (let attempt = 1; attempt <= 2; attempt++) {
353
+ for (let attempt = 1; attempt <= attempts; attempt++) {
354
354
  const controller = new AbortController();
355
355
  const timer = setTimeout(() => controller.abort(), timeoutMs);
356
356
  try {
@@ -385,8 +385,11 @@ function respondDegraded(ctx, detail, toolName, authRejected = false) {
385
385
  const advice = authRejected
386
386
  ? 'Re-enroll this machine: fullcourtdefense onboard --token <fleet-enrollment-token>.'
387
387
  : 'Do not retry until the connection is restored.';
388
+ // Always track the outage — monitor/shadow machines use the streak too
389
+ // (gateRecentlyDown) to skip synchronous gate calls while the backend is
390
+ // down, so a dead network costs them zero added latency per action.
391
+ const health = (0, policyGateHealth_1.recordGateFailure)();
388
392
  if (ctx.failClosed && !ctx.shadow) {
389
- const health = (0, policyGateHealth_1.recordGateFailure)();
390
393
  if ((0, policyGateHealth_1.shouldFailClosed)(health)) {
391
394
  // Persistent outage on an enforcing machine: block, and raise a CRITICAL
392
395
  // distress code — the daemon heartbeat ships it and org admins get the
@@ -422,14 +425,19 @@ function respondDegraded(ctx, detail, toolName, authRejected = false) {
422
425
  * which is unreachable by definition here. Monitor/shadow machines stay
423
426
  * report-only — the local verdict is logged, nothing is stopped.
424
427
  */
425
- function tryLocalPolicyEnforcement(ctx, call, detail) {
428
+ function tryLocalPolicyEnforcement(ctx, call, detail, opts = {}) {
426
429
  const policies = ctx.localPolicies;
427
430
  if (!policies || policies.length === 0)
428
431
  return false;
429
432
  // Track the outage for visibility: distress ships on the next heartbeat and
430
- // the failure streak still feeds the grace-window state.
431
- (0, policyGateHealth_1.recordGateFailure)();
432
- (0, distress_1.reportDistress)('hook', distress_1.DISTRESS.NETWORK_DOWN, `policy gate unreachable enforcing locally from cached bundle (${policies.length} policies): ${detail}`);
433
+ // the failure streak still feeds the grace-window state. Skipped-call paths
434
+ // (monitor gate-down fast path) pass countAsGateFailure:false — refreshing
435
+ // lastFailureAt on every SKIP would keep gateRecentlyDown() true forever and
436
+ // the machine would never retry the gate.
437
+ if (opts.countAsGateFailure !== false) {
438
+ (0, policyGateHealth_1.recordGateFailure)();
439
+ (0, distress_1.reportDistress)('hook', distress_1.DISTRESS.NETWORK_DOWN, `policy gate unreachable — enforcing locally from cached bundle (${policies.length} policies): ${detail}`);
440
+ }
433
441
  const { operation, context } = (0, actionPolicyEngine_1.inferToolContext)(call.toolName, call.toolArgs);
434
442
  context.toolName = call.toolName;
435
443
  context.developerName = developerId();
@@ -440,7 +448,9 @@ function tryLocalPolicyEnforcement(ctx, call, detail) {
440
448
  if (local.verdict === 'block' || local.verdict === 'require_approval') {
441
449
  const reason = local.reason || `${call.toolName}: local policy ${local.verdict}`;
442
450
  if (ctx.shadow) {
443
- (0, telemetry_1.spoolEvent)({ decision: 'allow', toolName: call.toolName, reason: `[shadow] local policy would ${local.verdict} (offline): ${reason}`, offlineEnforced: true });
451
+ // Truth-in-reporting: the action RAN 'warn' (advisory), never 'allow'
452
+ // (which hides the would-block from the console) and never 'block'.
453
+ (0, telemetry_1.spoolEvent)({ decision: 'warn', toolName: call.toolName, reason: `[monitor] local policy would ${local.verdict} (offline): ${reason}`, offlineEnforced: true });
444
454
  (0, telemetry_1.triggerFlush)(false);
445
455
  ctx.respond(false, undefined, `[FullCourtDefense shadow] would ${local.verdict === 'block' ? 'block' : 'require approval for'} ${call.toolName} (offline, locally cached policy): ${reason}`);
446
456
  }
@@ -811,7 +821,8 @@ async function hookCommand(args, config) {
811
821
  if (localBlock) {
812
822
  dbg({ phase: 'local_deterministic_prompt_block', event, ruleId: localBlock.ruleId, category: localBlock.category });
813
823
  if (shadow) {
814
- spoolLocalFinding({ finding: localBlock, toolName: 'prompt', operation: 'prompt' });
824
+ // Truth-in-reporting: this prompt RAN (shadow/monitor) — 'warn', never 'block'.
825
+ spoolLocalFinding({ finding: localBlock, toolName: 'prompt', operation: 'prompt', decision: 'warn' });
815
826
  respond(false, undefined, `[FullCourtDefense shadow] would block prompt: ${localBlock.reason}`);
816
827
  return;
817
828
  }
@@ -935,7 +946,8 @@ async function enforceActionPolicy(ctx) {
935
946
  if (localBlock) {
936
947
  dbg({ phase: 'local_deterministic_block', event, tool: call.toolName, ruleId: localBlock.ruleId, category: localBlock.category });
937
948
  if (shadow) {
938
- spoolLocalFinding({ finding: localBlock, toolName: call.toolName, operation: event });
949
+ // Truth-in-reporting: this action RAN (shadow/monitor) — 'warn', never 'block'.
950
+ spoolLocalFinding({ finding: localBlock, toolName: call.toolName, operation: event, decision: 'warn' });
939
951
  respond(false, undefined, `[FullCourtDefense shadow] would block ${call.toolName}: ${localBlock.reason}`);
940
952
  return;
941
953
  }
@@ -982,6 +994,26 @@ async function enforceActionPolicy(ctx) {
982
994
  const headers = { 'Content-Type': 'application/json' };
983
995
  if (shieldKey)
984
996
  headers['x-shield-key'] = shieldKey;
997
+ // MONITOR CONTRACT (latency half): a monitor/shadow machine must never make
998
+ // the developer WAIT on the network. During a known outage (a gate failure in
999
+ // the last 60s) skip the synchronous call entirely — cached org policies are
1000
+ // evaluated locally and any finding rides the async spool. Otherwise keep the
1001
+ // call (it records the action in the console) but bound it to ONE attempt
1002
+ // with a tight window; on a miss the catch below degrades to local
1003
+ // evaluation and the action proceeds. Enforce machines keep the full
1004
+ // timeout + retry: their verdict actually gates the action.
1005
+ if (shadow && (0, policyGateHealth_1.gateRecentlyDown)()) {
1006
+ dbg({ phase: 'policy_skip_gate_down', event, tool: call.toolName });
1007
+ const detail = 'Policy gate recently unreachable — monitor mode skipped the synchronous check.';
1008
+ if (!tryLocalPolicyEnforcement(ctx, call, detail, { countAsGateFailure: false })) {
1009
+ (0, telemetry_1.spoolEvent)({ decision: 'allow', toolName: call.toolName, reason: `monitor: ${detail}`, offlineEnforced: true });
1010
+ (0, telemetry_1.triggerFlush)(false);
1011
+ respond(false);
1012
+ }
1013
+ return;
1014
+ }
1015
+ const gateTimeoutMs = shadow ? Math.min(timeoutMs, 1500) : timeoutMs;
1016
+ const gateAttempts = shadow ? 1 : 2;
985
1017
  let result;
986
1018
  try {
987
1019
  const resp = await fetchWithOneRetry(`${apiUrl}/api/agent-security/runtime/check-tool-call`, {
@@ -1001,7 +1033,7 @@ async function enforceActionPolicy(ctx) {
1001
1033
  ...(event === 'mcp' && mcpServerName(payload) ? { mcpServer: mcpServerName(payload) } : {}),
1002
1034
  ...machineMetadata(ctx.client),
1003
1035
  }),
1004
- }, timeoutMs, (attempt, err) => dbg({ phase: 'policy_retry', event, attempt, error: err }));
1036
+ }, gateTimeoutMs, (attempt, err) => dbg({ phase: 'policy_retry', event, attempt, error: err }), gateAttempts);
1005
1037
  if (!resp.ok) {
1006
1038
  dbg({ phase: 'policy_http_error', event, status: resp.status, failClosed: ctx.failClosed });
1007
1039
  const authRejected = resp.status === 401 || resp.status === 403;
@@ -1131,6 +1163,19 @@ async function waitForApproval(input) {
1131
1163
  }
1132
1164
  async function enforceShieldText(ctx) {
1133
1165
  const { event, text, payload, apiUrl, shieldId, shieldKey, shadow, timeoutMs, respond } = ctx;
1166
+ // MONITOR CONTRACT (latency half — same rule as enforceActionPolicy): a
1167
+ // monitor/shadow machine never makes the developer WAIT on the network.
1168
+ // Known outage => skip the synchronous Shield call (local deterministic
1169
+ // rules already ran; reporting rides the async spool). Otherwise one tight
1170
+ // attempt; a miss degrades to allow via the catch below.
1171
+ if (shadow && (0, policyGateHealth_1.gateRecentlyDown)()) {
1172
+ dbg({ phase: 'shield_skip_gate_down', event });
1173
+ (0, telemetry_1.spoolEvent)({ decision: 'allow', toolName: 'prompt', reason: 'monitor: Shield gate recently unreachable — skipped the synchronous scan.', offlineEnforced: true });
1174
+ (0, telemetry_1.triggerFlush)(false);
1175
+ respond(false);
1176
+ }
1177
+ const gateTimeoutMs = shadow ? Math.min(timeoutMs, 1500) : timeoutMs;
1178
+ const gateAttempts = shadow ? 1 : 2;
1134
1179
  try {
1135
1180
  const headers = {
1136
1181
  'Content-Type': 'application/json',
@@ -1145,7 +1190,7 @@ async function enforceShieldText(ctx) {
1145
1190
  method: 'POST',
1146
1191
  headers,
1147
1192
  body: JSON.stringify({ message: text }),
1148
- }, timeoutMs, (attempt, error) => dbg({ phase: 'shield_retry', event, attempt, error }));
1193
+ }, gateTimeoutMs, (attempt, error) => dbg({ phase: 'shield_retry', event, attempt, error }), gateAttempts);
1149
1194
  if (!resp.ok) {
1150
1195
  respondDegraded(ctx, `Backend returned HTTP ${resp.status}.`, 'prompt');
1151
1196
  return;
@@ -619,7 +619,7 @@ class AgentGuardApi {
619
619
  constructor(config) {
620
620
  this.config = config;
621
621
  }
622
- async checkToolCall(input) {
622
+ async checkToolCall(input, timeoutMs) {
623
623
  const result = await this.post('/api/agent-security/runtime/check-tool-call', {
624
624
  shieldId: this.config.shieldId,
625
625
  agentName: this.config.agentName,
@@ -633,7 +633,7 @@ class AgentGuardApi {
633
633
  userObjective: this.config.userObjective,
634
634
  authority: this.config.authority,
635
635
  source: 'runtime_sdk',
636
- });
636
+ }, timeoutMs);
637
637
  if (!result.success || !result.data) {
638
638
  if (result.status === 401 || result.status === 403)
639
639
  throw new CredentialsRejectedError(result.status);
@@ -975,7 +975,12 @@ class McpGatewayServer {
975
975
  }
976
976
  let preflight;
977
977
  try {
978
- preflight = await this.api.checkToolCall({ toolName, operation, toolArgs });
978
+ // MONITOR CONTRACT (latency half): a monitor/no-signal machine must not
979
+ // make the developer WAIT on the network. Keep the synchronous call (it
980
+ // records the action in the console) but bound it tightly — on timeout
981
+ // the catch below degrades to cached-policy evaluation and the call
982
+ // proceeds. Enforce machines keep the full window: their verdict gates.
983
+ preflight = await this.api.checkToolCall({ toolName, operation, toolArgs }, reportOnlyMode ? 1_500 : undefined);
979
984
  }
980
985
  catch (err) {
981
986
  // Policy service unreachable. Local deterministic rules (above) already
@@ -1029,6 +1034,26 @@ class McpGatewayServer {
1029
1034
  }
1030
1035
  }
1031
1036
  operation = preflight.operation;
1037
+ if (!preflight.allowed && reportOnlyMode) {
1038
+ // MONITOR CONTRACT (online mirror of the offline gate above): the
1039
+ // backend's check-tool-call does NOT downgrade verdicts by shield mode,
1040
+ // so a monitor machine CAN receive block/approval here (e.g. an enforce
1041
+ // Action Policy created from discovery). A monitor machine never blocks
1042
+ // and never pauses for approval — record the would-verdict, let it run.
1043
+ const wouldReason = preflight.actionPolicy?.reason
1044
+ || preflight.intentEvaluation?.reasons?.[0]
1045
+ || `Tool call ${preflight.decision} by FullCourtDefense.`;
1046
+ (0, telemetry_1.spoolEvent)({
1047
+ decision: 'warn',
1048
+ toolName,
1049
+ operation,
1050
+ reason: `[monitor] policy would ${preflight.decision === 'approval' ? 'require approval' : preflight.decision}: ${wouldReason}`,
1051
+ ruleId: preflight.actionPolicy?.policyName ? `action-policy:${preflight.actionPolicy.policyName}` : undefined,
1052
+ });
1053
+ (0, telemetry_1.triggerFlush)(false);
1054
+ process.stderr.write(`FullCourtDefense (monitor) would ${preflight.decision === 'approval' ? 'require approval for' : 'block'} ${toolName}: ${wouldReason}\n`);
1055
+ preflight = { allowed: true, decision: 'allow', operation };
1056
+ }
1032
1057
  if (!preflight.allowed) {
1033
1058
  if (preflight.decision === 'approval' && this.gatewayConfig.approvalMode === 'wait') {
1034
1059
  approvalActionId = preflight.approvalActionId;
@@ -104,7 +104,11 @@ function buildGuardJs(nodePath, cliEntry) {
104
104
  `const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
105
105
  `const BLOCK_CODE=${BLOCK_CODE};`,
106
106
  `function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,action:r.action==='warn'?'warn':'block',re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='block'?'block':'monitor',rules};}catch{return{mode:'monitor',rules:[]};}}`,
107
- `function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();let warnHit=null;for(const r of rules){try{if(r.re.test(n)||r.re.test(line)){if(r.action!=='warn')return r;if(!warnHit)warnHit=r;}}catch{}}return warnHit;}`,
107
+ `// Execution view (aligned with the CLI's stripInertDataSegments): quoted spans containing whitespace are`,
108
+ `// prose (inert) unless in a code position — interpreter -c/-Command flags, remote executors (ssh/wsl/docker`,
109
+ `// exec), $( ) substitution, or real backtick command substitution (>=2 chars; a 1-char pair is a PS escape).`,
110
+ `function execView(line){if(/\\b(?:iex|invoke-expression|invoke-command)\\b|\\beval\\b|\\bxargs\\b|\\bbase64\\b[^\\n]*(?:-d\\b|--decode\\b)|frombase64string|-encodedcommand\\b|\\s-enc\\s|\\|\\s*&?\\s*(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\\.exe)?|cmd(?:\\.exe)?|node|python[0-9.]*|perl|ruby)\\b|\\b(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\\.exe)?|cmd(?:\\.exe)?|node|python[0-9.]*|perl|ruby)\\s+(?:-c|\\/c|-e|-command|-scriptblock)\\b|\\b(?:ssh|wsl(?:\\.exe)?|chroot)\\b|\\b(?:docker|kubectl|podman|nerdctl)(?:\\.exe)?\\s+(?:exec|run)\\b/i.test(line))return line;const K=/\\$\\(|\`[^\`]{2,}\`/;const C=/(?:(?:^|[;&|(]|\\s)(?:-c|\\/c|-e|-command|-scriptblock|-filter)[= ]\\s*|(?:^|[;&|(]\\s*)(?:\\S*[\\\\\\/])?(?:sudo\\s+|doas\\s+)?(?:ssh|wsl(?:\\.exe)?|chroot|su|screen|tmux)(?:\\s[^;&|"']*)?\\s|(?:\\S*[\\\\\\/])?(?:docker|kubectl|podman|nerdctl)(?:\\.exe)?\\s+(?:exec|run)\\b[^;&|"']*\\s)$/i;let o=line;o=o.replace(/@'[\\s\\S]*?'@/g,' fcd_inert_data ');o=o.replace(/@"[\\s\\S]*?"@/g,m=>m.includes('$(')?m:' fcd_inert_data ');o=o.replace(/<<-?\\s*'(\\w+)'[\\s\\S]*?(?:\\n\\1\\b|$)/g,' fcd_inert_data ');o=o.replace(/<<-?\\s*"?(\\w+)"?[\\s\\S]*?(?:\\n\\1\\b|$)/g,m=>K.test(m)?m:' fcd_inert_data ');const s=o;o=s.replace(/"((?:[^"\\\\]|\\\\.)*)"|'((?:[^'\\\\]|\\\\.)*)'/g,(w,dq,sq,i)=>{const b=dq!==undefined?dq:(sq||'');if(!/\\s/.test(b))return w;if(C.test(s.slice(Math.max(0,i-80),i)))return w;if(dq!==undefined&&K.test(b))return w;return ' fcd_inert_data ';});return o;}`,
111
+ `function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();const x=execView(line);const xn=x.replace(/\\s+/g,' ').trim();let warnHit=null;for(const r of rules){try{const t=r.source==='custom'?line:x;const tn=r.source==='custom'?n:xn;if(r.re.test(tn)||r.re.test(t)){if(r.action!=='warn')return r;if(!warnHit)warnHit=r;}}catch{}}return warnHit;}`,
108
112
  `function triggerFlush(){try{if(fs.existsSync(NODE_PATH)&&fs.existsSync(CLI_ENTRY)){const c=spawn(NODE_PATH,[CLI_ENTRY,'flush-spool','--heartbeat','true'],{detached:true,stdio:'ignore'});c.unref();}}catch{}}`,
109
113
  `function spoolEvent(rule,line,decision){try{const ev=line.trim();const evidence=ev.length>180?ev.slice(0,180)+'...':ev;const event={eventId:crypto.randomUUID(),type:'verdict',decision,toolName:'shell_terminal',operation:'shell_command',reason:'Shell guard: '+rule.reason,ruleId:rule.id,category:rule.category,severity:rule.severity,source:rule.source,evidence,occurredAt:new Date().toISOString()};fs.appendFileSync(SPOOL_PATH,JSON.stringify(event)+'\\n',{encoding:'utf8',mode:0o600});triggerFlush();}catch{}}`,
110
114
  `let argv=process.argv.slice(2);if(argv[0]==='--')argv=argv.slice(1);const line=argv.join(' ');`,
@@ -112,7 +116,7 @@ function buildGuardJs(nodePath, cliEntry) {
112
116
  `if(process.env.FCD_SHELL_GUARD==='off')process.exit(0);`,
113
117
  `const{mode,rules}=loadRules();const hit=matchLine(line,rules);`,
114
118
  `if(!hit)process.exit(0);`,
115
- `if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'allow');process.exit(0);}`,
119
+ `if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'warn');process.exit(0);}`,
116
120
  `if(hit.action==='warn'){console.error('[FullCourtDefense] warning ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+') — allowed by org policy, reported to your security dashboard.');spoolEvent(hit,line,'warn');process.exit(0);}`,
117
121
  `console.error('[FullCourtDefense] BLOCKED ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
118
122
  `console.error('This command was not executed. Reported to your security dashboard.');`,
@@ -485,14 +485,48 @@ function buildGuardPs1(nodePath, cliEntry) {
485
485
  ` }`,
486
486
  `} catch { $global:FcdGuardRules = @() }`,
487
487
  ``,
488
+ `# Execution view (aligned with the CLI's stripInertDataSegments): quoted`,
489
+ `# spans containing whitespace are prose — inert BY DEFAULT — unless in a`,
490
+ `# code position: interpreter -c/-Command flags, remote executors (ssh/wsl/`,
491
+ `# docker exec), $( ) substitution, or real backtick command substitution`,
492
+ `# (>=2 chars of content; a 1-char pair like \`n is a PS ESCAPE, not code).`,
493
+ `# Whole-line bailout on any interpreter/executor construct keeps detection`,
494
+ `# false-negative-safe. Single-token quoted args stay: argv (rm -rf "/").`,
495
+ `function global:Get-FcdExecView {`,
496
+ ` param([string]$CommandLine)`,
497
+ String.raw ` if ($CommandLine -match '(?i)\b(?:iex|invoke-expression|invoke-command)\b|\beval\b|\bxargs\b|\bbase64\b[^\n]*(?:-d\b|--decode\b)|frombase64string|-encodedcommand\b|\s-enc\s|\|\s*&?\s*(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\.exe)?|cmd(?:\.exe)?|node|python[0-9.]*|perl|ruby)\b|\b(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\.exe)?|cmd(?:\.exe)?|node|python[0-9.]*|perl|ruby)\s+(?:-c|/c|-e|-command|-scriptblock)\b|\b(?:ssh|wsl(?:\.exe)?|chroot)\b|\b(?:docker|kubectl|podman|nerdctl)(?:\.exe)?\s+(?:exec|run)\b') { return $CommandLine }`,
498
+ ` $out = $CommandLine`,
499
+ " $interp = '\\$\\(|" + '`[^`]{2,}`' + "'",
500
+ " $keepIfInterpolates = [System.Text.RegularExpressions.MatchEvaluator]{ param($m) if ($m.Value -match $interp) { $m.Value } else { ' fcd_inert_data ' } }",
501
+ String.raw ` $out = [regex]::Replace($out, "@'[\s\S]*?'@", ' fcd_inert_data ')`,
502
+ ` $out = [regex]::Replace($out, '@"[\\s\\S]*?"@', $keepIfInterpolates)`,
503
+ String.raw ` $out = [regex]::Replace($out, "<<-?\s*'(\w+)'[\s\S]*?(?:\n\1\b|$)", ' fcd_inert_data ')`,
504
+ String.raw ` $out = [regex]::Replace($out, '<<-?\s*"?(\w+)"?[\s\S]*?(?:\n\1\b|$)', $keepIfInterpolates)`,
505
+ String.raw ` $codeCtx = '(?i)(?:(?:^|[;&|(]|\s)(?:-c|/c|-e|-command|-scriptblock|-filter)[= ]\s*|(?:^|[;&|(]\s*)(?:\S*[\\/])?(?:sudo\s+|doas\s+)?(?:ssh|wsl(?:\.exe)?|chroot|su|screen|tmux)(?:\s[^;&|"'']*)?\s|(?:\S*[\\/])?(?:docker|kubectl|podman|nerdctl)(?:\.exe)?\s+(?:exec|run)\b[^;&|"'']*\s)$'`,
506
+ String.raw ` $quotedRe = '"((?:[^"\\]|\\.)*)"|''((?:[^''\\]|\\.)*)'''`,
507
+ ` $src = $out`,
508
+ String.raw ` $spanEval = [System.Text.RegularExpressions.MatchEvaluator]{ param($m) $body = if ($m.Groups[1].Success) { $m.Groups[1].Value } else { $m.Groups[2].Value }; if ($body -notmatch '\s') { return $m.Value }; $start = [Math]::Max(0, $m.Index - 80); if ($src.Substring($start, $m.Index - $start) -match $codeCtx) { return $m.Value }; if ($m.Groups[1].Success -and $body -match $interp) { return $m.Value }; return ' fcd_inert_data ' }`,
509
+ ` $out = [regex]::Replace($src, $quotedRe, $spanEval)`,
510
+ ` return $out`,
511
+ `}`,
512
+ ``,
488
513
  `function global:Test-FcdShellGuard {`,
489
514
  ` param([string]$CommandLine)`,
490
515
  ` if ([string]::IsNullOrWhiteSpace($CommandLine)) { return $null }`,
491
516
  ` $normalized = ($CommandLine -replace '\\s+', ' ').Trim()`,
517
+ ` $execView = $CommandLine`,
518
+ ` try { $execView = Get-FcdExecView -CommandLine $CommandLine } catch { }`,
519
+ ` $execNormalized = ($execView -replace '\\s+', ' ').Trim()`,
492
520
  ` $warnHit = $null`,
493
521
  ` foreach ($rule in $global:FcdGuardRules) {`,
494
522
  ` try {`,
495
- ` if ($rule.Regex.IsMatch($normalized) -or $rule.Regex.IsMatch($CommandLine)) {`,
523
+ ` # Built-in rules ask "can this text ACT?" — they see the execution`,
524
+ ` # view, so a commit message quoting a dangerous command is inert.`,
525
+ ` # Console custom rules define their own scope — they see the raw line.`,
526
+ ` $isCustom = ([string]$rule.Source -eq 'custom')`,
527
+ ` $target = if ($isCustom) { $CommandLine } else { $execView }`,
528
+ ` $targetNorm = if ($isCustom) { $normalized } else { $execNormalized }`,
529
+ ` if ($rule.Regex.IsMatch($targetNorm) -or $rule.Regex.IsMatch($target)) {`,
496
530
  ` # A block rule always outranks a warn rule matching the same line.`,
497
531
  ` if ($rule.Action -ne 'warn') { return $rule }`,
498
532
  ` if ($null -eq $warnHit) { $warnHit = $rule }`,
@@ -546,7 +580,7 @@ function buildGuardPs1(nodePath, cliEntry) {
546
580
  ` if ($global:FcdGuardMode -eq 'monitor') {`,
547
581
  ` Write-Host ''`,
548
582
  ` Write-Host ('[FullCourtDefense] monitor: would block [' + $hit.Severity + '] ' + $hit.Reason + ' (rule ' + $hit.Id + ')') -ForegroundColor Yellow`,
549
- ` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision 'allow'`,
583
+ ` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision 'warn'`,
550
584
  ` [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()`,
551
585
  ` return`,
552
586
  ` }`,
@@ -29,5 +29,13 @@ export interface GateHealth {
29
29
  export declare function recordGateSuccess(): void;
30
30
  /** Record one failed gate round-trip and return the updated streak. */
31
31
  export declare function recordGateFailure(now?: Date): GateHealth;
32
+ /**
33
+ * True when the gate failed very recently. Monitor/shadow surfaces use this to
34
+ * skip the synchronous policy call entirely during an outage (reporting rides
35
+ * the async spool instead), so a dead/hanging backend costs a monitor machine
36
+ * ZERO added latency after the first bounded miss. The 60s window guarantees
37
+ * an automatic retry, which resets the streak via recordGateSuccess().
38
+ */
39
+ export declare function gateRecentlyDown(now?: Date): boolean;
32
40
  /** Fail closed only for persistent outages, never for a single blip. */
33
41
  export declare function shouldFailClosed(health: GateHealth, now?: Date): boolean;
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.FAIL_CLOSED_AFTER_MS = exports.FAIL_CLOSED_AFTER_FAILURES = void 0;
37
37
  exports.recordGateSuccess = recordGateSuccess;
38
38
  exports.recordGateFailure = recordGateFailure;
39
+ exports.gateRecentlyDown = gateRecentlyDown;
39
40
  exports.shouldFailClosed = shouldFailClosed;
40
41
  const fs = __importStar(require("fs"));
41
42
  const os = __importStar(require("os"));
@@ -104,6 +105,19 @@ function recordGateFailure(now = new Date()) {
104
105
  catch { /* best-effort */ }
105
106
  return next;
106
107
  }
108
+ /**
109
+ * True when the gate failed very recently. Monitor/shadow surfaces use this to
110
+ * skip the synchronous policy call entirely during an outage (reporting rides
111
+ * the async spool instead), so a dead/hanging backend costs a monitor machine
112
+ * ZERO added latency after the first bounded miss. The 60s window guarantees
113
+ * an automatic retry, which resets the streak via recordGateSuccess().
114
+ */
115
+ function gateRecentlyDown(now = new Date()) {
116
+ const health = readHealth();
117
+ return health.consecutiveFailures >= 1
118
+ && health.lastFailureAt !== undefined
119
+ && now.getTime() - Date.parse(health.lastFailureAt) < 60_000;
120
+ }
107
121
  /** Fail closed only for persistent outages, never for a single blip. */
108
122
  function shouldFailClosed(health, now = new Date()) {
109
123
  if (health.consecutiveFailures >= exports.FAIL_CLOSED_AFTER_FAILURES)
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.25.3"
2
+ "version": "1.25.5"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.25.3",
3
+ "version": "1.25.5",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {