fullcourtdefense-cli 1.36.0 → 1.36.2

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.
Files changed (63) hide show
  1. package/dist/actionPolicyEngine.d.ts +23 -0
  2. package/dist/actionPolicyEngine.js +58 -19
  3. package/dist/approvalPrompt.d.ts +15 -0
  4. package/dist/approvalPrompt.js +157 -20
  5. package/dist/askDialog.d.ts +2 -0
  6. package/dist/askDialog.js +15 -1
  7. package/dist/askHeader.d.ts +81 -0
  8. package/dist/askHeader.js +573 -0
  9. package/dist/blockExplanation.d.ts +9 -0
  10. package/dist/blockExplanation.js +14 -0
  11. package/dist/callerEnv.d.ts +1 -1
  12. package/dist/callerEnv.js +14 -0
  13. package/dist/cloudGrant.d.ts +46 -0
  14. package/dist/cloudGrant.js +420 -0
  15. package/dist/cloudGrantTools.d.ts +97 -0
  16. package/dist/cloudGrantTools.js +1112 -0
  17. package/dist/commandNarrative.d.ts +8 -0
  18. package/dist/commandNarrative.js +222 -49
  19. package/dist/commands/cmdGuard.js +1 -1
  20. package/dist/commands/daemon.js +14 -0
  21. package/dist/commands/deterministicGuard.d.ts +8 -2
  22. package/dist/commands/deterministicGuard.js +219 -55
  23. package/dist/commands/discover.js +37 -14
  24. package/dist/commands/discoverAgentFiles.d.ts +2 -0
  25. package/dist/commands/discoverAgentFiles.js +1 -0
  26. package/dist/commands/discoverPaths.d.ts +4 -0
  27. package/dist/commands/discoverPaths.js +19 -1
  28. package/dist/commands/discoverProjectRoots.d.ts +72 -0
  29. package/dist/commands/discoverProjectRoots.js +327 -0
  30. package/dist/commands/discoverProxy.d.ts +1 -1
  31. package/dist/commands/discoverProxy.js +1 -1
  32. package/dist/commands/discoverSecrets.d.ts +44 -0
  33. package/dist/commands/discoverSecrets.js +158 -14
  34. package/dist/commands/hook.js +10 -7
  35. package/dist/commands/mcpGateway.js +9 -7
  36. package/dist/commands/posixShellGuard.js +1 -1
  37. package/dist/commands/shellGuard.js +70 -25
  38. package/dist/commands/uninstallAll.d.ts +1 -7
  39. package/dist/commands/uninstallAll.js +6 -45
  40. package/dist/config.d.ts +1 -1
  41. package/dist/config.js +3 -3
  42. package/dist/credentialGrant.d.ts +48 -0
  43. package/dist/credentialGrant.js +29 -18
  44. package/dist/credentialStore.js +1 -1
  45. package/dist/devConfirm.d.ts +44 -3
  46. package/dist/devConfirm.js +166 -30
  47. package/dist/distress.d.ts +2 -0
  48. package/dist/distress.js +2 -0
  49. package/dist/grantTargets.d.ts +21 -0
  50. package/dist/grantTargets.js +235 -0
  51. package/dist/localSafetySnapshot.js +2 -0
  52. package/dist/msiProductCode.d.ts +10 -0
  53. package/dist/msiProductCode.js +124 -0
  54. package/dist/notify.js +1 -0
  55. package/dist/selfTest.d.ts +1 -1
  56. package/dist/selfTest.js +1 -1
  57. package/dist/selfUpdate.d.ts +51 -2
  58. package/dist/selfUpdate.js +217 -19
  59. package/dist/shellAst.d.ts +2 -0
  60. package/dist/shellAst.js +7 -1
  61. package/dist/shellAstLoader.js +12 -1
  62. package/dist/version.json +1 -1
  63. package/package.json +6 -1
@@ -33,7 +33,13 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.probeWindowsFileAttributes = void 0;
37
+ exports.dockerConfigCredentialFacts = dockerConfigCredentialFacts;
38
+ exports.isCloudPlaceholderAttributes = isCloudPlaceholderAttributes;
39
+ exports.isCloudSyncedPath = isCloudSyncedPath;
40
+ exports.dropCloudPlaceholders = dropCloudPlaceholders;
36
41
  exports.scanSecrets = scanSecrets;
42
+ const child_process_1 = require("child_process");
37
43
  const crypto = __importStar(require("crypto"));
38
44
  const fs = __importStar(require("fs"));
39
45
  const os = __importStar(require("os"));
@@ -158,17 +164,58 @@ function looksLikePlaceholder(raw, line) {
158
164
  return true;
159
165
  return false;
160
166
  }
167
+ /** Fields of a Docker `auths` entry that carry a usable credential (docker/cli configfile AuthConfig). */
168
+ const DOCKER_INLINE_CREDENTIAL_KEYS = new Set(['auth', 'password', 'identitytoken', 'registrytoken']);
169
+ /**
170
+ * Whether ~/.docker/config.json holds credentials itself. `auths` entries that
171
+ * only name a registry are metadata when `credsStore` / `credHelpers` keep the
172
+ * secret in the OS store (Docker Desktop on Windows: `credsStore: desktop` =
173
+ * Windows Credential Manager). Counts only; never returns a value.
174
+ */
175
+ function dockerConfigCredentialFacts(content) {
176
+ let config;
177
+ try {
178
+ config = JSON.parse(content);
179
+ }
180
+ catch {
181
+ // Unparseable: fall back to "a credential field with a non-empty string value".
182
+ const inline = /"(?:auth|password|identitytoken|registrytoken)"\s*:\s*"[^"\s]+"/i.test(content);
183
+ return { inlineRegistries: inline ? 1 : 0, usesCredentialStore: /"(?:credsStore|credHelpers)"\s*:/.test(content) };
184
+ }
185
+ if (!config || typeof config !== 'object')
186
+ return { inlineRegistries: 0, usesCredentialStore: false };
187
+ const record = config;
188
+ const credsStore = typeof record.credsStore === 'string' && record.credsStore.trim() !== '';
189
+ const credHelpers = !!record.credHelpers && typeof record.credHelpers === 'object' && Object.keys(record.credHelpers).length > 0;
190
+ let inlineRegistries = 0;
191
+ const auths = record.auths;
192
+ if (auths && typeof auths === 'object' && !Array.isArray(auths)) {
193
+ for (const entry of Object.values(auths)) {
194
+ if (!entry || typeof entry !== 'object')
195
+ continue;
196
+ const hasInline = Object.entries(entry).some(([key, value]) => DOCKER_INLINE_CREDENTIAL_KEYS.has(key.toLowerCase()) && typeof value === 'string' && value.trim() !== '');
197
+ if (hasInline)
198
+ inlineRegistries += 1;
199
+ }
200
+ }
201
+ return { inlineRegistries, usesCredentialStore: credsStore || credHelpers };
202
+ }
161
203
  function scanCredentialStorePosture(filePath, content, category, out, seen) {
162
204
  const normalized = filePath.replace(/\\/g, '/').toLowerCase();
163
- if (normalized.endsWith('/.docker/config.json') && /"auths"\s*:|"auth"\s*:|"identitytoken"\s*:/i.test(content)) {
164
- pushStoreFinding(out, seen, {
165
- filePath,
166
- category,
167
- provider: 'docker',
168
- severity: 'high',
169
- title: 'Docker registry credentials',
170
- detail: 'Docker config contains registry auth material that agent tools or local malware can read.',
171
- });
205
+ if (normalized.endsWith('/.docker/config.json')) {
206
+ const docker = dockerConfigCredentialFacts(content);
207
+ if (docker.inlineRegistries > 0) {
208
+ pushStoreFinding(out, seen, {
209
+ filePath,
210
+ category,
211
+ provider: 'docker',
212
+ severity: 'high',
213
+ title: 'Docker registry credentials',
214
+ detail: docker.usesCredentialStore
215
+ ? `Docker config stores credentials inline for ${docker.inlineRegistries} registr${docker.inlineRegistries === 1 ? 'y' : 'ies'} next to a credential store; the inline entries bypass it and agent tools or local malware can read them.`
216
+ : `Docker config stores credentials inline for ${docker.inlineRegistries} registr${docker.inlineRegistries === 1 ? 'y' : 'ies'} that agent tools or local malware can read.`,
217
+ });
218
+ }
172
219
  }
173
220
  if (normalized.endsWith('/.kube/config') && /\b(?:token|client-key-data|client-certificate-data)\s*:/i.test(content)) {
174
221
  pushStoreFinding(out, seen, {
@@ -402,10 +449,15 @@ function scanSshKeys(home, out, seen, excludedRoots = []) {
402
449
  }
403
450
  return scanned;
404
451
  }
452
+ // Every root starts at depth 0 in one breadth-first queue, and a folder is
453
+ // walked once: a project root that also sits under the home folder is walked
454
+ // from its own top level (depth 0) before the home walk could reach it at
455
+ // depth 4+, and its files are never read twice.
405
456
  function collectEnvFiles(roots, maxDepth, excludedRoots = []) {
406
457
  const found = [];
407
458
  let excluded = 0;
408
459
  let depthLimited = false;
460
+ const visited = new Set();
409
461
  const queue = roots.map(dir => ({ dir, depth: 0 }));
410
462
  while (queue.length > 0 && found.length < MAX_ENV_FILES) {
411
463
  const { dir, depth } = queue.shift();
@@ -413,6 +465,10 @@ function collectEnvFiles(roots, maxDepth, excludedRoots = []) {
413
465
  excluded += 1;
414
466
  continue;
415
467
  }
468
+ const key = (0, discoverPaths_1.normalizedPath)(dir);
469
+ if (visited.has(key))
470
+ continue;
471
+ visited.add(key);
416
472
  let entries;
417
473
  try {
418
474
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -447,12 +503,14 @@ function collectEnvFiles(roots, maxDepth, excludedRoots = []) {
447
503
  }
448
504
  }
449
505
  }
450
- return { files: found, truncated: depthLimited || found.length >= MAX_ENV_FILES || queue.length > 0, excluded };
506
+ const countLimited = found.length >= MAX_ENV_FILES;
507
+ return { files: found, truncated: depthLimited || countLimited || queue.length > 0, depthLimited, countLimited: countLimited || queue.length > 0, excluded };
451
508
  }
452
509
  function collectProjectCredentialFiles(roots, maxDepth, excludedRoots = []) {
453
510
  const found = [];
454
511
  let excluded = 0;
455
512
  let depthLimited = false;
513
+ const visited = new Set();
456
514
  const queue = roots.map(dir => ({ dir, depth: 0 }));
457
515
  const isCredentialFile = (filePath) => {
458
516
  const normalized = filePath.replace(/\\/g, '/').toLowerCase();
@@ -470,6 +528,10 @@ function collectProjectCredentialFiles(roots, maxDepth, excludedRoots = []) {
470
528
  excluded += 1;
471
529
  continue;
472
530
  }
531
+ const key = (0, discoverPaths_1.normalizedPath)(dir);
532
+ if (visited.has(key))
533
+ continue;
534
+ visited.add(key);
473
535
  let entries;
474
536
  try {
475
537
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -501,7 +563,81 @@ function collectProjectCredentialFiles(roots, maxDepth, excludedRoots = []) {
501
563
  found.push(full);
502
564
  }
503
565
  }
504
- return { files: found, truncated: depthLimited || found.length >= MAX_ENV_FILES || queue.length > 0, excluded };
566
+ const countLimited = found.length >= MAX_ENV_FILES;
567
+ return { files: found, truncated: depthLimited || countLimited || queue.length > 0, depthLimited, countLimited: countLimited || queue.length > 0, excluded };
568
+ }
569
+ // Windows file attributes that mark a cloud-sync placeholder (OneDrive Files
570
+ // On-Demand): reading such a file makes the sync client download it.
571
+ const FILE_ATTRIBUTE_OFFLINE = 0x1000;
572
+ const FILE_ATTRIBUTE_RECALL_ON_OPEN = 0x40000;
573
+ const FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS = 0x400000;
574
+ function isCloudPlaceholderAttributes(attributes) {
575
+ return Number.isInteger(attributes) && attributes >= 0
576
+ && (attributes & (FILE_ATTRIBUTE_OFFLINE | FILE_ATTRIBUTE_RECALL_ON_OPEN | FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS)) !== 0;
577
+ }
578
+ /** True for paths inside a OneDrive folder (by env root or a "OneDrive…" path segment). */
579
+ function isCloudSyncedPath(filePath, env = process.env) {
580
+ const roots = [env.OneDrive, env.OneDriveCommercial, env.OneDriveConsumer].filter((root) => !!root);
581
+ if (roots.some(root => (0, discoverPaths_1.pathIsWithin)(filePath, root)))
582
+ return true;
583
+ return filePath.split(/[\\/]/).some(segment => /^onedrive\b/i.test(segment));
584
+ }
585
+ /**
586
+ * One PowerShell call reads the attributes of every candidate (no file is
587
+ * opened, so nothing is downloaded). Paths go in base64 on stdin so non-ASCII
588
+ * folder names survive the console code page; one integer per line comes back
589
+ * in input order. Returns null when PowerShell is unavailable or times out.
590
+ */
591
+ const probeWindowsFileAttributes = files => {
592
+ if (files.length === 0)
593
+ return [];
594
+ const script = [
595
+ "$ErrorActionPreference = 'SilentlyContinue'",
596
+ 'foreach ($line in [Console]::In.ReadToEnd() -split "`n") {',
597
+ ' $line = $line.Trim(); if (-not $line) { continue }',
598
+ ' try { $p = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($line)); [Console]::Out.WriteLine([int][IO.File]::GetAttributes($p)) }',
599
+ ' catch { [Console]::Out.WriteLine(-1) }',
600
+ '}',
601
+ ].join('\n');
602
+ try {
603
+ const result = (0, child_process_1.spawnSync)('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
604
+ input: files.map(file => Buffer.from(file, 'utf8').toString('base64')).join('\n'),
605
+ encoding: 'utf8',
606
+ timeout: 15000,
607
+ windowsHide: true,
608
+ });
609
+ if (result.status !== 0 || typeof result.stdout !== 'string')
610
+ return null;
611
+ const values = result.stdout.split(/\r?\n/).map(line => line.trim()).filter(Boolean).map(Number);
612
+ if (values.length !== files.length)
613
+ return null;
614
+ return values.map(value => (Number.isInteger(value) && value >= 0 ? value : undefined));
615
+ }
616
+ catch {
617
+ return null;
618
+ }
619
+ };
620
+ exports.probeWindowsFileAttributes = probeWindowsFileAttributes;
621
+ /**
622
+ * Drop OneDrive online-only placeholders before any read. Only files under a
623
+ * OneDrive folder are probed; a failed probe keeps every file (the per-file
624
+ * size cap still bounds what is read).
625
+ */
626
+ function dropCloudPlaceholders(files, options = {}) {
627
+ if ((options.platform ?? process.platform) !== 'win32')
628
+ return { files, skipped: 0 };
629
+ const env = options.env ?? process.env;
630
+ const synced = files.filter(file => isCloudSyncedPath(file, env));
631
+ if (synced.length === 0)
632
+ return { files, skipped: 0 };
633
+ const attributes = (options.probe ?? exports.probeWindowsFileAttributes)(synced);
634
+ if (!attributes)
635
+ return { files, skipped: 0 };
636
+ const placeholders = new Set(synced.filter((_, index) => {
637
+ const value = attributes[index];
638
+ return value !== undefined && isCloudPlaceholderAttributes(value);
639
+ }).map(file => (0, discoverPaths_1.normalizedPath)(file)));
640
+ return { files: files.filter(file => !placeholders.has((0, discoverPaths_1.normalizedPath)(file))), skipped: placeholders.size };
505
641
  }
506
642
  function scanSecrets(options = {}) {
507
643
  const home = os.homedir();
@@ -550,10 +686,13 @@ function scanSecrets(options = {}) {
550
686
  path.join(home, 'Documents'),
551
687
  path.join(home, 'code'),
552
688
  ].filter(root => fs.existsSync(root) && !(0, discoverPaths_1.isExcludedPath)(root, disabledRoots)));
689
+ const placeholderOptions = { platform: options.platform, env: options.env, probe: options.probeFileAttributes };
553
690
  const envCollection = collectEnvFiles(envRoots, maxDepth, disabledRoots);
554
691
  excludedFiles += envCollection.excluded;
555
692
  candidatesTruncated = candidatesTruncated || envCollection.truncated;
556
- for (const envFile of envCollection.files) {
693
+ const envFiles = dropCloudPlaceholders(envCollection.files, placeholderOptions);
694
+ let cloudPlaceholdersSkipped = envFiles.skipped;
695
+ for (const envFile of envFiles.files) {
557
696
  if (findings.length >= MAX_FINDINGS)
558
697
  break;
559
698
  const content = readTextFile(envFile);
@@ -565,7 +704,9 @@ function scanSecrets(options = {}) {
565
704
  const credentialCollection = collectProjectCredentialFiles([cwd, ...extraRoots], maxDepth, disabledRoots);
566
705
  excludedFiles += credentialCollection.excluded;
567
706
  candidatesTruncated = candidatesTruncated || credentialCollection.truncated;
568
- for (const credentialFile of credentialCollection.files) {
707
+ const credentialFiles = dropCloudPlaceholders(credentialCollection.files, placeholderOptions);
708
+ cloudPlaceholdersSkipped += credentialFiles.skipped;
709
+ for (const credentialFile of credentialFiles.files) {
569
710
  if (findings.length >= MAX_FINDINGS)
570
711
  break;
571
712
  const content = readTextFile(credentialFile);
@@ -602,7 +743,10 @@ function scanSecrets(options = {}) {
602
743
  scannedStores,
603
744
  healthScore: scoreFromFindings(findings),
604
745
  limits: { maxFileBytes: MAX_FILE_BYTES, maxEnvFiles: MAX_ENV_FILES, maxFindings: MAX_FINDINGS, maxDepth },
605
- stats: { candidateFiles: scannedFiles, excludedFiles },
746
+ stats: { candidateFiles: scannedFiles, excludedFiles, cloudPlaceholdersSkipped },
606
747
  truncated: { candidateFiles: candidatesTruncated, findings: findings.length >= MAX_FINDINGS },
748
+ truncatedReasons: {
749
+ candidateFiles: (0, discoverPaths_1.truncationReason)(envCollection.depthLimited || credentialCollection.depthLimited, envCollection.countLimited || credentialCollection.countLimited),
750
+ },
607
751
  };
608
752
  }
@@ -1248,11 +1248,13 @@ function explainGuardStop(kind, event, call, reason, ruleId) {
1248
1248
  });
1249
1249
  }
1250
1250
  function spoolLocalFinding(input) {
1251
+ // An action that ran is never recorded as "Blocked …" (monitor showed "Blocked recursive force delete" on a warn).
1252
+ const recorded = input.decision === 'warn' ? input.recorded || 'warned' : undefined;
1251
1253
  (0, telemetry_1.spoolEvent)({
1252
1254
  decision: input.decision || 'block',
1253
1255
  toolName: input.toolName,
1254
1256
  operation: input.operation,
1255
- reason: input.finding.reason,
1257
+ reason: recorded ? (0, blockExplanation_1.recordedReason)(input.finding.reason, recorded) : input.finding.reason,
1256
1258
  ruleId: input.finding.ruleId,
1257
1259
  category: input.finding.category,
1258
1260
  categoryId: input.finding.categoryId,
@@ -1811,8 +1813,9 @@ async function hookCommandInner(args, config, io) {
1811
1813
  }
1812
1814
  // An ask carries the dialog envelope for the per-event hook process in the
1813
1815
  // developer's session (askDialog.ts); it is removed before the IDE reads the verdict.
1816
+ // The same client the runtime event records as `agentClient`: the dialog names the agent the console shows.
1814
1817
  if (ask && askEnvelope)
1815
- decision = (0, askDialog_1.attachAskEnvelope)(decision, askEnvelope);
1818
+ decision = (0, askDialog_1.attachAskEnvelope)(decision, { ...askEnvelope, agentClient: client });
1816
1819
  (0, hookIo_1.dbg)({ phase: 'verdict', event, format: hookFormat, blocked, decision: ask && askEnvelope ? { ...decision, [askDialog_1.ASK_ENVELOPE_FIELD]: '(envelope)' } : decision });
1817
1820
  if (hookFormat === 'claude' && event === 'prompt' && denied) {
1818
1821
  // Claude Desktop/Code variants do not all honor the structured
@@ -1914,8 +1917,8 @@ async function hookCommandInner(args, config, io) {
1914
1917
  (0, hookIo_1.dbg)({ phase: 'local_deterministic_prompt_block', event, ruleId: localBlock.ruleId, category: localBlock.category });
1915
1918
  if (shadow) {
1916
1919
  // Truth-in-reporting: this prompt RAN (shadow/monitor) — 'warn', never 'block'.
1917
- spoolLocalFinding({ finding: localBlock, toolName: 'prompt', operation: 'prompt', decision: 'warn' });
1918
- respond(false, undefined, `[FullCourtDefense shadow] would block prompt: ${localBlock.reason}`);
1920
+ spoolLocalFinding({ finding: localBlock, toolName: 'prompt', operation: 'prompt', decision: 'warn', recorded: 'would_block' });
1921
+ respond(false, undefined, `[FullCourtDefense shadow] ${(0, blockExplanation_1.recordedReason)(localBlock.reason, 'would_block')}`);
1919
1922
  return;
1920
1923
  }
1921
1924
  spoolLocalFinding({ finding: localBlock, toolName: 'prompt', operation: 'prompt' });
@@ -2059,8 +2062,8 @@ async function enforceActionPolicy(ctx) {
2059
2062
  (0, hookIo_1.dbg)({ phase: 'local_deterministic_block', event, tool: call.toolName, ruleId: localBlock.ruleId, category: localBlock.category });
2060
2063
  if (shadow) {
2061
2064
  // Truth-in-reporting: this action RAN (shadow/monitor) — 'warn', never 'block'.
2062
- spoolLocalFinding({ finding: localBlock, toolName: call.toolName, operation: event, decision: 'warn' });
2063
- respond(false, undefined, `[FullCourtDefense shadow] would block ${call.toolName}: ${localBlock.reason}`);
2065
+ spoolLocalFinding({ finding: localBlock, toolName: call.toolName, operation: event, decision: 'warn', recorded: 'would_block' });
2066
+ respond(false, undefined, `[FullCourtDefense shadow] ${call.toolName}: ${(0, blockExplanation_1.recordedReason)(localBlock.reason, 'would_block')}`);
2064
2067
  return;
2065
2068
  }
2066
2069
  spoolLocalFinding({ finding: localBlock, toolName: call.toolName, operation: event });
@@ -2077,7 +2080,7 @@ async function enforceActionPolicy(ctx) {
2077
2080
  if (localAsk) {
2078
2081
  (0, hookIo_1.dbg)({ phase: 'local_deterministic_ask', event, tool: call.toolName, ruleId: localAsk.ruleId, humanPresent: (0, devConfirm_1.isHumanPresent)(), shadow });
2079
2082
  if (shadow) {
2080
- spoolLocalFinding({ finding: localAsk, toolName: call.toolName, operation: event, decision: 'warn' });
2083
+ spoolLocalFinding({ finding: localAsk, toolName: call.toolName, operation: event, decision: 'warn', recorded: 'would_ask' });
2081
2084
  }
2082
2085
  else if (!(0, devConfirm_1.isHumanPresent)()) {
2083
2086
  spoolLocalFinding({ finding: localAsk, toolName: call.toolName, operation: event });
@@ -1546,7 +1546,7 @@ class McpGatewayServer {
1546
1546
  process.stderr.write(`FullCourtDefense asking the developer to confirm ${toolName}: ${ask.reason} (${origin} "${ask.itemId}", ${ask.ruleId})\n`);
1547
1547
  const alwaysScopeLabel = (0, devConfirm_1.describeGrantScope)(grantScope);
1548
1548
  const alwaysLimitsLabel = grantLimits ? (0, devConfirm_1.describeGrantLimits)(grantLimits) : undefined;
1549
- const answer = await this.confirmWithDeveloper({ toolName, reason: ask.reason, ruleLabel: `${origin} "${ask.itemId}" (${ask.ruleId})`, evidence: ask.evidence, argsDigest: digest, allowAlways: Boolean(grantLimits), alwaysScopeLabel, alwaysLimitsLabel });
1549
+ const answer = await this.confirmWithDeveloper({ toolName, reason: ask.reason, ruleLabel: `${origin} "${ask.itemId}" (${ask.ruleId})`, evidence: ask.evidence, argsDigest: digest, allowAlways: Boolean(grantLimits), alwaysScopeLabel, alwaysLimitsLabel, alwaysCovers: (0, devConfirm_1.grantCoverage)(grantScope) });
1550
1550
  if (answer.answer === 'accept') {
1551
1551
  // Settle through the shared ledger so "remember for this session" is
1552
1552
  // recorded exactly like an IDE approval would be.
@@ -1594,8 +1594,9 @@ class McpGatewayServer {
1594
1594
  // where the scan can still yield block-action findings. Record the
1595
1595
  // would-block and let the call through.
1596
1596
  const would = requestOutcome.blockingFinding ? 'would block' : 'would ask you to confirm';
1597
- this.spoolLocalFinding({ ...localBlock, reason: `[monitor] ${localBlock.reason}` }, toolName, operation, 'warn');
1598
- process.stderr.write(`FullCourtDefense (monitor) ${would} ${toolName}: ${localBlock.reason} (${origin} "${localBlock.itemId}", ${localBlock.ruleId})\n`);
1597
+ const recorded = (0, blockExplanation_1.recordedReason)(localBlock.reason, requestOutcome.blockingFinding ? 'would_block' : 'would_ask');
1598
+ this.spoolLocalFinding({ ...localBlock, reason: `[monitor] ${recorded}` }, toolName, operation, 'warn');
1599
+ process.stderr.write(`FullCourtDefense (monitor) ${would} ${toolName}: ${recorded.replace(/^Would (?:block|ask): /, '')} (${origin} "${localBlock.itemId}", ${localBlock.ruleId})\n`);
1599
1600
  }
1600
1601
  else {
1601
1602
  this.spoolLocalFinding(localBlock, toolName, operation);
@@ -1881,7 +1882,7 @@ class McpGatewayServer {
1881
1882
  const responseOrigin = localResponseBlock.source === 'custom' ? 'org custom rule' : 'built-in Local Safety rule';
1882
1883
  if (reportOnlyMode) {
1883
1884
  // MONITOR CONTRACT (see request-side block above): record, never block.
1884
- this.spoolLocalFinding({ ...localResponseBlock, reason: `[monitor] ${localResponseBlock.reason}` }, toolName, operation, 'warn');
1885
+ this.spoolLocalFinding({ ...localResponseBlock, reason: `[monitor] ${(0, blockExplanation_1.recordedReason)(localResponseBlock.reason, 'would_block')}` }, toolName, operation, 'warn');
1885
1886
  process.stderr.write(`FullCourtDefense (monitor) would block the ${toolName} response: ${localResponseBlock.reason} (${responseOrigin} "${localResponseBlock.itemId}", ${localResponseBlock.ruleId})\n`);
1886
1887
  }
1887
1888
  else {
@@ -1901,7 +1902,7 @@ class McpGatewayServer {
1901
1902
  if (!fullyMasked) {
1902
1903
  const finding = responseOutcome.maskFindings[0];
1903
1904
  if (reportOnlyMode) {
1904
- this.spoolLocalFinding({ ...finding, reason: `[monitor] ${finding.reason}` }, toolName, operation, 'warn');
1905
+ this.spoolLocalFinding({ ...finding, reason: `[monitor] ${(0, blockExplanation_1.recordedReason)(finding.reason, 'would_block')}` }, toolName, operation, 'warn');
1905
1906
  process.stderr.write(`FullCourtDefense (monitor) would block the ${toolName} response: mask rule "${finding.itemId}" could not rewrite this payload.\n`);
1906
1907
  }
1907
1908
  else {
@@ -2243,6 +2244,7 @@ class McpGatewayServer {
2243
2244
  allowAlways,
2244
2245
  alwaysScopeLabel,
2245
2246
  alwaysLimitsLabel,
2247
+ alwaysCovers: (0, devConfirm_1.grantCoverage)(grantScope),
2246
2248
  });
2247
2249
  if (answer.answer === 'accept') {
2248
2250
  // Policy memory limits are authoritative; a client cannot upgrade 'none'.
@@ -2320,7 +2322,7 @@ class McpGatewayServer {
2320
2322
  }
2321
2323
  async askInOsDialog(input, clientAutoAnswered) {
2322
2324
  const evidence = input.evidence ? `\nEvidence: ${input.evidence.slice(0, 240)}` : '';
2323
- const dialog = await (0, devConfirm_1.confirmNativeActionDetailed)(`Agent: ${this.gatewayConfig.agentName}\nIDE: ${this.gatewayConfig.agentClient}\nDeveloper: ${this.gatewayConfig.developerName || 'Unknown'}\nTool: ${input.toolName}\nRule: ${input.ruleLabel}\nWhy: ${input.reason}${evidence}\n\nYour answer is reported to your security dashboard as your decision.`, OS_DIALOG_TIMEOUT_MS, { allowAlways: input.allowAlways === true, alwaysScopeLabel: input.alwaysScopeLabel, alwaysLimitsLabel: input.alwaysLimitsLabel });
2325
+ const dialog = await (0, devConfirm_1.confirmNativeActionDetailed)(`Agent: ${this.gatewayConfig.agentName}\nIDE: ${this.gatewayConfig.agentClient}\nDeveloper: ${this.gatewayConfig.developerName || 'Unknown'}\nTool: ${input.toolName}\nRule: ${input.ruleLabel}\nWhy: ${input.reason}${evidence}\n\nYour answer is reported to your security dashboard as your decision.`, OS_DIALOG_TIMEOUT_MS, { allowAlways: input.allowAlways === true, alwaysScopeLabel: input.alwaysScopeLabel, alwaysLimitsLabel: input.alwaysLimitsLabel, alwaysCovers: input.alwaysCovers });
2324
2326
  process.stderr.write(`FullCourtDefense: dialog for ${input.toolName} ended with "${dialog.outcome}" after ${dialog.latencyMs}ms.\n`);
2325
2327
  const answer = dialog.outcome === 'allow' || dialog.outcome === 'allow_always' ? 'accept' : dialog.outcome === 'deny' ? 'decline' : dialog.outcome;
2326
2328
  return { answer, remember: false, always: dialog.outcome === 'allow_always', latencyMs: dialog.latencyMs, channel: 'os_dialog', clientAutoAnswered };
@@ -3079,7 +3081,7 @@ function healCodexTomlAgentIdentities(file, dryRun) {
3079
3081
  * runtime-agnostic agent identity that protect-all/auto-protect produce (`<developer>-<server>`).
3080
3082
  * Otherwise the gateway reports a generic `<developer>-<client>` name and a dashboard policy
3081
3083
  * targeting the MCP server (agentName contains <server>) silently never matches — the exact
3082
- * bug where a block policy on `patria-bank-finance` did not fire because the Claude Desktop
3084
+ * bug where a block policy on `contoso-bank-finance` did not fire because the Claude Desktop
3083
3085
  * entry reported `boazl-claude-desktop`. An explicit --agent-name / FCD_AGENT_NAME still wins.
3084
3086
  */
3085
3087
  function applyPerServerAgentName(gatewayConfig, args, serverName) {
@@ -171,7 +171,7 @@ function buildGuardJs(nodePath, cliEntry) {
171
171
  `// Execution view (aligned with the CLI's stripInertDataSegments): quoted spans containing whitespace are`,
172
172
  `// prose (inert) unless in a code position — interpreter -c/-Command flags, remote executors (ssh/wsl/docker`,
173
173
  `// exec), $( ) substitution, or real backtick command substitution (>=2 chars; a 1-char pair is a PS escape).`,
174
- `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;}`,
174
+ `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;}`,
175
175
  `// Precedence on one line: block outranks ask, ask outranks warn.`,
176
176
  `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;const asks=[];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==='block')return r;if(r.action==='ask'){asks.push(r);}else if(!warnHit)warnHit=r;}}catch{}}if(asks.length===1)return asks[0];if(asks.length>1){asks.sort((a,b)=>a.id<b.id?-1:a.id>b.id?1:0);return{...asks[0],id:'asks:'+asks.map(r=>encodeURIComponent(r.id)).join('+'),reason:asks.map(r=>r.reason).join('; ')}}return warnHit;}`,
177
177
  `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{}}`,
@@ -183,10 +183,32 @@ function compileSpec(spec) {
183
183
  pattern += `(?=${SEG}\\s(?:${spec.target.join('|')}))`;
184
184
  return { id: spec.id, category: spec.category, severity: spec.severity, reason: spec.reason, source: 'builtin', pattern };
185
185
  }
186
+ /**
187
+ * Every body must occur on one line, in any order. Tried only at line starts: an
188
+ * unanchored `(?=.*a)(?=.*b)` re-scans the rest of the line from every
189
+ * character, which is quadratic on a long line that does not match (a 16 KB
190
+ * commit message took seconds per rule). Same matches in .NET and JS: a hit from
191
+ * mid-line implies one from the start of that line.
192
+ */
193
+ function sameLine(...bodies) {
194
+ return String.raw `(?:^|[\n\r\u2028\u2029])` + bodies.map(body => `(?=.*${body})`).join('');
195
+ }
186
196
  // Reusable target fragments.
187
- const T_UNIX_ROOT = String.raw `["']?/["']?(?:\s|$|[;&|])|\*(?:\s|$|[;&|])`;
197
+ /** The root itself; a bare `*` (the current folder's contents) is its own finding. */
198
+ const T_UNIX_ROOT_ONLY = String.raw `["']?/\*?["']?(?:\s|$|[;&|])`;
199
+ const T_BARE_STAR = String.raw `\*(?:\s|$|[;&|])`;
188
200
  const T_WIN_DRIVE = String.raw `["']?[a-z]:(?:[\\/](?:\*(?:\.\*)?)?)?["']?\s*(?:$|[;&|])`;
189
201
  const T_WIN_SYSDIR = String.raw `["']?[a-z]:[\\/](?:windows|program files(?: \(x86\))?|programdata|users)\b`;
202
+ /**
203
+ * A bare `*` right after `cd /` (or `cd ~`) is the root's (home's) contents, not "the current
204
+ * folder". Whole-line literal rules, listed before the bare-star specs so the line reads first.
205
+ */
206
+ const CD_ROOT = String.raw `(?:^|[;&|\n]\s*)cd\s+["']?/["']?\s*(?:&&|;|\n)\s*(?:sudo\s+)?`;
207
+ const CD_HOME = String.raw `(?:^|[;&|\n]\s*)cd\s+["']?(?:~|\$HOME|\$\{HOME\})[\\/]?["']?\s*(?:&&|;|\n)\s*(?:sudo\s+)?`;
208
+ function starAfterCd(cd, command, flags) {
209
+ return `${cd}${command}\\b${flags.map(f => `(?=[^;&|\\n]*\\s(?:${f})(?:\\s|$))`).join('')}[^;&|\\n]*\\s\\*(?:\\s|$|[;&|])`;
210
+ }
211
+ const F_RECURSIVE = String.raw `-[a-z]*r[a-z]*|--recursive`;
190
212
  /**
191
213
  * Built-in dangerous-command rules. Flag/target-bearing rules are compiled from
192
214
  * specs (order-independent by construction); the rest are literal regexes. Item
@@ -196,7 +218,14 @@ const T_WIN_SYSDIR = String.raw `["']?[a-z]:[\\/](?:windows|program files(?: \(x
196
218
  const BUILTIN_RULES = [
197
219
  // Destructive filesystem — spec-compiled (order-independent, optional flags not required).
198
220
  compileSpec({ id: 'rm_rf_root', category: 'destructive_command', severity: 'critical', reason: 'recursive delete of filesystem root',
199
- command: ['rm'], flags: [String.raw `-[a-z]*r[a-z]*|--recursive`], target: [T_UNIX_ROOT] }),
221
+ command: ['rm'], flags: [String.raw `-[a-z]*r[a-z]*|--recursive`], target: [T_UNIX_ROOT_ONLY] }),
222
+ { id: 'rm_rf_root', category: 'destructive_command', severity: 'critical', reason: 'recursive delete of filesystem root', source: 'builtin',
223
+ pattern: starAfterCd(CD_ROOT, 'rm', [F_RECURSIVE]) },
224
+ { id: 'rm_rf_root', category: 'destructive_command', severity: 'critical', reason: 'recursive delete of the home directory', source: 'builtin',
225
+ pattern: starAfterCd(CD_HOME, 'rm', [F_RECURSIVE]) },
226
+ // Same item (same verdict and console row); the fact is the current folder, not the root.
227
+ compileSpec({ id: 'rm_rf_root', category: 'destructive_command', severity: 'critical', reason: 'recursive delete of everything in the current folder (rm -r *)',
228
+ command: ['rm'], flags: [String.raw `-[a-z]*r[a-z]*|--recursive`], target: [T_BARE_STAR] }),
200
229
  compileSpec({ id: 'rm_rf_home', category: 'destructive_command', severity: 'critical', reason: 'recursive delete of the home directory',
201
230
  command: ['rm'], flags: [String.raw `-[a-z]*r[a-z]*|--recursive`], target: [String.raw `["']?(?:~|\$HOME|\$\{HOME\}|%USERPROFILE%|\$env:USERPROFILE)(?:[\\/]\*?)?["']?(?:\s|$|[;&|])`] }),
202
231
  compileSpec({ id: 'windows_drive_delete', category: 'destructive_command', severity: 'critical', reason: 'recursive Windows drive delete',
@@ -210,10 +239,19 @@ const BUILTIN_RULES = [
210
239
  // switch and an action.
211
240
  compileSpec({ id: 'windows_drive_delete', category: 'destructive_command', severity: 'critical', reason: 'recursive Remove-Item on a drive root',
212
241
  command: ['remove-item', 'ri'], flags: [String.raw `-recurse\w*`], target: [String.raw `["']?[a-z]:[\\/]?["']?(?:\s|$|[;&|])`] }),
242
+ // Root vs a bare `*` (the current folder, or the root after `cd /`): same item, the right fact.
213
243
  compileSpec({ id: 'chmod_root_777', category: 'destructive_command', severity: 'high', reason: 'recursive world-writable permission change on root',
214
- command: ['chmod'], flags: [String.raw `-[a-z]*r[a-z]*|--recursive`, String.raw `[0-7]?777`], target: [T_UNIX_ROOT] }),
244
+ command: ['chmod'], flags: [F_RECURSIVE, String.raw `[0-7]?777`], target: [T_UNIX_ROOT_ONLY] }),
245
+ { id: 'chmod_root_777', category: 'destructive_command', severity: 'high', reason: 'recursive world-writable permission change on root', source: 'builtin',
246
+ pattern: starAfterCd(CD_ROOT, 'chmod', [F_RECURSIVE, String.raw `[0-7]?777`]) },
247
+ compileSpec({ id: 'chmod_root_777', category: 'destructive_command', severity: 'high', reason: 'recursive world-writable permission change on everything in the current folder (chmod -R 777 *)',
248
+ command: ['chmod'], flags: [F_RECURSIVE, String.raw `[0-7]?777`], target: [T_BARE_STAR] }),
215
249
  compileSpec({ id: 'chown_root_recursive', category: 'destructive_command', severity: 'high', reason: 'recursive ownership change on the filesystem root',
216
- command: ['chown'], flags: [String.raw `-[a-z]*r[a-z]*|--recursive`], target: [T_UNIX_ROOT] }),
250
+ command: ['chown'], flags: [F_RECURSIVE], target: [T_UNIX_ROOT_ONLY] }),
251
+ { id: 'chown_root_recursive', category: 'destructive_command', severity: 'high', reason: 'recursive ownership change on the filesystem root', source: 'builtin',
252
+ pattern: starAfterCd(CD_ROOT, 'chown', [F_RECURSIVE]) },
253
+ compileSpec({ id: 'chown_root_recursive', category: 'destructive_command', severity: 'high', reason: 'recursive ownership change on everything in the current folder (chown -R *)',
254
+ command: ['chown'], flags: [F_RECURSIVE], target: [T_BARE_STAR] }),
217
255
  compileSpec({ id: 'dd_disk_overwrite', category: 'destructive_command', severity: 'critical', reason: 'raw disk overwrite command',
218
256
  command: ['dd'], target: [String.raw `of=/dev/(?:sd|xvd|hd|nvme|disk)\S*`] }),
219
257
  { id: 'disk_device_overwrite', category: 'destructive_command', severity: 'critical', reason: 'redirecting output over a raw disk device', source: 'builtin',
@@ -248,7 +286,7 @@ const BUILTIN_RULES = [
248
286
  { id: 'remote_pipe_interpreter', category: 'reverse_shell', severity: 'high', reason: 'remote script piped to an interpreter', source: 'builtin',
249
287
  pattern: String.raw `\b(?:curl|wget)\b[^|;&]{0,300}\|\s*(?:sudo\s+)?(?:python[0-9.]*|perl|ruby|node|php)\b` },
250
288
  { id: 'powershell_download_exec', category: 'reverse_shell', severity: 'high', reason: 'download-and-execute (IEX + web download)', source: 'builtin',
251
- pattern: String.raw `(?=.*\b(?:iex\b|invoke-expression\b))(?=.*(?:downloadstring|net\.webclient|invoke-webrequest\b|\biwr\b|invoke-restmethod\b|\birm\b))` },
289
+ pattern: sameLine(String.raw `\b(?:iex\b|invoke-expression\b)`, String.raw `(?:downloadstring|net\.webclient|invoke-webrequest\b|\biwr\b|invoke-restmethod\b|\birm\b)`) },
252
290
  { id: 'python_socket_subprocess', category: 'reverse_shell', severity: 'high', reason: 'python reverse shell', source: 'builtin',
253
291
  pattern: String.raw `\bpython(?:3)?\s+-c\b(?=.*socket)(?=.*subprocess)` },
254
292
  { id: 'perl_reverse_shell', category: 'reverse_shell', severity: 'high', reason: 'perl reverse shell', source: 'builtin',
@@ -259,15 +297,15 @@ const BUILTIN_RULES = [
259
297
  // One terminal rule per CONSOLE toggle (catalog ids) — a single combined rule
260
298
  // meant switching off / asking on one endpoint changed nothing in a shell.
261
299
  { id: 'aws_gcp_metadata_ip', category: 'metadata_ssrf', severity: 'high', reason: 'request to the AWS/GCP metadata endpoint (169.254.169.254)', source: 'builtin',
262
- pattern: String.raw `(?=.*\b(?:curl|wget|iwr|invoke-webrequest|invoke-restmethod|irm)\b)(?=.*169\.254\.169\.254)` },
300
+ pattern: sameLine(String.raw `\b(?:curl|wget|iwr|invoke-webrequest|invoke-restmethod|irm)\b`, String.raw `169\.254\.169\.254`) },
263
301
  { id: 'gcp_metadata_host', category: 'metadata_ssrf', severity: 'high', reason: 'request to the GCP metadata host (metadata.google.internal)', source: 'builtin',
264
- pattern: String.raw `(?=.*\b(?:curl|wget|iwr|invoke-webrequest|invoke-restmethod|irm)\b)(?=.*metadata\.google\.internal)` },
302
+ pattern: sameLine(String.raw `\b(?:curl|wget|iwr|invoke-webrequest|invoke-restmethod|irm)\b`, String.raw `metadata\.google\.internal`) },
265
303
  { id: 'azure_metadata_host', category: 'metadata_ssrf', severity: 'high', reason: 'request to the Azure metadata host (metadata.azure.com)', source: 'builtin',
266
- pattern: String.raw `(?=.*\b(?:curl|wget|iwr|invoke-webrequest|invoke-restmethod|irm)\b)(?=.*metadata\.azure\.com)` },
304
+ pattern: sameLine(String.raw `\b(?:curl|wget|iwr|invoke-webrequest|invoke-restmethod|irm)\b`, String.raw `metadata\.azure\.com`) },
267
305
  { id: 'alibaba_metadata_ip', category: 'metadata_ssrf', severity: 'high', reason: 'request to the Alibaba metadata endpoint (100.100.100.200)', source: 'builtin',
268
- pattern: String.raw `(?=.*\b(?:curl|wget|iwr|invoke-webrequest|invoke-restmethod|irm)\b)(?=.*100\.100\.100\.200)` },
306
+ pattern: sameLine(String.raw `\b(?:curl|wget|iwr|invoke-webrequest|invoke-restmethod|irm)\b`, String.raw `100\.100\.100\.200`) },
269
307
  { id: 'credential_exfiltration', category: 'secret_exfiltration', severity: 'critical', reason: 'credential file sent to the network', source: 'builtin',
270
- pattern: String.raw `(?=.*\b(?:curl|wget|iwr|invoke-webrequest|invoke-restmethod|scp|nc|ncat)\b)(?=.*(?:\.ssh[\\/]id_(?:rsa|ed25519|ecdsa|dsa)|\.aws[\\/]credentials|application_default_credentials\.json|\.npmrc|\.netrc|\.kube[\\/]config))` },
308
+ pattern: sameLine(String.raw `\b(?:curl|wget|iwr|invoke-webrequest|invoke-restmethod|scp|nc|ncat)\b`, String.raw `(?:\.ssh[\\/]id_(?:rsa|ed25519|ecdsa|dsa)|\.aws[\\/]credentials|application_default_credentials\.json|\.npmrc|\.netrc|\.kube[\\/]config)`) },
271
309
  // Infra destroy — spec-compiled where flags matter (order-independent).
272
310
  compileSpec({ id: 'terraform_destroy_auto', category: 'infra_destroy', severity: 'high', reason: 'terraform destroy with auto-approve',
273
311
  command: ['terraform'], extra: [String.raw `\bdestroy\b`], flags: [String.raw `--auto-approve`] }),
@@ -616,25 +654,32 @@ function evaluateShellCommand(line, rules = activeShellGuardRules()) {
616
654
  }).filter((x) => !!x);
617
655
  // Whole line first (some rules intentionally span a pipe, e.g. curl | sh),
618
656
  // then each &&/||/;/newline segment (catches "cleanup && del /s c:\").
619
- const candidates = [line, ...line.split(/&&|\|\||[;&\n]|\|/).map(s => s.trim()).filter(Boolean)];
657
+ // Built-in rules judge the execution view — quoted prose (a grep pattern, a
658
+ // commit message) is inert — exactly as the live PowerShell / cmd / posix
659
+ // guards do; org custom rules see the raw line, as they do there.
660
+ const segmentsOf = (text) => [text, ...text.split(/&&|\|\||[;&\n]|\|/).map(s => s.trim()).filter(Boolean)];
661
+ const views = [[false, segmentsOf((0, deterministicGuard_1.stripInertDataSegments)(line))], [true, segmentsOf(line)]];
620
662
  const seen = new Set();
621
663
  const matches = [];
622
- for (const candidate of candidates) {
623
- const norm = candidate.replace(/\s+/g, ' ').trim();
624
- for (const { rule, re } of compiled) {
625
- try {
626
- if (re.test(norm) || re.test(candidate)) {
627
- const key = `${rule.id}|${candidate}`;
628
- if (seen.has(key))
629
- continue;
630
- seen.add(key);
631
- matches.push({ rule, segment: candidate });
632
- break; // first rule per segment is enough
664
+ for (const [custom, candidates] of views)
665
+ for (const candidate of candidates) {
666
+ const norm = candidate.replace(/\s+/g, ' ').trim();
667
+ for (const { rule, re } of compiled) {
668
+ if ((rule.source === 'custom') !== custom)
669
+ continue;
670
+ try {
671
+ if (re.test(norm) || re.test(candidate)) {
672
+ const key = `${rule.id}|${candidate}`;
673
+ if (seen.has(key))
674
+ continue;
675
+ seen.add(key);
676
+ matches.push({ rule, segment: candidate });
677
+ break; // first rule per segment is enough
678
+ }
633
679
  }
680
+ catch { /* skip bad regex */ }
634
681
  }
635
- catch { /* skip bad regex */ }
636
682
  }
637
- }
638
683
  return matches;
639
684
  }
640
685
  /**
@@ -733,7 +778,7 @@ function buildGuardPs1(nodePath, cliEntry) {
733
778
  `# false-negative-safe. Single-token quoted args stay: argv (rm -rf "/").`,
734
779
  `function global:Get-FcdExecView {`,
735
780
  ` param([string]$CommandLine)`,
736
- 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 }`,
781
+ 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 }`,
737
782
  ` $out = $CommandLine`,
738
783
  " $interp = '\\$\\(|" + '`[^`]{2,}`' + "'",
739
784
  " $keepIfInterpolates = [System.Text.RegularExpressions.MatchEvaluator]{ param($m) if ($m.Value -match $interp) { $m.Value } else { ' fcd_inert_data ' } }",
@@ -28,13 +28,7 @@ export declare function removeFcdConfigBackups(): {
28
28
  export declare function purgeLocalState(config: BotGuardConfig): {
29
29
  failures: string[];
30
30
  };
31
- /**
32
- * Product code of the installed FullCourtDefense MSI, from the registry
33
- * uninstall hive (both 64-bit and WOW6432Node views). Undefined on npm
34
- * installs or when the MSI entry is missing.
35
- */
36
- export declare function parseMsiProductCodeFromRegOutput(output: string): string | undefined;
37
- export declare function findMsiProductCode(): string | undefined;
31
+ export { findMsiProductCode, parseMsiProductCodeFromRegOutput } from '../msiProductCode';
38
32
  /**
39
33
  * `fullcourtdefense detach` — reverse `onboard`/`install-all` protection but
40
34
  * KEEP machine credentials and the installed application, so the machine can