fullcourtdefense-cli 1.25.4 → 1.25.6

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,22 +112,27 @@ 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
+ `// ${(0, shellGuard_1.guardBuildStamp)()}`,
116
+ `const fs=require('fs');const os=require('os');const path=require('path');const{spawn,spawnSync}=require('child_process');const crypto=require('crypto');`,
116
117
  `const RULES_PATH=${JSON.stringify(GUARD_RULES_PATH)};`,
117
118
  `const SPOOL_PATH=${JSON.stringify(path.join(os.homedir(), '.fullcourtdefense-spool.jsonl'))};`,
118
119
  `const NODE_PATH=${JSON.stringify(nodePath)};`,
119
120
  `const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
120
121
  `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
- `// Execution view (aligned with the CLI's stripInertDataSegments): commit messages, issue/PR bodies, and`,
122
- `// here-strings/heredocs are DATA — inert unless the line can feed an interpreter or the region interpolates.`,
123
- `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/i.test(line))return line;const K=/\\$\\(|\`[^\`]*\`/;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 ');o=o.replace(/((?:^|\\s)(?:-m|-b|--message|--body|--title|--description|--notes?|--comment|--caption|--summary|--subject|-value|-message)[= ]\\s*)("(?:[^"\\\\]|\\\\.)*"|'(?:[^'\\\\]|\\\\.)*')/gi,(w,f,q)=>q[0]==="'"?f+' fcd_inert_data ':K.test(q)?w:f+' fcd_inert_data ');return o;}`,
122
+ `// Execution view (aligned with the CLI's stripInertDataSegments): quoted spans containing whitespace are`,
123
+ `// prose (inert) unless in a code position interpreter -c/-Command flags, remote executors (ssh/wsl/docker`,
124
+ `// exec), $( ) substitution, or real backtick command substitution (>=2 chars; a 1-char pair is a PS escape).`,
125
+ `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;}`,
124
126
  `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;}`,
125
- `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{}}`,
127
+ `// Flush is DETACHED (spawn+unref, matching the posix guard) a spawnSync here`,
128
+ `// made the user's terminal wait for a full CLI process INCLUDING the network`,
129
+ `// flush after every finding. Monitor mode must never add synchronous latency.`,
130
+ `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{}}`,
126
131
  `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);}`,
127
132
  `const args=process.argv.slice(2);if(!args.length)process.exit(0);if(process.env.FCD_CMD_GUARD==='off')delegate(args);`,
128
133
  `const line=args.join(' ');const{mode,rules}=loadRules();const hit=matchLine(line,rules);`,
129
134
  `if(!hit)delegate(args);`,
130
- `if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'allow');delegate(args);}`,
135
+ `if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'warn');delegate(args);}`,
131
136
  `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);}`,
132
137
  `console.error('[FullCourtDefense] BLOCKED ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
133
138
  `console.error('This command was not executed. Reported to your security dashboard.');`,
@@ -142,6 +147,7 @@ function buildAutorunBat(nodePath) {
142
147
  return [
143
148
  '@echo off',
144
149
  'REM FullCourtDefense cmd.exe guard (installed by fullcourtdefense-cli).',
150
+ `REM ${(0, shellGuard_1.guardBuildStamp)()}`,
145
151
  'REM Disable for one session: set FCD_CMD_GUARD=off',
146
152
  'REM Remove permanently: fullcourtdefense uninstall-cmd-guard',
147
153
  'if defined FCD_CMD_AUTORUN_DONE goto :eof',
@@ -271,6 +277,13 @@ function refreshCmdGuardRules() {
271
277
  if (!isCmdGuardInstalled())
272
278
  return;
273
279
  (0, shellGuard_1.writeShellGuardRules)();
280
+ // Self-heal after CLI updates: the planted checker/autorun carry the
281
+ // detection logic of the build that wrote them — rewrite when stale.
282
+ if ((0, shellGuard_1.guardArtifactStale)(GUARD_JS_PATH)) {
283
+ const nodePath = process.execPath;
284
+ (0, shellGuard_1.writeGuardArtifact)(GUARD_JS_PATH, buildGuardJs(nodePath, process.argv[1] || ''));
285
+ (0, shellGuard_1.writeGuardArtifact)(AUTORUN_BAT_PATH, buildAutorunBat(nodePath));
286
+ }
274
287
  }
275
288
  catch { /* best-effort */ }
276
289
  }
@@ -783,20 +783,38 @@ function isSensitivePathContext(toolName, candidate) {
783
783
  return /(?:path|file|filename|dir|directory|target|source|src|dest|location|glob|pattern|uri|url)/i.test(lastKey);
784
784
  }
785
785
  // ---- execution view: message/document DATA inside a command is inert ---------
786
- // Kills the "wrote ABOUT a dangerous command" FP class: commit messages,
787
- // issue/PR bodies, here-strings, doc heredocs quoting `terraform destroy` or
788
- // `rm -rf /` are data, not execution. Safety invariants:
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):
789
796
  // 1. NOTHING is stripped when the text can feed an interpreter (| sh, iex,
790
- // eval, xargs, base64 -d, -EncodedCommand, FromBase64String) data that
791
- // flows into an interpreter IS execution.
792
- // 2. A region containing $( ) or `…` pairs is kept — interpolation executes
793
- // BEFORE the outer command.
794
- // 3. Secret-literal rules and honeypot/custom rules never use this view —
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 —
795
809
  // a token pasted into a commit message still leaks.
796
- 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/i;
797
- const INTERPOLATES = /\$\(|`[^`]*`/;
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,}`/;
798
814
  const INERT_DATA = ' fcd_inert_data ';
799
- const TEXT_SINK_FLAG_ARG = /((?:^|\s)(?:-m|-b|--message|--body|--title|--description|--notes?|--comment|--caption|--summary|--subject|-value|-message)[= ]\s*)("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/gi;
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;
800
818
  function stripInertDataSegments(text) {
801
819
  if (DATA_TO_INTERPRETER.test(text))
802
820
  return text;
@@ -807,11 +825,19 @@ function stripInertDataSegments(text) {
807
825
  // POSIX heredocs: quoted delimiter (<<'EOF') is literal; unquoted interpolates.
808
826
  out = out.replace(/<<-?\s*'(\w+)'[\s\S]*?(?:\n\1\b|$)/g, INERT_DATA);
809
827
  out = out.replace(/<<-?\s*"?(\w+)"?[\s\S]*?(?:\n\1\b|$)/g, m => (INTERPOLATES.test(m) ? m : INERT_DATA));
810
- // Quoted message/text-flag arguments (commit messages, issue/PR bodies…).
811
- out = out.replace(TEXT_SINK_FLAG_ARG, (whole, flag, quoted) => {
812
- if (quoted.startsWith("'"))
813
- return flag + INERT_DATA;
814
- return INTERPOLATES.test(quoted) ? whole : flag + 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;
815
841
  });
816
842
  return out;
817
843
  }
@@ -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;
@@ -97,6 +97,7 @@ function shSingleQuote(value) {
97
97
  function buildGuardJs(nodePath, cliEntry) {
98
98
  return [
99
99
  `'use strict';`,
100
+ `// ${(0, shellGuard_1.guardBuildStamp)()}`,
100
101
  `const fs=require('fs');const crypto=require('crypto');const{spawn}=require('child_process');`,
101
102
  `const RULES_PATH=${JSON.stringify(GUARD_RULES_PATH)};`,
102
103
  `const SPOOL_PATH=${JSON.stringify(SPOOL_PATH)};`,
@@ -104,9 +105,10 @@ function buildGuardJs(nodePath, cliEntry) {
104
105
  `const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
105
106
  `const BLOCK_CODE=${BLOCK_CODE};`,
106
107
  `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
- `// Execution view (aligned with the CLI's stripInertDataSegments): commit messages, issue/PR bodies, and`,
108
- `// here-strings/heredocs are DATA — inert unless the line can feed an interpreter or the region interpolates.`,
109
- `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/i.test(line))return line;const K=/\\$\\(|\`[^\`]*\`/;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 ');o=o.replace(/((?:^|\\s)(?:-m|-b|--message|--body|--title|--description|--notes?|--comment|--caption|--summary|--subject|-value|-message)[= ]\\s*)("(?:[^"\\\\]|\\\\.)*"|'(?:[^'\\\\]|\\\\.)*')/gi,(w,f,q)=>q[0]==="'"?f+' fcd_inert_data ':K.test(q)?w:f+' fcd_inert_data ');return o;}`,
108
+ `// Execution view (aligned with the CLI's stripInertDataSegments): quoted spans containing whitespace are`,
109
+ `// prose (inert) unless in a code position interpreter -c/-Command flags, remote executors (ssh/wsl/docker`,
110
+ `// exec), $( ) substitution, or real backtick command substitution (>=2 chars; a 1-char pair is a PS escape).`,
111
+ `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;}`,
110
112
  `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;}`,
111
113
  `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{}}`,
112
114
  `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{}}`,
@@ -115,7 +117,7 @@ function buildGuardJs(nodePath, cliEntry) {
115
117
  `if(process.env.FCD_SHELL_GUARD==='off')process.exit(0);`,
116
118
  `const{mode,rules}=loadRules();const hit=matchLine(line,rules);`,
117
119
  `if(!hit)process.exit(0);`,
118
- `if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'allow');process.exit(0);}`,
120
+ `if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'warn');process.exit(0);}`,
119
121
  `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);}`,
120
122
  `console.error('[FullCourtDefense] BLOCKED ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
121
123
  `console.error('This command was not executed. Reported to your security dashboard.');`,
@@ -127,6 +129,7 @@ function buildGuardJs(nodePath, cliEntry) {
127
129
  function buildGuardZsh(nodePath) {
128
130
  return [
129
131
  `# FullCourtDefense interactive shell guard for zsh (installed by fullcourtdefense-cli).`,
132
+ `# ${(0, shellGuard_1.guardBuildStamp)()}`,
130
133
  `# Disable for one session: export FCD_SHELL_GUARD=off`,
131
134
  `# Remove permanently: fullcourtdefense uninstall-shell-guard`,
132
135
  ``,
@@ -173,6 +176,7 @@ function buildGuardZsh(nodePath) {
173
176
  function buildGuardBash(nodePath) {
174
177
  return [
175
178
  `# FullCourtDefense interactive shell guard for bash (installed by fullcourtdefense-cli).`,
179
+ `# ${(0, shellGuard_1.guardBuildStamp)()}`,
176
180
  `# Disable for one session: export FCD_SHELL_GUARD=off`,
177
181
  `# Remove permanently: fullcourtdefense uninstall-shell-guard`,
178
182
  ``,
@@ -392,6 +396,15 @@ function refreshPosixShellGuardRules() {
392
396
  if (!isPosixShellGuardInstalled())
393
397
  return;
394
398
  (0, shellGuard_1.writeShellGuardRules)();
399
+ // Self-heal after CLI updates: planted guard scripts carry the detection
400
+ // logic of the build that wrote them — rewrite when the stamp is stale.
401
+ if ((0, shellGuard_1.guardArtifactStale)(GUARD_JS_PATH)) {
402
+ const nodePath = process.execPath;
403
+ const cliEntry = process.argv[1] || '';
404
+ (0, shellGuard_1.writeGuardArtifact)(GUARD_JS_PATH, buildGuardJs(nodePath, cliEntry), 0o600);
405
+ (0, shellGuard_1.writeGuardArtifact)(GUARD_ZSH_PATH, buildGuardZsh(nodePath), 0o600);
406
+ (0, shellGuard_1.writeGuardArtifact)(GUARD_BASH_PATH, buildGuardBash(nodePath), 0o600);
407
+ }
395
408
  }
396
409
  catch { /* best-effort */ }
397
410
  }
@@ -1,3 +1,19 @@
1
+ /**
2
+ * Build stamp — the CLI version that generated a planted guard artifact.
3
+ *
4
+ * The profile/rc/autorun guard scripts are COPIES of CLI detection logic
5
+ * written to disk at install time. Without a stamp, a fleet CLI self-update
6
+ * ships new detection logic but every machine keeps running the old planted
7
+ * script forever (rules JSON refreshes carry rules, never logic). Each
8
+ * builder embeds this stamp in a comment; the telemetry-cycle refresh
9
+ * rewrites any artifact whose stamp differs from the running build.
10
+ */
11
+ export declare const GUARD_BUILD_MARKER = "fcd-guard-build:";
12
+ export declare function guardBuildStamp(): string;
13
+ /** True when a planted guard artifact was generated by a DIFFERENT CLI build. */
14
+ export declare function guardArtifactStale(filePath: string): boolean;
15
+ /** Atomic write — a torn guard script would break every new shell that sources it. */
16
+ export declare function writeGuardArtifact(filePath: string, content: string, mode?: number): void;
1
17
  /** Blast-severity levels (shellfirm-style). Drives per-severity console policy. */
2
18
  export type ShellGuardSeverity = 'critical' | 'high' | 'medium' | 'low';
3
19
  export interface ShellGuardRule {
@@ -33,6 +33,10 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.GUARD_BUILD_MARKER = void 0;
37
+ exports.guardBuildStamp = guardBuildStamp;
38
+ exports.guardArtifactStale = guardArtifactStale;
39
+ exports.writeGuardArtifact = writeGuardArtifact;
36
40
  exports.writeShellGuardRules = writeShellGuardRules;
37
41
  exports.activeShellGuardRules = activeShellGuardRules;
38
42
  exports.evaluateShellCommand = evaluateShellCommand;
@@ -81,6 +85,48 @@ const RUNTIME_CACHE_PATH = path.join(os.homedir(), '.fullcourtdefense-runtime.js
81
85
  const SNAPSHOT_CACHE_DIR = path.join(os.homedir(), '.fullcourtdefense');
82
86
  const MARKER_START = '# >>> fullcourtdefense shell guard >>>';
83
87
  const MARKER_END = '# <<< fullcourtdefense shell guard <<<';
88
+ /**
89
+ * Build stamp — the CLI version that generated a planted guard artifact.
90
+ *
91
+ * The profile/rc/autorun guard scripts are COPIES of CLI detection logic
92
+ * written to disk at install time. Without a stamp, a fleet CLI self-update
93
+ * ships new detection logic but every machine keeps running the old planted
94
+ * script forever (rules JSON refreshes carry rules, never logic). Each
95
+ * builder embeds this stamp in a comment; the telemetry-cycle refresh
96
+ * rewrites any artifact whose stamp differs from the running build.
97
+ */
98
+ exports.GUARD_BUILD_MARKER = 'fcd-guard-build:';
99
+ function guardBuildVersion() {
100
+ try {
101
+ const parsed = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'version.json'), 'utf8'));
102
+ return parsed.version || 'unknown';
103
+ }
104
+ catch {
105
+ return 'unknown';
106
+ }
107
+ }
108
+ function guardBuildStamp() {
109
+ return `${exports.GUARD_BUILD_MARKER} ${guardBuildVersion()}`;
110
+ }
111
+ /** True when a planted guard artifact was generated by a DIFFERENT CLI build. */
112
+ function guardArtifactStale(filePath) {
113
+ const version = guardBuildVersion();
114
+ // Dev builds without version.json must not churn artifacts every 30s.
115
+ if (version === 'unknown')
116
+ return false;
117
+ try {
118
+ return !fs.readFileSync(filePath, 'utf8').includes(`${exports.GUARD_BUILD_MARKER} ${version}`);
119
+ }
120
+ catch {
121
+ return false;
122
+ }
123
+ }
124
+ /** Atomic write — a torn guard script would break every new shell that sources it. */
125
+ function writeGuardArtifact(filePath, content, mode) {
126
+ const tmp = `${filePath}.tmp`;
127
+ fs.writeFileSync(tmp, content, mode !== undefined ? { encoding: 'utf8', mode } : 'utf8');
128
+ fs.renameSync(tmp, filePath);
129
+ }
84
130
  /** Confine every lookahead to a single command segment (never span `;` `&` `|`). */
85
131
  const SEG = String.raw `[^;&|]*`;
86
132
  function compileSpec(spec) {
@@ -444,6 +490,12 @@ function refreshShellGuardRules() {
444
490
  if (!fs.existsSync(GUARD_PS1_PATH))
445
491
  return;
446
492
  writeShellGuardRules();
493
+ // Self-heal after CLI updates: the planted profile script carries the
494
+ // detection logic of the build that WROTE it. Rewrite when stale so new
495
+ // shells load the current guard (rules refreshes alone never carry logic).
496
+ if (guardArtifactStale(GUARD_PS1_PATH)) {
497
+ writeGuardArtifact(GUARD_PS1_PATH, buildGuardPs1(process.execPath, process.argv[1] || ''));
498
+ }
447
499
  }
448
500
  catch { /* best-effort */ }
449
501
  }
@@ -454,6 +506,7 @@ function psQuote(value) {
454
506
  function buildGuardPs1(nodePath, cliEntry) {
455
507
  const lines = [
456
508
  `# FullCourtDefense interactive shell guard (installed by fullcourtdefense-cli).`,
509
+ `# ${guardBuildStamp()}`,
457
510
  `# Blocks dangerous commands typed in PowerShell using the org's cached rules.`,
458
511
  `# Disable for one session: $env:FCD_SHELL_GUARD = 'off'`,
459
512
  `# Remove permanently: fullcourtdefense uninstall-shell-guard`,
@@ -485,22 +538,28 @@ function buildGuardPs1(nodePath, cliEntry) {
485
538
  ` }`,
486
539
  `} catch { $global:FcdGuardRules = @() }`,
487
540
  ``,
488
- `# Execution view (aligned with the CLI's stripInertDataSegments): message/`,
489
- `# document DATA inside a command is inert — commit messages, issue/PR bodies,`,
490
- `# here-strings. Never strips when the line can feed an interpreter (| sh,`,
491
- `# iex, eval, xargs, base64 -d, -EncodedCommand) and keeps any region that`,
492
- `# interpolates $( ) or backtick pairs those execute BEFORE the command.`,
541
+ `# Execution view (aligned with the CLI's stripInertDataSegments): quoted`,
542
+ `# spans containing whitespace are prose inert BY DEFAULT unless in a`,
543
+ `# code position: interpreter -c/-Command flags, remote executors (ssh/wsl/`,
544
+ `# docker exec), $( ) substitution, or real backtick command substitution`,
545
+ `# (>=2 chars of content; a 1-char pair like \`n is a PS ESCAPE, not code).`,
546
+ `# Whole-line bailout on any interpreter/executor construct keeps detection`,
547
+ `# false-negative-safe. Single-token quoted args stay: argv (rm -rf "/").`,
493
548
  `function global:Get-FcdExecView {`,
494
549
  ` param([string]$CommandLine)`,
495
- 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') { return $CommandLine }`,
550
+ 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 }`,
496
551
  ` $out = $CommandLine`,
497
- " $keepIfInterpolates = [System.Text.RegularExpressions.MatchEvaluator]{ param($m) if ($m.Value -match '\\$\\(|" + '`[^`]*`' + "') { $m.Value } else { ' fcd_inert_data ' } }",
552
+ " $interp = '\\$\\(|" + '`[^`]{2,}`' + "'",
553
+ " $keepIfInterpolates = [System.Text.RegularExpressions.MatchEvaluator]{ param($m) if ($m.Value -match $interp) { $m.Value } else { ' fcd_inert_data ' } }",
498
554
  String.raw ` $out = [regex]::Replace($out, "@'[\s\S]*?'@", ' fcd_inert_data ')`,
499
555
  ` $out = [regex]::Replace($out, '@"[\\s\\S]*?"@', $keepIfInterpolates)`,
500
556
  String.raw ` $out = [regex]::Replace($out, "<<-?\s*'(\w+)'[\s\S]*?(?:\n\1\b|$)", ' fcd_inert_data ')`,
501
557
  String.raw ` $out = [regex]::Replace($out, '<<-?\s*"?(\w+)"?[\s\S]*?(?:\n\1\b|$)', $keepIfInterpolates)`,
502
- String.raw ` $flagArg = '((?:^|\s)(?:-m|-b|--message|--body|--title|--description|--notes?|--comment|--caption|--summary|--subject|-value|-message)[= ]\s*)("(?:[^"\\]|\\.)*"|''(?:[^''\\]|\\.)*'')'`,
503
- " $out = [regex]::Replace($out, $flagArg, [System.Text.RegularExpressions.MatchEvaluator]{ param($m) if ($m.Groups[2].Value.StartsWith(\"'\")) { $m.Groups[1].Value + ' fcd_inert_data ' } elseif ($m.Groups[2].Value -match '\\$\\(|" + '`[^`]*`' + "') { $m.Value } else { $m.Groups[1].Value + ' fcd_inert_data ' } }, 'IgnoreCase')",
558
+ 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)$'`,
559
+ String.raw ` $quotedRe = '"((?:[^"\\]|\\.)*)"|''((?:[^''\\]|\\.)*)'''`,
560
+ ` $src = $out`,
561
+ 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 ' }`,
562
+ ` $out = [regex]::Replace($src, $quotedRe, $spanEval)`,
504
563
  ` return $out`,
505
564
  `}`,
506
565
  ``,
@@ -574,7 +633,7 @@ function buildGuardPs1(nodePath, cliEntry) {
574
633
  ` if ($global:FcdGuardMode -eq 'monitor') {`,
575
634
  ` Write-Host ''`,
576
635
  ` Write-Host ('[FullCourtDefense] monitor: would block [' + $hit.Severity + '] ' + $hit.Reason + ' (rule ' + $hit.Id + ')') -ForegroundColor Yellow`,
577
- ` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision 'allow'`,
636
+ ` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision 'warn'`,
578
637
  ` [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()`,
579
638
  ` return`,
580
639
  ` }`,
@@ -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.4"
2
+ "version": "1.25.6"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.25.4",
3
+ "version": "1.25.6",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {