fullcourtdefense-cli 1.21.40 → 1.22.0

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.
@@ -117,14 +117,15 @@ function buildGuardJs(nodePath, cliEntry) {
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
- `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,re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='monitor'?'monitor':'block',rules};}catch{return{mode:'block',rules:[]};}}`,
121
- `function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();for(const r of rules){try{if(r.re.test(n)||r.re.test(line))return r;}catch{}}return null;}`,
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==='monitor'?'monitor':'block',rules};}catch{return{mode:'block',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
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{}}`,
123
123
  `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
124
  `const args=process.argv.slice(2);if(!args.length)process.exit(0);if(process.env.FCD_CMD_GUARD==='off')delegate(args);`,
125
125
  `const line=args.join(' ');const{mode,rules}=loadRules();const hit=matchLine(line,rules);`,
126
126
  `if(!hit)delegate(args);`,
127
127
  `if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'allow');delegate(args);}`,
128
+ `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);}`,
128
129
  `console.error('[FullCourtDefense] BLOCKED ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
129
130
  `console.error('This command was not executed. Reported to your security dashboard.');`,
130
131
  `spoolEvent(hit,line,'block');process.exit(1);`,
@@ -718,23 +718,22 @@ async function runDaemon(args, config) {
718
718
  executingActionIds.add(action.id);
719
719
  // Cryptographic gate: never execute an action the control plane didn't
720
720
  // sign — a Firestore/backend compromise must not become fleet-wide RCE.
721
- const verdict = (0, machineActionVerify_1.verifyMachineAction)(action);
721
+ // Machine binding is enforced too: a correctly signed action targeting a
722
+ // DIFFERENT machine is rejected (bundle fetches send this machine's id, so
723
+ // a mismatch means misrouting/replay, not normal operation). Legacy fleets
724
+ // whose enrolled fingerprint drifted can set FCD_MACHINE_ACTION_ANY_MACHINE_OK=1
725
+ // locally while they re-enroll.
726
+ const localIdentity = (0, machineIdentity_1.getMachineIdentity)();
727
+ const verdict = (0, machineActionVerify_1.verifyMachineAction)(action, { localMachineId: localIdentity.machineId });
722
728
  if (!verdict.ok) {
723
729
  log(`Remote action REJECTED: ${action.type} (${action.id}) — ${verdict.reason}`);
724
- await reportMachineAction(action.id, 'failed', { error: `Signature verification failed: ${verdict.reason}` });
730
+ await reportMachineAction(action.id, 'failed', { error: `Action verification failed: ${verdict.reason}` });
725
731
  executingActionIds.delete(action.id);
726
732
  await uploadLogTail();
727
733
  return;
728
734
  }
729
735
  if (verdict.reason)
730
- log(`Remote action signature notice: ${verdict.reason}`);
731
- const localIdentity = (0, machineIdentity_1.getMachineIdentity)();
732
- if (action.machineId && action.machineId !== localIdentity.machineId) {
733
- // Warn-only for now: enrolled machines can carry legacy fingerprints that
734
- // differ from the freshly computed identity. Tighten to reject once the
735
- // fleet is fully on stable machine ids.
736
- log(`Remote action machine-binding notice: action targets ${action.machineId}, local id is ${localIdentity.machineId}.`);
737
- }
736
+ log(`Remote action verification notice: ${verdict.reason}`);
738
737
  await reportMachineAction(action.id, 'running');
739
738
  log(`Remote action started: ${action.type} (${action.id}).`);
740
739
  await uploadLogTail();
@@ -1,5 +1,15 @@
1
1
  export type DeterministicDirection = 'request' | 'response';
2
- export type DeterministicCategory = 'sensitive_file' | 'metadata_ssrf' | 'destructive_command' | 'destructive_sql' | 'infra_destroy' | 'reverse_shell' | 'secret_exfiltration' | 'honeypot';
2
+ export type DeterministicCategory = 'sensitive_file' | 'metadata_ssrf' | 'credential_command' | 'destructive_command' | 'destructive_sql' | 'infra_destroy' | 'reverse_shell' | 'secret_exfiltration' | 'honeypot';
3
+ /**
4
+ * Per-rule enforcement action (org-configured, shipped in the Local Safety
5
+ * snapshot). Absent everywhere = 'block' — the only behavior older CLIs and
6
+ * older snapshots know, so mixed fleets stay fail-strict.
7
+ * - 'block' — stop the action (today's behavior).
8
+ * - 'warn' — let the action proceed; surface + report the finding as a warning.
9
+ * - 'mask' — redact the matched secret where the surface can rewrite content
10
+ * (MCP tool responses); surfaces that cannot rewrite treat it as block.
11
+ */
12
+ export type LocalSafetyRuleAction = 'block' | 'warn' | 'mask';
3
13
  export interface DeterministicFinding {
4
14
  blocked: true;
5
15
  ruleId: string;
@@ -11,15 +21,26 @@ export interface DeterministicFinding {
11
21
  evidence: string;
12
22
  explanation?: string;
13
23
  policyHash?: string;
24
+ /** Org-configured enforcement action for the matched rule. Absent = 'block'. */
25
+ action?: LocalSafetyRuleAction;
26
+ /**
27
+ * The exact matched secret string, present only for secret_exfiltration
28
+ * findings — the seam 'mask' uses to redact tool responses. Never spooled.
29
+ */
30
+ matchedSecret?: string;
14
31
  }
15
32
  export interface LocalSafetyCustomBlock {
16
33
  id: string;
17
34
  categoryId: string;
18
35
  pattern: string;
19
36
  explanation?: string;
37
+ /** Absent = 'block'. */
38
+ action?: LocalSafetyRuleAction;
20
39
  }
21
40
  export interface LocalSafetyScanOptions {
22
41
  disabledBuiltInItemIds?: string[];
42
+ /** Built-in items with a NON-default action (absent item = 'block'). */
43
+ itemActions?: Record<string, LocalSafetyRuleAction>;
23
44
  customBlocks?: LocalSafetyCustomBlock[];
24
45
  policyHash?: string;
25
46
  cwd?: string;
@@ -37,3 +58,36 @@ export interface LocalSafetyScanOptions {
37
58
  export declare function scanDeterministicToolCall(toolName: string, toolArgs: Record<string, unknown>, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
38
59
  export declare function scanDeterministicTextResponse(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
39
60
  export declare function scanDeterministicPrompt(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
61
+ export interface DeterministicScanOutcome {
62
+ /** The finding that stops the action, if any. */
63
+ blockingFinding?: DeterministicFinding;
64
+ /** warn-action findings that matched — the action proceeds; report these. */
65
+ warnings: DeterministicFinding[];
66
+ }
67
+ export interface DeterministicTextResponseOutcome extends DeterministicScanOutcome {
68
+ /** mask-action secret findings that WERE redacted from the text. */
69
+ maskFindings: DeterministicFinding[];
70
+ /** The response text with every masked secret redacted. */
71
+ maskedText: string;
72
+ }
73
+ /**
74
+ * Resolve a scan under per-rule actions WITHOUT letting a warn rule shadow a
75
+ * block rule. Scans return the FIRST match, so a single pass would let one
76
+ * warn-mode rule hide a second, block-mode rule matching the same value. This
77
+ * re-scans with each warn rule suppressed until a blocking finding surfaces or
78
+ * nothing matches. Default (no itemActions configured) is a single pass
79
+ * returning today's behavior exactly.
80
+ *
81
+ * 'mask' degrades to block here: these surfaces (tool calls, prompts, shell
82
+ * commands) cannot rewrite content, so mask can only fail strict.
83
+ */
84
+ export declare function resolveDeterministicOutcome(scan: (options?: LocalSafetyScanOptions) => DeterministicFinding | undefined, options?: LocalSafetyScanOptions): DeterministicScanOutcome;
85
+ /**
86
+ * Resolve a TOOL RESPONSE under per-rule actions. The response is the one
87
+ * surface that can be rewritten, so 'mask' redacts here: each masked secret is
88
+ * replaced in the text and the REDACTED text is rescanned — a second secret of
89
+ * the same type is still found (rule suppression would hide it). Warn rules
90
+ * are suppressed as in resolveDeterministicOutcome. Any 'block' rule match
91
+ * blocks regardless of what was masked before it.
92
+ */
93
+ export declare function resolveDeterministicTextResponse(text: string, options?: LocalSafetyScanOptions): DeterministicTextResponseOutcome;
@@ -36,6 +36,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.scanDeterministicToolCall = scanDeterministicToolCall;
37
37
  exports.scanDeterministicTextResponse = scanDeterministicTextResponse;
38
38
  exports.scanDeterministicPrompt = scanDeterministicPrompt;
39
+ exports.resolveDeterministicOutcome = resolveDeterministicOutcome;
40
+ exports.resolveDeterministicTextResponse = resolveDeterministicTextResponse;
39
41
  const fs = __importStar(require("fs"));
40
42
  const os = __importStar(require("os"));
41
43
  const path = __importStar(require("path"));
@@ -190,6 +192,15 @@ const DEFAULT_DISABLED_BUILT_INS = new Set([
190
192
  // IDE). Off by default — orgs opt in via the Local Safety console toggle,
191
193
  // which the runtime snapshot then applies on every machine.
192
194
  'contextual_secret',
195
+ // Cloud token printers / secret fetchers used constantly by LEGITIMATE
196
+ // deploy tooling and dev loops. Off by default; orgs with stricter posture
197
+ // opt in via the console. (The rarely-legitimate forms — gh auth token,
198
+ // git credential fill, keychain dumps — are ON by default.)
199
+ 'gcloud_access_token_cmd',
200
+ 'gcloud_secret_access_cmd',
201
+ 'az_access_token_cmd',
202
+ 'aws_secret_fetch_cmd',
203
+ 'vault_read_cmd',
193
204
  ]);
194
205
  function evidence(value) {
195
206
  const compact = value.replace(/\s+/g, ' ').trim();
@@ -247,6 +258,10 @@ function disabled(options) {
247
258
  function isEnabled(itemId, options) {
248
259
  return !disabled(options).has(itemId);
249
260
  }
261
+ function actionFor(itemId, options) {
262
+ const action = options?.itemActions?.[itemId];
263
+ return action === 'warn' || action === 'mask' ? action : 'block';
264
+ }
250
265
  function builtIn(itemId, categoryId, category, ruleId, reason, findingEvidence, explanation, options) {
251
266
  if (!isEnabled(itemId, options))
252
267
  return undefined;
@@ -261,6 +276,7 @@ function builtIn(itemId, categoryId, category, ruleId, reason, findingEvidence,
261
276
  evidence: evidence(findingEvidence),
262
277
  explanation,
263
278
  policyHash: options?.policyHash,
279
+ action: actionFor(itemId, options),
264
280
  };
265
281
  }
266
282
  /**
@@ -301,6 +317,7 @@ function honeypotTouch(value, options) {
301
317
  reason: 'Blocked access to a decoy credential file (honeypot). No legitimate tool uses this file — this indicates credential-hunting behavior.',
302
318
  evidence: evidence(value),
303
319
  policyHash: options?.policyHash,
320
+ action: actionFor('honeypot_decoy_access', options),
304
321
  };
305
322
  }
306
323
  }
@@ -323,6 +340,7 @@ function customBlock(value, options) {
323
340
  evidence: evidence(value),
324
341
  explanation: block.explanation,
325
342
  policyHash: options?.policyHash,
343
+ action: block.action === 'warn' || block.action === 'mask' ? block.action : 'block',
326
344
  };
327
345
  }
328
346
  return undefined;
@@ -348,6 +366,20 @@ function containsSensitiveCredentialPath(value) {
348
366
  { itemId: 'aws_credentials', label: 'AWS credentials', re: /(?:^|\/)\.aws\/(?:credentials|config)(?:$|[/?#\s'"])/ },
349
367
  { itemId: 'kube_config', label: 'Kubernetes config', re: /(?:^|\/)\.kube\/config(?:$|[/?#\s'"])/ },
350
368
  { itemId: 'docker_auth', label: 'Docker auth config', re: /(?:^|\/)\.docker\/config\.json(?:$|[/?#\s'"])/ },
369
+ // GitHub CLI stores its OAuth token in hosts.yml — ~/.config/gh/ on Unix,
370
+ // AppData/Roaming/GitHub CLI/ on Windows (normalized+lowercased upstream).
371
+ { itemId: 'github_cli_token', label: 'GitHub CLI token', re: /(?:^|\/)(?:gh|github cli)\/hosts\.yml(?:$|[/?#\s'"])/ },
372
+ { itemId: 'git_credentials', label: 'Git plaintext credentials', re: /(?:^|\/)\.git-credentials(?:$|[/?#\s'"])/ },
373
+ { itemId: 'gcloud_credentials', label: 'gcloud credential store', re: /(?:^|\/)gcloud\/(?:credentials\.db|access_tokens\.db|legacy_credentials)(?:$|[/?#\s'"])/ },
374
+ { itemId: 'azure_tokens', label: 'Azure token cache', re: /(?:^|\/)\.azure\/(?:msal_token_cache|accesstokens\.json)/ },
375
+ { itemId: 'gnupg_private_keys', label: 'GnuPG private keys', re: /(?:^|\/)\.gnupg\/(?:private-keys-v1\.d|secring\.gpg)(?:$|[/?#\s'"])/ },
376
+ { itemId: 'pgpass', label: 'PostgreSQL password file', re: /(?:^|\/)(?:\.pgpass|pgpass\.conf)(?:$|[/?#\s'"])/ },
377
+ { itemId: 'mysql_cnf', label: 'MySQL credentials file', re: /(?:^|\/)\.my\.cnf(?:$|[/?#\s'"])/ },
378
+ { itemId: 'terraform_credentials', label: 'Terraform credentials', re: /credentials\.tfrc\.json(?:$|[/?#\s'"])/ },
379
+ { itemId: 'huggingface_token', label: 'Hugging Face token', re: /(?:^|\/)\.?huggingface\/token(?:$|[/?#\s'"])/ },
380
+ { itemId: 'cargo_credentials', label: 'Cargo registry credentials', re: /(?:^|\/)\.cargo\/credentials(?:\.toml)?(?:$|[/?#\s'"])/ },
381
+ { itemId: 'gem_credentials', label: 'RubyGems credentials', re: /(?:^|\/)\.gem\/credentials(?:$|[/?#\s'"])/ },
382
+ { itemId: 'composer_auth', label: 'Composer auth tokens', re: /(?:^|\/)\.composer\/auth\.json(?:$|[/?#\s'"])/ },
351
383
  { itemId: 'npmrc', label: 'npm credentials', re: /(?:^|\/)\.npmrc(?:$|[/?#\s'"])/ },
352
384
  { itemId: 'pypirc', label: 'PyPI credentials', re: /(?:^|\/)\.pypirc(?:$|[/?#\s'"])/ },
353
385
  { itemId: 'netrc', label: 'netrc credentials', re: /(?:^|\/)\.netrc(?:$|[/?#\s'"])/ },
@@ -369,6 +401,47 @@ function containsSensitiveCredentialPath(value) {
369
401
  }
370
402
  return undefined;
371
403
  }
404
+ /**
405
+ * Commands that PRINT stored credentials to stdout — the file-path rules are
406
+ * useless against them because the secret never comes from a watched file
407
+ * ("gh auth token" emits the same token that lives in hosts.yml). Obvious,
408
+ * rarely-legitimate-for-agents forms are on by default; cloud token printers
409
+ * that legitimate deploy tooling uses constantly are strict (off by default).
410
+ */
411
+ function credentialCommandReason(value) {
412
+ const lower = normalizeForCommand(value).toLowerCase();
413
+ if (/\bgh\s+auth\s+token\b/.test(lower) || (/\bgh\s+auth\s+status\b/.test(lower) && /(?:--show-token|\s-t\b)/.test(lower))) {
414
+ return { itemId: 'gh_auth_token_cmd', reason: 'GitHub CLI token print (gh auth token)' };
415
+ }
416
+ if (/\bgit\s+credential(?:-manager|-store|-cache)?\s+(?:fill|get)\b/.test(lower)) {
417
+ return { itemId: 'git_credential_fill', reason: 'git credential helper dump' };
418
+ }
419
+ if (/\bsecurity\s+(?:find-generic-password|find-internet-password)\b[^\n;|&]*\s-w\b/.test(lower) || /\bsecurity\s+dump-keychain\b/.test(lower)) {
420
+ return { itemId: 'macos_keychain_dump', reason: 'macOS keychain password dump' };
421
+ }
422
+ if (/\bkubectl\s+config\s+view\b[^\n;|&]*--raw\b/.test(lower)) {
423
+ return { itemId: 'kubectl_config_raw', reason: 'raw kubeconfig dump (kubectl config view --raw)' };
424
+ }
425
+ if (/\baws\s+configure\s+export-credentials\b/.test(lower) || /\baws\s+configure\s+get\b[^\n;|&]*secret/.test(lower)) {
426
+ return { itemId: 'aws_export_credentials', reason: 'AWS secret access key export' };
427
+ }
428
+ if (/\bgcloud\s+auth\b[^\n;|&]*\bprint-access-token\b/.test(lower)) {
429
+ return { itemId: 'gcloud_access_token_cmd', reason: 'gcloud access-token print' };
430
+ }
431
+ if (/\bgcloud\s+secrets\s+versions\s+access\b/.test(lower)) {
432
+ return { itemId: 'gcloud_secret_access_cmd', reason: 'GCP Secret Manager read' };
433
+ }
434
+ if (/\baz\s+account\s+get-access-token\b/.test(lower)) {
435
+ return { itemId: 'az_access_token_cmd', reason: 'Azure access-token print' };
436
+ }
437
+ if (/\baws\s+secretsmanager\s+get-secret-value\b/.test(lower) || /\baws\s+ssm\s+get-parameter\b[^\n;|&]*--with-decryption\b/.test(lower)) {
438
+ return { itemId: 'aws_secret_fetch_cmd', reason: 'AWS secret fetch' };
439
+ }
440
+ if (/\bvault\s+(?:kv\s+get|read)\b/.test(lower)) {
441
+ return { itemId: 'vault_read_cmd', reason: 'Vault secret read' };
442
+ }
443
+ return undefined;
444
+ }
372
445
  function destructiveCommandReason(value) {
373
446
  const text = normalizeForCommand(value);
374
447
  const lower = text.toLowerCase();
@@ -536,6 +609,12 @@ function scanTextValue(toolName, value, options) {
536
609
  if (reverseShell) {
537
610
  return builtIn(reverseShell.itemId, 'reverse_shells', 'reverse_shell', 'local-reverse-shell', `Blocked ${reverseShell.reason}.`, value, reverseShell.reason, options);
538
611
  }
612
+ const credentialCmd = credentialCommandReason(value);
613
+ if (credentialCmd) {
614
+ const finding = builtIn(credentialCmd.itemId, 'credential_commands', 'credential_command', 'local-credential-command', `Blocked credential-revealing command: ${credentialCmd.reason}.`, value, credentialCmd.reason, options);
615
+ if (finding)
616
+ return finding;
617
+ }
539
618
  const destructive = destructiveCommandReason(value);
540
619
  if (destructive) {
541
620
  return builtIn(destructive.itemId, 'destructive_filesystem', 'destructive_command', 'local-destructive-command', `Blocked ${destructive.reason}.`, value, destructive.reason, options);
@@ -608,7 +687,8 @@ function scanDeterministicToolCall(toolName, toolArgs, options) {
608
687
  || contextualSecretFromKeyPath(candidate.keyPath, candidate.value)
609
688
  || findContextualSecret(candidate.value);
610
689
  if (secret) {
611
- return builtIn(secret.itemId, 'secret_exfiltration', 'secret_exfiltration', 'local-secret-exfiltration', `Blocked outbound data containing ${secret.label}.`, secret.value, secret.label, options);
690
+ const finding = builtIn(secret.itemId, 'secret_exfiltration', 'secret_exfiltration', 'local-secret-exfiltration', `Blocked outbound data containing ${secret.label}.`, secret.value, secret.label, options);
691
+ return finding ? { ...finding, matchedSecret: secret.value } : undefined;
612
692
  }
613
693
  }
614
694
  }
@@ -623,7 +703,8 @@ function scanDeterministicTextResponse(text, options) {
623
703
  const secret = findSecret(text) || findContextualSecret(text);
624
704
  if (!secret)
625
705
  return undefined;
626
- return builtIn(secret.itemId, 'secret_exfiltration', 'secret_exfiltration', 'local-secret-in-tool-response', `Blocked tool response containing ${secret.label}.`, secret.value, secret.label, options);
706
+ const finding = builtIn(secret.itemId, 'secret_exfiltration', 'secret_exfiltration', 'local-secret-in-tool-response', `Blocked tool response containing ${secret.label}.`, secret.value, secret.label, options);
707
+ return finding ? { ...finding, matchedSecret: secret.value } : undefined;
627
708
  }
628
709
  function looksLikeQuestion(value) {
629
710
  return /^(?:what|why|how|explain|describe|is|are|can you explain)\b/i.test(value.trim());
@@ -667,6 +748,12 @@ function scanDeterministicPrompt(text, options) {
667
748
  if (reverseShell) {
668
749
  return builtIn(reverseShell.itemId, 'reverse_shells', 'reverse_shell', 'local-prompt-reverse-shell', `Blocked prompt containing ${reverseShell.reason}.`, trimmed, reverseShell.reason, options);
669
750
  }
751
+ const credentialCmd = credentialCommandReason(trimmed);
752
+ if (credentialCmd) {
753
+ const finding = builtIn(credentialCmd.itemId, 'credential_commands', 'credential_command', 'local-prompt-credential-command', `Blocked prompt containing ${credentialCmd.reason}.`, trimmed, credentialCmd.reason, options);
754
+ if (finding)
755
+ return finding;
756
+ }
670
757
  const destructive = destructiveCommandReason(trimmed);
671
758
  if (destructive) {
672
759
  return builtIn(destructive.itemId, 'destructive_filesystem', 'destructive_command', 'local-prompt-destructive-command', `Blocked prompt containing ${destructive.reason}.`, trimmed, destructive.reason, options);
@@ -706,6 +793,9 @@ function referencedScripts(command, cwd) {
706
793
  const tokens = splitCommand(command);
707
794
  const scripts = [];
708
795
  const runners = new Set(['bash', 'sh', 'zsh', 'python', 'python3', 'node', 'pwsh']);
796
+ // Wrappers that still EXECUTE the script that follows them ("sudo ./x.sh").
797
+ const execPrefixes = new Set(['sudo', 'time', 'nohup', 'exec', 'call', 'source', '.']);
798
+ const startsNewCommand = (prev) => prev === undefined || /[;&|]$/.test(prev) || execPrefixes.has(path.basename(prev).toLowerCase());
709
799
  for (let i = 0; i < tokens.length; i++) {
710
800
  const token = path.basename(tokens[i]).toLowerCase();
711
801
  if (runners.has(token)) {
@@ -719,9 +809,16 @@ function referencedScripts(command, cwd) {
719
809
  if (resolved)
720
810
  scripts.push(resolved);
721
811
  }
722
- const direct = safeResolveScriptPath(tokens[i], cwd);
723
- if (direct)
724
- scripts.push(direct);
812
+ // A bare script path only EXECUTES when it is the command word itself
813
+ // ("./deploy.sh --prod", "sudo ./x.sh", "a.sh && b.sh"). A script path that
814
+ // is an ARGUMENT of another command ("git add guard.test.js",
815
+ // "code scripts/x.py") is inert — content-scanning those blocked ordinary
816
+ // version-control and editor commands on our own test fixtures.
817
+ if (startsNewCommand(tokens[i - 1])) {
818
+ const direct = safeResolveScriptPath(tokens[i], cwd);
819
+ if (direct)
820
+ scripts.push(direct);
821
+ }
725
822
  }
726
823
  if (tokens[0]?.toLowerCase() === 'npm' && tokens[1]?.toLowerCase() === 'run' && tokens[2]) {
727
824
  const pkg = path.resolve(cwd || process.cwd(), 'package.json');
@@ -817,3 +914,83 @@ function scanReferencedScript(command, cwd, options) {
817
914
  }
818
915
  return undefined;
819
916
  }
917
+ const MAX_ACTION_PASSES = 10;
918
+ function withRuleSuppressed(options, finding) {
919
+ if (finding.source === 'custom') {
920
+ return {
921
+ ...(options || {}),
922
+ customBlocks: (options?.customBlocks || []).filter(block => block.id !== finding.itemId),
923
+ };
924
+ }
925
+ const baseDisabled = options?.disabledBuiltInItemIds || Array.from(DEFAULT_DISABLED_BUILT_INS);
926
+ return { ...(options || {}), disabledBuiltInItemIds: [...baseDisabled, finding.itemId] };
927
+ }
928
+ function findingAction(finding) {
929
+ return finding.action === 'warn' || finding.action === 'mask' ? finding.action : 'block';
930
+ }
931
+ /**
932
+ * Resolve a scan under per-rule actions WITHOUT letting a warn rule shadow a
933
+ * block rule. Scans return the FIRST match, so a single pass would let one
934
+ * warn-mode rule hide a second, block-mode rule matching the same value. This
935
+ * re-scans with each warn rule suppressed until a blocking finding surfaces or
936
+ * nothing matches. Default (no itemActions configured) is a single pass
937
+ * returning today's behavior exactly.
938
+ *
939
+ * 'mask' degrades to block here: these surfaces (tool calls, prompts, shell
940
+ * commands) cannot rewrite content, so mask can only fail strict.
941
+ */
942
+ function resolveDeterministicOutcome(scan, options) {
943
+ const warnings = [];
944
+ let current = options;
945
+ for (let pass = 0; pass < MAX_ACTION_PASSES; pass++) {
946
+ const finding = scan(current);
947
+ if (!finding)
948
+ return { warnings };
949
+ if (findingAction(finding) !== 'warn')
950
+ return { blockingFinding: finding, warnings };
951
+ warnings.push(finding);
952
+ current = withRuleSuppressed(current, finding);
953
+ }
954
+ // Pass budget exhausted with matches still appearing — fail strict.
955
+ const last = scan(current);
956
+ return last ? { blockingFinding: last, warnings } : { warnings };
957
+ }
958
+ /**
959
+ * Resolve a TOOL RESPONSE under per-rule actions. The response is the one
960
+ * surface that can be rewritten, so 'mask' redacts here: each masked secret is
961
+ * replaced in the text and the REDACTED text is rescanned — a second secret of
962
+ * the same type is still found (rule suppression would hide it). Warn rules
963
+ * are suppressed as in resolveDeterministicOutcome. Any 'block' rule match
964
+ * blocks regardless of what was masked before it.
965
+ */
966
+ function resolveDeterministicTextResponse(text, options) {
967
+ const warnings = [];
968
+ const maskFindings = [];
969
+ let currentOptions = options;
970
+ let currentText = text;
971
+ for (let pass = 0; pass < MAX_ACTION_PASSES; pass++) {
972
+ const finding = scanDeterministicTextResponse(currentText, currentOptions);
973
+ if (!finding)
974
+ return { warnings, maskFindings, maskedText: currentText };
975
+ const action = findingAction(finding);
976
+ if (action === 'block')
977
+ return { blockingFinding: finding, warnings, maskFindings, maskedText: currentText };
978
+ if (action === 'mask') {
979
+ if (!finding.matchedSecret || !currentText.includes(finding.matchedSecret)) {
980
+ // Nothing concrete to redact — mask degrades to block, never to allow.
981
+ return { blockingFinding: finding, warnings, maskFindings, maskedText: currentText };
982
+ }
983
+ maskFindings.push(finding);
984
+ currentText = currentText.split(finding.matchedSecret).join(`[REDACTED:${finding.itemId}]`);
985
+ }
986
+ else {
987
+ warnings.push(finding);
988
+ currentOptions = withRuleSuppressed(currentOptions, finding);
989
+ }
990
+ }
991
+ // Pass budget exhausted with matches still appearing — fail strict.
992
+ const last = scanDeterministicTextResponse(currentText, currentOptions);
993
+ return last
994
+ ? { blockingFinding: last, warnings, maskFindings, maskedText: currentText }
995
+ : { warnings, maskFindings, maskedText: currentText };
996
+ }
@@ -545,7 +545,7 @@ function localBlockUserMessage(finding) {
545
545
  }
546
546
  function spoolLocalFinding(input) {
547
547
  (0, telemetry_1.spoolEvent)({
548
- decision: 'block',
548
+ decision: input.decision || 'block',
549
549
  toolName: input.toolName,
550
550
  operation: input.operation,
551
551
  reason: input.finding.reason,
@@ -561,6 +561,16 @@ function spoolLocalFinding(input) {
561
561
  });
562
562
  (0, telemetry_1.triggerFlush)(true);
563
563
  }
564
+ /**
565
+ * warn-action rule matches: the action PROCEEDS — spool each finding as a
566
+ * 'warn' event so it lands in the org console/timeline without stopping work.
567
+ */
568
+ function spoolLocalWarnings(warnings, toolName, operation) {
569
+ for (const finding of warnings) {
570
+ dbg({ phase: 'local_deterministic_warn', tool: toolName, ruleId: finding.ruleId, itemId: finding.itemId });
571
+ spoolLocalFinding({ finding, toolName, operation, decision: 'warn' });
572
+ }
573
+ }
564
574
  async function hookCommand(args, config) {
565
575
  // Mode is server-authoritative (pulled from the cached runtime bundle), with
566
576
  // local flags as explicit overrides. Resolved after creds below.
@@ -768,7 +778,9 @@ async function hookCommand(args, config) {
768
778
  machineName: os.hostname(),
769
779
  expectedPolicyHash: effectivePolicyHash,
770
780
  });
771
- const localBlock = (0, deterministicGuard_1.scanDeterministicPrompt)(text, (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
781
+ const promptOutcome = (0, deterministicGuard_1.resolveDeterministicOutcome)(opts => (0, deterministicGuard_1.scanDeterministicPrompt)(text, opts), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
782
+ spoolLocalWarnings(promptOutcome.warnings, 'prompt', 'prompt');
783
+ const localBlock = promptOutcome.blockingFinding;
772
784
  if (localBlock) {
773
785
  dbg({ phase: 'local_deterministic_prompt_block', event, ruleId: localBlock.ruleId, category: localBlock.category });
774
786
  if (shadow) {
@@ -809,10 +821,12 @@ async function enforceActionPolicy(ctx) {
809
821
  machineName: os.hostname(),
810
822
  expectedPolicyHash: effectivePolicyHash,
811
823
  });
812
- const localBlock = (0, deterministicGuard_1.scanDeterministicToolCall)(call.toolName, call.toolArgs, (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot, {
824
+ const toolOutcome = (0, deterministicGuard_1.resolveDeterministicOutcome)(opts => (0, deterministicGuard_1.scanDeterministicToolCall)(call.toolName, call.toolArgs, opts), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot, {
813
825
  cwd: typeof call.toolArgs.cwd === 'string' ? call.toolArgs.cwd : process.cwd(),
814
826
  inspectScripts: event === 'shell',
815
827
  }));
828
+ spoolLocalWarnings(toolOutcome.warnings, call.toolName, event);
829
+ const localBlock = toolOutcome.blockingFinding;
816
830
  if (localBlock) {
817
831
  dbg({ phase: 'local_deterministic_block', event, tool: call.toolName, ruleId: localBlock.ruleId, category: localBlock.category });
818
832
  if (shadow) {
@@ -310,6 +310,43 @@ function contentToText(result) {
310
310
  return String(result);
311
311
  }
312
312
  }
313
+ /**
314
+ * Redact mask-action secrets from an MCP tool result IN PLACE of blocking it.
315
+ * Only the shapes contentToText() reads are rewritable (plain string, text
316
+ * content items); anything else reports fullyMasked=false and the caller
317
+ * blocks — mask must never leak the secret it exists to hide.
318
+ */
319
+ function maskToolResult(result, maskFindings) {
320
+ const secrets = maskFindings
321
+ .map(finding => ({ secret: finding.matchedSecret, replacement: `[REDACTED:${finding.itemId}]` }))
322
+ .filter((entry) => Boolean(entry.secret));
323
+ const redact = (text) => {
324
+ let out = text;
325
+ for (const { secret, replacement } of secrets)
326
+ out = out.split(secret).join(replacement);
327
+ return out;
328
+ };
329
+ let masked = result;
330
+ if (typeof result === 'string') {
331
+ masked = redact(result);
332
+ }
333
+ else {
334
+ const content = result?.content;
335
+ if (Array.isArray(content)) {
336
+ masked = {
337
+ ...result,
338
+ content: content.map(item => (item?.type === 'text' && typeof item.text === 'string')
339
+ ? { ...item, text: redact(item.text) }
340
+ : item),
341
+ };
342
+ }
343
+ }
344
+ // Fail-strict verification: every masked secret must be gone from what a
345
+ // client would actually read out of this result.
346
+ const remaining = contentToText(masked);
347
+ const fullyMasked = secrets.every(({ secret }) => !remaining.includes(secret));
348
+ return { masked, fullyMasked };
349
+ }
313
350
  class StdioMcpClient {
314
351
  child;
315
352
  buffer = '';
@@ -748,9 +785,9 @@ class McpGatewayServer {
748
785
  await this.downstreamReady;
749
786
  }
750
787
  /** Spool a locally-enforced deterministic block so it reaches Live Enforcement even offline. */
751
- spoolLocalFinding(finding, toolName, operation) {
788
+ spoolLocalFinding(finding, toolName, operation, decision = 'block') {
752
789
  (0, telemetry_1.spoolEvent)({
753
- decision: 'block',
790
+ decision,
754
791
  toolName,
755
792
  operation,
756
793
  reason: finding.reason,
@@ -764,7 +801,7 @@ class McpGatewayServer {
764
801
  policyHash: finding.policyHash,
765
802
  offlineEnforced: true,
766
803
  });
767
- (0, telemetry_1.triggerFlush)(true);
804
+ (0, telemetry_1.triggerFlush)(decision === 'block');
768
805
  }
769
806
  async handleToolCall(params) {
770
807
  await this.ensureDownstream();
@@ -833,7 +870,12 @@ class McpGatewayServer {
833
870
  machineName: os.hostname(),
834
871
  expectedPolicyHash,
835
872
  });
836
- const localBlock = (0, deterministicGuard_1.scanDeterministicToolCall)(toolName, toolArgs, (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
873
+ const requestOutcome = (0, deterministicGuard_1.resolveDeterministicOutcome)(opts => (0, deterministicGuard_1.scanDeterministicToolCall)(toolName, toolArgs, opts), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
874
+ for (const warning of requestOutcome.warnings) {
875
+ this.spoolLocalFinding(warning, toolName, operation, 'warn');
876
+ process.stderr.write(`AgentGuard warning (${warning.itemId}): ${warning.reason}\n`);
877
+ }
878
+ const localBlock = requestOutcome.blockingFinding;
837
879
  if (localBlock) {
838
880
  this.spoolLocalFinding(localBlock, toolName, operation);
839
881
  const origin = localBlock.source === 'custom' ? 'org custom rule' : 'built-in Local Safety rule';
@@ -933,13 +975,34 @@ class McpGatewayServer {
933
975
  throw new Error(parts.join(' | '));
934
976
  }
935
977
  }
936
- const rawResult = await this.downstream.callTool(toolName, toolArgs);
937
- const localResponseBlock = (0, deterministicGuard_1.scanDeterministicTextResponse)(contentToText(rawResult), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
978
+ let rawResult = await this.downstream.callTool(toolName, toolArgs);
979
+ const responseOutcome = (0, deterministicGuard_1.resolveDeterministicTextResponse)(contentToText(rawResult), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
980
+ for (const warning of responseOutcome.warnings) {
981
+ this.spoolLocalFinding(warning, toolName, operation, 'warn');
982
+ process.stderr.write(`AgentGuard warning (${warning.itemId}): ${warning.reason}\n`);
983
+ }
984
+ const localResponseBlock = responseOutcome.blockingFinding;
938
985
  if (localResponseBlock) {
939
986
  this.spoolLocalFinding(localResponseBlock, toolName, operation);
940
987
  const responseOrigin = localResponseBlock.source === 'custom' ? 'org custom rule' : 'built-in Local Safety rule';
941
988
  throw new Error(`${localResponseBlock.reason} (${responseOrigin} "${localResponseBlock.itemId}", ${localResponseBlock.ruleId}: ${localResponseBlock.evidence}) — logged to your org's console; admins manage rules under Shield → Local Safety.`);
942
989
  }
990
+ if (responseOutcome.maskFindings.length > 0) {
991
+ // mask-action rules: redact the matched secrets in place. If the result
992
+ // shape cannot be rewritten (secret hidden in a non-text payload), fail
993
+ // strict and block — a mask rule must never leak what it was set to hide.
994
+ const { masked, fullyMasked } = maskToolResult(rawResult, responseOutcome.maskFindings);
995
+ if (!fullyMasked) {
996
+ const finding = responseOutcome.maskFindings[0];
997
+ this.spoolLocalFinding(finding, toolName, operation);
998
+ throw new Error(`${finding.reason} (rule "${finding.itemId}" is set to mask, but this tool response could not be rewritten — blocked instead.)`);
999
+ }
1000
+ rawResult = masked;
1001
+ for (const finding of responseOutcome.maskFindings) {
1002
+ this.spoolLocalFinding(finding, toolName, operation, 'mask');
1003
+ process.stderr.write(`AgentGuard masked a ${finding.itemId} in the ${toolName} response.\n`);
1004
+ }
1005
+ }
943
1006
  const finalResult = this.gatewayConfig.scanResponse
944
1007
  ? await this.api.scanToolResponse({ toolName, operation, toolArgs, result: rawResult })
945
1008
  : rawResult;
@@ -103,8 +103,8 @@ function buildGuardJs(nodePath, cliEntry) {
103
103
  `const NODE_PATH=${JSON.stringify(nodePath)};`,
104
104
  `const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
105
105
  `const BLOCK_CODE=${BLOCK_CODE};`,
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,re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='monitor'?'monitor':'block',rules};}catch{return{mode:'block',rules:[]};}}`,
107
- `function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();for(const r of rules){try{if(r.re.test(n)||r.re.test(line))return r;}catch{}}return null;}`,
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==='monitor'?'monitor':'block',rules};}catch{return{mode:'block',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;}`,
108
108
  `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
109
  `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
110
  `let argv=process.argv.slice(2);if(argv[0]==='--')argv=argv.slice(1);const line=argv.join(' ');`,
@@ -113,6 +113,7 @@ function buildGuardJs(nodePath, cliEntry) {
113
113
  `const{mode,rules}=loadRules();const hit=matchLine(line,rules);`,
114
114
  `if(!hit)process.exit(0);`,
115
115
  `if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'allow');process.exit(0);}`,
116
+ `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);}`,
116
117
  `console.error('[FullCourtDefense] BLOCKED ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
117
118
  `console.error('This command was not executed. Reported to your security dashboard.');`,
118
119
  `spoolEvent(hit,line,'block');process.exit(BLOCK_CODE);`,
@@ -8,6 +8,13 @@ export interface ShellGuardRule {
8
8
  pattern: string;
9
9
  reason: string;
10
10
  source: 'builtin' | 'custom';
11
+ /**
12
+ * Per-rule enforcement action from the console (Local Safety). Absent =
13
+ * 'block'. 'warn' → yellow notice, the command still runs (like monitor
14
+ * mode, but for this one rule). 'mask' is meaningless for a typed command
15
+ * and degrades to block.
16
+ */
17
+ action?: 'block' | 'warn';
11
18
  }
12
19
  interface ShellGuardRulesFile {
13
20
  updatedAt: string;
@@ -287,6 +287,44 @@ function cachedDisabledItemIds() {
287
287
  catch { /* no snapshot cache yet */ }
288
288
  return disabled;
289
289
  }
290
+ /**
291
+ * Per-item enforcement actions from the cached local-safety snapshots. Only
292
+ * 'warn' survives here: 'mask' cannot rewrite a typed command (degrades to
293
+ * block by omission), and cross-snapshot conflicts resolve fail-strict — an
294
+ * explicit 'block' anywhere wins over a 'warn' elsewhere.
295
+ */
296
+ function cachedItemActions() {
297
+ const warn = new Set();
298
+ const blocked = new Set();
299
+ try {
300
+ for (const file of fs.readdirSync(SNAPSHOT_CACHE_DIR)) {
301
+ if (!file.startsWith('local-safety-') || !file.endsWith('.json'))
302
+ continue;
303
+ try {
304
+ const parsed = JSON.parse(fs.readFileSync(path.join(SNAPSHOT_CACHE_DIR, file), 'utf8'));
305
+ const actions = parsed?.snapshot?.itemActions;
306
+ if (!actions || typeof actions !== 'object')
307
+ continue;
308
+ for (const [itemId, action] of Object.entries(actions)) {
309
+ if (typeof itemId !== 'string' || !itemId)
310
+ continue;
311
+ if (action === 'warn')
312
+ warn.add(itemId);
313
+ else
314
+ blocked.add(itemId);
315
+ }
316
+ }
317
+ catch { /* skip malformed snapshot */ }
318
+ }
319
+ }
320
+ catch { /* no snapshot cache yet */ }
321
+ const result = new Map();
322
+ for (const itemId of warn) {
323
+ if (!blocked.has(itemId))
324
+ result.set(itemId, 'warn');
325
+ }
326
+ return result;
327
+ }
290
328
  /** Org custom block patterns from the cached local-safety snapshots (offline read). */
291
329
  function cachedCustomRules() {
292
330
  const rules = [];
@@ -311,6 +349,8 @@ function cachedCustomRules() {
311
349
  pattern: escapeDotNetRegex(block.pattern.trim()),
312
350
  reason: typeof block.explanation === 'string' && block.explanation ? block.explanation : `matched custom safety pattern "${block.pattern.trim()}"`,
313
351
  source: 'custom',
352
+ // 'mask' cannot rewrite a typed command — only 'warn' relaxes here.
353
+ ...(block.action === 'warn' ? { action: 'warn' } : {}),
314
354
  });
315
355
  }
316
356
  }
@@ -328,21 +368,27 @@ function cachedCustomRules() {
328
368
  return true;
329
369
  });
330
370
  }
371
+ /** Builtins minus dashboard-disabled, with per-rule console actions applied. */
372
+ function effectiveBuiltinRules() {
373
+ const disabled = cachedDisabledItemIds();
374
+ const itemActions = cachedItemActions();
375
+ return BUILTIN_RULES
376
+ .filter(rule => !disabled.has(rule.id))
377
+ .map(rule => itemActions.get(rule.id) === 'warn' ? { ...rule, action: 'warn' } : rule);
378
+ }
331
379
  /** Write (or rewrite) the ruleset the profile guard loads at shell startup. */
332
380
  function writeShellGuardRules() {
333
- const disabled = cachedDisabledItemIds();
334
381
  const data = {
335
382
  updatedAt: new Date().toISOString(),
336
383
  mode: cachedMode(),
337
- rules: [...BUILTIN_RULES.filter(rule => !disabled.has(rule.id)), ...cachedCustomRules()],
384
+ rules: [...effectiveBuiltinRules(), ...cachedCustomRules()],
338
385
  };
339
386
  fs.writeFileSync(GUARD_RULES_PATH, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 });
340
387
  return data;
341
388
  }
342
389
  /** The active ruleset in memory (builtins minus dashboard-disabled + org custom). */
343
390
  function activeShellGuardRules() {
344
- const disabled = cachedDisabledItemIds();
345
- return [...BUILTIN_RULES.filter(rule => !disabled.has(rule.id)), ...cachedCustomRules()];
391
+ return [...effectiveBuiltinRules(), ...cachedCustomRules()];
346
392
  }
347
393
  /**
348
394
  * Evaluate a typed command line WITHOUT executing it — the dry-run classifier
@@ -428,6 +474,7 @@ function buildGuardPs1(nodePath, cliEntry) {
428
474
  ` Severity = [string]$_.severity`,
429
475
  ` Reason = [string]$_.reason`,
430
476
  ` Source = [string]$_.source`,
477
+ ` Action = $(if ([string]$_.action -eq 'warn') { 'warn' } else { 'block' })`,
431
478
  ` Regex = [regex]::new([string]$_.pattern, 'IgnoreCase')`,
432
479
  ` }`,
433
480
  ` } catch { $null }`,
@@ -439,12 +486,17 @@ function buildGuardPs1(nodePath, cliEntry) {
439
486
  ` param([string]$CommandLine)`,
440
487
  ` if ([string]::IsNullOrWhiteSpace($CommandLine)) { return $null }`,
441
488
  ` $normalized = ($CommandLine -replace '\\s+', ' ').Trim()`,
489
+ ` $warnHit = $null`,
442
490
  ` foreach ($rule in $global:FcdGuardRules) {`,
443
491
  ` try {`,
444
- ` if ($rule.Regex.IsMatch($normalized) -or $rule.Regex.IsMatch($CommandLine)) { return $rule }`,
492
+ ` if ($rule.Regex.IsMatch($normalized) -or $rule.Regex.IsMatch($CommandLine)) {`,
493
+ ` # A block rule always outranks a warn rule matching the same line.`,
494
+ ` if ($rule.Action -ne 'warn') { return $rule }`,
495
+ ` if ($null -eq $warnHit) { $warnHit = $rule }`,
496
+ ` }`,
445
497
  ` } catch { }`,
446
498
  ` }`,
447
- ` return $null`,
499
+ ` return $warnHit`,
448
500
  `}`,
449
501
  ``,
450
502
  `function global:Write-FcdGuardEvent {`,
@@ -495,6 +547,14 @@ function buildGuardPs1(nodePath, cliEntry) {
495
547
  ` [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()`,
496
548
  ` return`,
497
549
  ` }`,
550
+ ` # Per-rule 'warn' action from the console: notice + the command runs.`,
551
+ ` if ($hit.Action -eq 'warn') {`,
552
+ ` Write-Host ''`,
553
+ ` Write-Host ('[FullCourtDefense] warning [' + $hit.Severity + ']: ' + $hit.Reason + ' (rule ' + $hit.Id + ') — allowed by org policy, reported to your security dashboard.') -ForegroundColor Yellow`,
554
+ ` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision 'warn'`,
555
+ ` [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()`,
556
+ ` return`,
557
+ ` }`,
498
558
  ` [Microsoft.PowerShell.PSConsoleReadLine]::RevertLine()`,
499
559
  ` Write-Host ''`,
500
560
  ` Write-Host ('[FullCourtDefense] BLOCKED [' + $hit.Severity + ']: ' + $hit.Reason + ' (rule ' + $hit.Id + ')') -ForegroundColor Red`,
@@ -1,7 +1,9 @@
1
- import { LocalSafetyCustomBlock, LocalSafetyScanOptions } from './commands/deterministicGuard';
1
+ import { LocalSafetyCustomBlock, LocalSafetyRuleAction, LocalSafetyScanOptions } from './commands/deterministicGuard';
2
2
  export interface LocalSafetySnapshot {
3
3
  policyHash: string;
4
4
  disabledBuiltInItemIds: string[];
5
+ /** Built-in items with a NON-default enforcement action (absent = 'block'). */
6
+ itemActions?: Record<string, LocalSafetyRuleAction>;
5
7
  customBlocks: LocalSafetyCustomBlock[];
6
8
  /** Org-managed trusted script paths — content scanning skipped under these. */
7
9
  trustedScriptPaths?: string[];
@@ -67,14 +67,29 @@ function writeCache(file, snapshot) {
67
67
  }
68
68
  catch { /* best-effort */ }
69
69
  }
70
+ function toRuleAction(value) {
71
+ return value === 'warn' || value === 'mask' ? value : undefined;
72
+ }
70
73
  function toSnapshot(data) {
71
74
  if (!data || typeof data !== 'object')
72
75
  return undefined;
76
+ // Only non-default ('warn'/'mask') entries are kept — anything unrecognized
77
+ // falls back to 'block', so a malformed value can only fail strict.
78
+ let itemActions;
79
+ if (data.itemActions && typeof data.itemActions === 'object' && !Array.isArray(data.itemActions)) {
80
+ for (const [itemId, raw] of Object.entries(data.itemActions)) {
81
+ const action = toRuleAction(raw);
82
+ if (!action)
83
+ continue;
84
+ (itemActions ||= {})[itemId] = action;
85
+ }
86
+ }
73
87
  return {
74
88
  policyHash: typeof data.policyHash === 'string' ? data.policyHash : '',
75
89
  disabledBuiltInItemIds: Array.isArray(data.disabledBuiltInItemIds)
76
90
  ? data.disabledBuiltInItemIds.filter((item) => typeof item === 'string')
77
91
  : [],
92
+ itemActions,
78
93
  customBlocks: Array.isArray(data.customBlocks)
79
94
  ? data.customBlocks
80
95
  .filter((item) => item && typeof item.pattern === 'string')
@@ -83,6 +98,7 @@ function toSnapshot(data) {
83
98
  categoryId: String(item.categoryId || 'custom'),
84
99
  pattern: String(item.pattern),
85
100
  explanation: typeof item.explanation === 'string' ? item.explanation : undefined,
101
+ action: toRuleAction(item.action),
86
102
  }))
87
103
  : [],
88
104
  trustedScriptPaths: Array.isArray(data.trustedScriptPaths)
@@ -134,6 +150,7 @@ async function loadLocalSafetySnapshot(input) {
134
150
  function snapshotToScanOptions(snapshot, extra = {}) {
135
151
  return {
136
152
  disabledBuiltInItemIds: snapshot?.disabledBuiltInItemIds,
153
+ itemActions: snapshot?.itemActions,
137
154
  customBlocks: snapshot?.customBlocks,
138
155
  trustedScriptPaths: snapshot?.trustedScriptPaths,
139
156
  policyHash: snapshot?.policyHash,
@@ -35,11 +35,24 @@ export interface MachineActionVerdict {
35
35
  ok: boolean;
36
36
  reason?: string;
37
37
  }
38
+ export interface MachineActionVerifyContext {
39
+ /**
40
+ * The local machine's computed identity. When provided, a signed action bound
41
+ * to a DIFFERENT machineId is rejected — a valid control-plane signature for
42
+ * machine A must never execute on machine B (misrouted bundle, replay, or a
43
+ * compromised delivery path). Actions without a machineId binding (legacy)
44
+ * are unaffected.
45
+ */
46
+ localMachineId?: string;
47
+ }
38
48
  /**
39
49
  * Verify a machine action before execution. Rejects when:
40
50
  * - the signature is missing (unless the local escape hatch is set),
41
51
  * - the signature's keyId is unknown,
42
52
  * - the Ed25519 verification fails (any signed field was tampered with),
43
- * - the action is already expired.
53
+ * - the action is already expired,
54
+ * - the action is bound to a different machineId than this machine
55
+ * (when `context.localMachineId` is provided; escape hatch:
56
+ * FCD_MACHINE_ACTION_ANY_MACHINE_OK=1 for legacy-fingerprint fleets).
44
57
  */
45
- export declare function verifyMachineAction(action: VerifiableMachineAction): MachineActionVerdict;
58
+ export declare function verifyMachineAction(action: VerifiableMachineAction, context?: MachineActionVerifyContext): MachineActionVerdict;
@@ -92,21 +92,44 @@ function resolvePublicKeyPem(keyId) {
92
92
  function unsignedAllowed() {
93
93
  return process.env.FCD_MACHINE_ACTION_UNSIGNED_OK === '1';
94
94
  }
95
+ function anyMachineAllowed() {
96
+ return process.env.FCD_MACHINE_ACTION_ANY_MACHINE_OK === '1';
97
+ }
95
98
  /**
96
99
  * Verify a machine action before execution. Rejects when:
97
100
  * - the signature is missing (unless the local escape hatch is set),
98
101
  * - the signature's keyId is unknown,
99
102
  * - the Ed25519 verification fails (any signed field was tampered with),
100
- * - the action is already expired.
103
+ * - the action is already expired,
104
+ * - the action is bound to a different machineId than this machine
105
+ * (when `context.localMachineId` is provided; escape hatch:
106
+ * FCD_MACHINE_ACTION_ANY_MACHINE_OK=1 for legacy-fingerprint fleets).
101
107
  */
102
- function verifyMachineAction(action) {
108
+ function verifyMachineAction(action, context) {
103
109
  const expiresAt = Date.parse(action.expiresAt);
104
110
  if (Number.isFinite(expiresAt) && expiresAt <= Date.now()) {
105
111
  return { ok: false, reason: 'Action is expired.' };
106
112
  }
113
+ // Machine binding is enforced BEFORE signature checks so the rejection reason
114
+ // is precise: the machineId is part of the signed canonical payload, so a
115
+ // mismatch here means a correctly signed action was delivered to the WRONG
116
+ // machine — not a forgery, but never something this machine may run.
117
+ if (context?.localMachineId && action.machineId && action.machineId !== context.localMachineId) {
118
+ if (anyMachineAllowed()) {
119
+ return verifySignatureOnly(action, `MACHINE-ID MISMATCH accepted via FCD_MACHINE_ACTION_ANY_MACHINE_OK=1 (action targets ${action.machineId}, local id is ${context.localMachineId}).`);
120
+ }
121
+ return {
122
+ ok: false,
123
+ reason: `Action is bound to machine ${action.machineId}, but this machine's id is ${context.localMachineId}. Rejected: signed remote actions only run on the machine they target. If this machine was re-imaged or its fingerprint changed, re-enroll it (\`fullcourtdefense onboard\`) or set FCD_MACHINE_ACTION_ANY_MACHINE_OK=1 as a temporary, local escape hatch.`,
124
+ };
125
+ }
126
+ return verifySignatureOnly(action);
127
+ }
128
+ /** Signature/expiry portion of verification (machine binding handled by the caller above). */
129
+ function verifySignatureOnly(action, notice) {
107
130
  if (!action.signature?.signature) {
108
131
  if (unsignedAllowed()) {
109
- return { ok: true, reason: 'UNSIGNED action accepted via FCD_MACHINE_ACTION_UNSIGNED_OK=1.' };
132
+ return { ok: true, reason: withNotice('UNSIGNED action accepted via FCD_MACHINE_ACTION_UNSIGNED_OK=1.', notice) };
110
133
  }
111
134
  return {
112
135
  ok: false,
@@ -125,10 +148,13 @@ function verifyMachineAction(action) {
125
148
  const key = crypto.createPublicKey(publicKeyPem);
126
149
  const valid = crypto.verify(null, canonicalPayload(action), key, Buffer.from(signature.signature, 'base64'));
127
150
  return valid
128
- ? { ok: true }
151
+ ? { ok: true, ...(notice ? { reason: notice } : {}) }
129
152
  : { ok: false, reason: 'Invalid signature — the action payload does not match what the control plane signed.' };
130
153
  }
131
154
  catch (error) {
132
155
  return { ok: false, reason: `Signature verification error: ${error.message}` };
133
156
  }
134
157
  }
158
+ function withNotice(base, notice) {
159
+ return notice ? `${notice} ${base}` : base;
160
+ }
@@ -1,7 +1,7 @@
1
1
  export interface SpoolEvent {
2
2
  eventId: string;
3
3
  type: 'verdict';
4
- decision: 'allow' | 'block' | 'approval' | 'mask';
4
+ decision: 'allow' | 'block' | 'approval' | 'warn' | 'mask';
5
5
  toolName?: string;
6
6
  operation?: string;
7
7
  reason?: string;
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.21.40"
2
+ "version": "1.22.0"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.21.40",
3
+ "version": "1.22.0",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {