fullcourtdefense-cli 1.25.3 → 1.25.4

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.
@@ -118,7 +118,10 @@ function buildGuardJs(nodePath, cliEntry) {
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;}`,
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;}`,
124
+ `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;}`,
122
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{}}`,
123
126
  `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
127
  `const args=process.argv.slice(2);if(!args.length)process.exit(0);if(process.env.FCD_CMD_GUARD==='off')delegate(args);`,
@@ -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,55 @@ 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
+ // 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:
789
+ // 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 —
795
+ // 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 = /\$\(|`[^`]*`/;
798
+ 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;
800
+ function stripInertDataSegments(text) {
801
+ if (DATA_TO_INTERPRETER.test(text))
802
+ return text;
803
+ let out = text;
804
+ // PowerShell here-strings: @'…'@ is fully literal; @"…"@ interpolates $( ).
805
+ out = out.replace(/@'[\s\S]*?'@/g, INERT_DATA);
806
+ out = out.replace(/@"[\s\S]*?"@/g, m => (m.includes('$(') ? m : INERT_DATA));
807
+ // POSIX heredocs: quoted delimiter (<<'EOF') is literal; unquoted interpolates.
808
+ out = out.replace(/<<-?\s*'(\w+)'[\s\S]*?(?:\n\1\b|$)/g, INERT_DATA);
809
+ 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;
815
+ });
816
+ return out;
817
+ }
818
+ function scanTextValue(toolName, rawValue, options) {
819
+ // Honeypot decoys and org custom patterns scan the RAW value: a decoy path
820
+ // even inside message data is exfiltration staging, and custom rules define
821
+ // their own scope.
822
+ const honeypot = honeypotTouch(rawValue, options);
784
823
  if (honeypot)
785
824
  return honeypot;
786
- const custom = customBlock(value, options);
825
+ const custom = customBlock(rawValue, options);
787
826
  if (custom)
788
827
  return custom;
828
+ // Every rule below asks "can this text ACT?" — so it sees the execution
829
+ // view: quoted message/document data (commit -m, issue --body, here-strings,
830
+ // heredocs) is inert unless it can flow into an interpreter (see
831
+ // stripInertDataSegments invariants). This is the generic FP guard for
832
+ // "wrote ABOUT a dangerous command" vs "ran one".
833
+ const value = stripInertDataSegments(rawValue);
789
834
  // NOTE: a disabled item must FALL THROUGH to the later rules, not end the
790
835
  // scan — otherwise turning off (or gating) a path rule would mask a real
791
836
  // reverse shell / destructive command in the same value.
@@ -104,7 +104,10 @@ 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): 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;}`,
110
+ `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
111
  `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
112
  `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
113
  `let argv=process.argv.slice(2);if(argv[0]==='--')argv=argv.slice(1);const line=argv.join(' ');`,
@@ -485,14 +485,42 @@ function buildGuardPs1(nodePath, cliEntry) {
485
485
  ` }`,
486
486
  `} catch { $global:FcdGuardRules = @() }`,
487
487
  ``,
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.`,
493
+ `function global:Get-FcdExecView {`,
494
+ ` 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 }`,
496
+ ` $out = $CommandLine`,
497
+ " $keepIfInterpolates = [System.Text.RegularExpressions.MatchEvaluator]{ param($m) if ($m.Value -match '\\$\\(|" + '`[^`]*`' + "') { $m.Value } else { ' fcd_inert_data ' } }",
498
+ String.raw ` $out = [regex]::Replace($out, "@'[\s\S]*?'@", ' fcd_inert_data ')`,
499
+ ` $out = [regex]::Replace($out, '@"[\\s\\S]*?"@', $keepIfInterpolates)`,
500
+ String.raw ` $out = [regex]::Replace($out, "<<-?\s*'(\w+)'[\s\S]*?(?:\n\1\b|$)", ' fcd_inert_data ')`,
501
+ 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')",
504
+ ` return $out`,
505
+ `}`,
506
+ ``,
488
507
  `function global:Test-FcdShellGuard {`,
489
508
  ` param([string]$CommandLine)`,
490
509
  ` if ([string]::IsNullOrWhiteSpace($CommandLine)) { return $null }`,
491
510
  ` $normalized = ($CommandLine -replace '\\s+', ' ').Trim()`,
511
+ ` $execView = $CommandLine`,
512
+ ` try { $execView = Get-FcdExecView -CommandLine $CommandLine } catch { }`,
513
+ ` $execNormalized = ($execView -replace '\\s+', ' ').Trim()`,
492
514
  ` $warnHit = $null`,
493
515
  ` foreach ($rule in $global:FcdGuardRules) {`,
494
516
  ` try {`,
495
- ` if ($rule.Regex.IsMatch($normalized) -or $rule.Regex.IsMatch($CommandLine)) {`,
517
+ ` # Built-in rules ask "can this text ACT?" — they see the execution`,
518
+ ` # view, so a commit message quoting a dangerous command is inert.`,
519
+ ` # Console custom rules define their own scope — they see the raw line.`,
520
+ ` $isCustom = ([string]$rule.Source -eq 'custom')`,
521
+ ` $target = if ($isCustom) { $CommandLine } else { $execView }`,
522
+ ` $targetNorm = if ($isCustom) { $normalized } else { $execNormalized }`,
523
+ ` if ($rule.Regex.IsMatch($targetNorm) -or $rule.Regex.IsMatch($target)) {`,
496
524
  ` # A block rule always outranks a warn rule matching the same line.`,
497
525
  ` if ($rule.Action -ne 'warn') { return $rule }`,
498
526
  ` if ($null -eq $warnHit) { $warnHit = $rule }`,
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.25.3"
2
+ "version": "1.25.4"
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.4",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {