fullcourtdefense-cli 1.35.9 → 1.36.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.
Files changed (65) hide show
  1. package/dist/actionPolicyEngine.d.ts +1 -0
  2. package/dist/actionPolicyEngine.js +21 -0
  3. package/dist/askDialog.js +1 -2
  4. package/dist/callerEnv.d.ts +10 -0
  5. package/dist/callerEnv.js +18 -1
  6. package/dist/commandNarrative.d.ts +8 -0
  7. package/dist/commandNarrative.js +69 -10
  8. package/dist/commands/agentCi.js +3 -1
  9. package/dist/commands/cmdGuard.js +2 -0
  10. package/dist/commands/daemon.js +48 -3
  11. package/dist/commands/deterministicGuard.d.ts +62 -2
  12. package/dist/commands/deterministicGuard.js +583 -30
  13. package/dist/commands/discover.js +36 -2
  14. package/dist/commands/discoverAgentFiles.d.ts +11 -0
  15. package/dist/commands/discoverAgentFiles.js +107 -0
  16. package/dist/commands/discoverExtensions.d.ts +64 -0
  17. package/dist/commands/discoverExtensions.js +386 -0
  18. package/dist/commands/hook.d.ts +28 -4
  19. package/dist/commands/hook.js +76 -19
  20. package/dist/commands/knownMcpServers.d.ts +8 -2
  21. package/dist/commands/knownMcpServers.js +33 -2
  22. package/dist/commands/local-scan.js +1 -0
  23. package/dist/commands/mcpGateway.js +273 -54
  24. package/dist/commands/protectedRun.d.ts +1 -1
  25. package/dist/commands/protectedRun.js +3 -3
  26. package/dist/commands/shellGuard.js +16 -1
  27. package/dist/commands/watchdog.js +19 -3
  28. package/dist/credentialGrant.d.ts +43 -0
  29. package/dist/credentialGrant.js +504 -0
  30. package/dist/daemonForensics.d.ts +27 -0
  31. package/dist/daemonForensics.js +47 -0
  32. package/dist/detectorBaseline.d.ts +2 -2
  33. package/dist/detectorBaseline.js +2 -2
  34. package/dist/devConfirm.d.ts +55 -1
  35. package/dist/devConfirm.js +291 -112
  36. package/dist/envDiagnostics.js +4 -2
  37. package/dist/hookSlim.js +4 -3
  38. package/dist/ideExtensionRisk.d.ts +110 -0
  39. package/dist/ideExtensionRisk.js +172 -0
  40. package/dist/machineActionVerify.d.ts +34 -8
  41. package/dist/machineActionVerify.js +76 -16
  42. package/dist/mcpProtocol.d.ts +124 -0
  43. package/dist/mcpProtocol.js +302 -0
  44. package/dist/mcpToolReview.d.ts +2 -1
  45. package/dist/mcpToolReview.js +6 -4
  46. package/dist/notify.d.ts +7 -0
  47. package/dist/notify.js +61 -0
  48. package/dist/selfUpdate.d.ts +59 -0
  49. package/dist/selfUpdate.js +138 -1
  50. package/dist/shellAstLoader.js +2 -0
  51. package/dist/shellQuoteMask.d.ts +25 -0
  52. package/dist/shellQuoteMask.js +319 -0
  53. package/dist/telemetry.d.ts +5 -0
  54. package/dist/telemetry.js +1 -0
  55. package/dist/verdictIpc.js +16 -0
  56. package/dist/verdictIpcClient.d.ts +32 -12
  57. package/dist/verdictIpcClient.js +55 -26
  58. package/dist/version.json +1 -1
  59. package/installer/macos/guardian/ai.fullcourtdefense.guardian.plist +19 -0
  60. package/installer/macos/guardian/fcd-guardian.sh +590 -0
  61. package/installer/macos/guardian/install-guardian.sh +32 -0
  62. package/installer/macos/guardian/uninstall-guardian.sh +12 -0
  63. package/installer/windows/README.md +88 -0
  64. package/installer/windows/rescue/FullCourtDefense-Rescue.ps1 +229 -0
  65. package/package.json +12 -2
@@ -33,8 +33,15 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.CREDENTIAL_COMMAND_RULES = exports.DEFAULT_DISABLED_BUILT_INS = void 0;
36
+ exports.REMOTE_CONTROL_COMMAND_RULES = exports.REMOTE_CONTROL_BRIDGES = exports.REMOTE_CONTROL_CATALOG_VERSION = exports.CREDENTIAL_COMMAND_RULES = exports.DEFAULT_DISABLED_BUILT_INS = void 0;
37
37
  exports.toLocalSafetyRuleAction = toLocalSafetyRuleAction;
38
+ exports.remoteControlBridgePattern = remoteControlBridgePattern;
39
+ exports.remoteControlBridgeInCommand = remoteControlBridgeInCommand;
40
+ exports.classifyRemoteMcpLaunch = classifyRemoteMcpLaunch;
41
+ exports.sanitizeRemoteControlFacts = sanitizeRemoteControlFacts;
42
+ exports.remoteControlFactFor = remoteControlFactFor;
43
+ exports.remoteControlGrantKey = remoteControlGrantKey;
44
+ exports.remoteControlLaunches = remoteControlLaunches;
38
45
  exports.setShellAstProvider = setShellAstProvider;
39
46
  exports.setShellAstMode = setShellAstMode;
40
47
  exports.getShellAstMode = getShellAstMode;
@@ -441,6 +448,12 @@ function isEnabled(itemId, options) {
441
448
  * and stale snapshots on the warn-first rollout instead of hard-blocking.
442
449
  */
443
450
  const DEFAULT_WARN_BUILT_INS = new Set(['env_workspace']);
451
+ /**
452
+ * Items that ASK by default when the snapshot carries no action for them — a
453
+ * snapshot built before the item existed (older backend) must not turn a new
454
+ * ask-first rule into a hard block. An explicit org 'block' still wins.
455
+ */
456
+ const DEFAULT_ASK_BUILT_INS = new Set(['agent_hooks_switched_off', 'agent_hook_config_removed', 'fcd_uninstalled_by_agent', 'remote_control_bridge']);
444
457
  function actionFor(itemId, options) {
445
458
  const action = toLocalSafetyRuleAction(options?.itemActions?.[itemId]);
446
459
  if (action)
@@ -451,6 +464,8 @@ function actionFor(itemId, options) {
451
464
  // keeps the strict "absent item = block" contract below.
452
465
  if (options?.snapshotPresent === false)
453
466
  return 'warn';
467
+ if (DEFAULT_ASK_BUILT_INS.has(itemId))
468
+ return 'ask';
454
469
  return DEFAULT_WARN_BUILT_INS.has(itemId) ? 'warn' : 'block';
455
470
  }
456
471
  function builtIn(itemId, categoryId, category, ruleId, reason, findingEvidence, explanation, options) {
@@ -575,36 +590,36 @@ function containsMetadataEndpoint(value) {
575
590
  function containsSensitiveCredentialPath(value) {
576
591
  const text = normalizeForPath(value);
577
592
  const checks = [
578
- { itemId: 'ssh_private_key', label: 'SSH private key', re: /(?:^|\/)\.ssh\/id_(?:rsa|ed25519|ecdsa|dsa)(?:$|[/?#\s'"])/ },
579
- { itemId: 'ssh_config', label: 'SSH config', re: /(?:^|\/)\.ssh\/config(?:$|[/?#\s'"])/ },
580
- { itemId: 'aws_credentials', label: 'AWS credentials', re: /(?:^|\/)\.aws\/(?:credentials|config)(?:$|[/?#\s'"])/ },
581
- { itemId: 'kube_config', label: 'Kubernetes config', re: /(?:^|\/)\.kube\/config(?:$|[/?#\s'"])/ },
582
- { itemId: 'docker_auth', label: 'Docker auth config', re: /(?:^|\/)\.docker\/config\.json(?:$|[/?#\s'"])/ },
593
+ { itemId: 'ssh_private_key', label: 'SSH private key', re: /(?:^|\/)\.ssh\/id_(?:rsa|ed25519|ecdsa|dsa)(?:$|[/?#\s'"`);|&<>])/ },
594
+ { itemId: 'ssh_config', label: 'SSH config', re: /(?:^|\/)\.ssh\/config(?:$|[/?#\s'"`);|&<>])/ },
595
+ { itemId: 'aws_credentials', label: 'AWS credentials', re: /(?:^|\/)\.aws\/(?:credentials|config)(?:$|[/?#\s'"`);|&<>])/ },
596
+ { itemId: 'kube_config', label: 'Kubernetes config', re: /(?:^|\/)\.kube\/config(?:$|[/?#\s'"`);|&<>])/ },
597
+ { itemId: 'docker_auth', label: 'Docker auth config', re: /(?:^|\/)\.docker\/config\.json(?:$|[/?#\s'"`);|&<>])/ },
583
598
  // GitHub CLI stores its OAuth token in hosts.yml — ~/.config/gh/ on Unix,
584
599
  // AppData/Roaming/GitHub CLI/ on Windows (normalized+lowercased upstream).
585
- { itemId: 'github_cli_token', label: 'GitHub CLI token', re: /(?:^|\/)(?:gh|github cli)\/hosts\.yml(?:$|[/?#\s'"])/ },
586
- { itemId: 'git_credentials', label: 'Git plaintext credentials', re: /(?:^|\/)\.git-credentials(?:$|[/?#\s'"])/ },
587
- { itemId: 'gcloud_credentials', label: 'gcloud credential store', re: /(?:^|\/)gcloud\/(?:credentials\.db|access_tokens\.db|legacy_credentials)(?:$|[/?#\s'"])/ },
600
+ { itemId: 'github_cli_token', label: 'GitHub CLI token', re: /(?:^|\/)(?:gh|github cli)\/hosts\.yml(?:$|[/?#\s'"`);|&<>])/ },
601
+ { itemId: 'git_credentials', label: 'Git plaintext credentials', re: /(?:^|\/)\.git-credentials(?:$|[/?#\s'"`);|&<>])/ },
602
+ { itemId: 'gcloud_credentials', label: 'gcloud credential store', re: /(?:^|\/)gcloud\/(?:credentials\.db|access_tokens\.db|legacy_credentials)(?:$|[/?#\s'"`);|&<>])/ },
588
603
  { itemId: 'azure_tokens', label: 'Azure token cache', re: /(?:^|\/)\.azure\/(?:msal_token_cache|accesstokens\.json)/ },
589
- { itemId: 'gnupg_private_keys', label: 'GnuPG private keys', re: /(?:^|\/)\.gnupg\/(?:private-keys-v1\.d|secring\.gpg)(?:$|[/?#\s'"])/ },
590
- { itemId: 'pgpass', label: 'PostgreSQL password file', re: /(?:^|\/)(?:\.pgpass|pgpass\.conf)(?:$|[/?#\s'"])/ },
591
- { itemId: 'mysql_cnf', label: 'MySQL credentials file', re: /(?:^|\/)\.my\.cnf(?:$|[/?#\s'"])/ },
592
- { itemId: 'terraform_credentials', label: 'Terraform credentials', re: /credentials\.tfrc\.json(?:$|[/?#\s'"])/ },
593
- { itemId: 'huggingface_token', label: 'Hugging Face token', re: /(?:^|\/)\.?huggingface\/token(?:$|[/?#\s'"])/ },
594
- { itemId: 'cargo_credentials', label: 'Cargo registry credentials', re: /(?:^|\/)\.cargo\/credentials(?:\.toml)?(?:$|[/?#\s'"])/ },
595
- { itemId: 'gem_credentials', label: 'RubyGems credentials', re: /(?:^|\/)\.gem\/credentials(?:$|[/?#\s'"])/ },
596
- { itemId: 'composer_auth', label: 'Composer auth tokens', re: /(?:^|\/)\.composer\/auth\.json(?:$|[/?#\s'"])/ },
597
- { itemId: 'npmrc', label: 'npm credentials', re: /(?:^|\/)\.npmrc(?:$|[/?#\s'"])/ },
598
- { itemId: 'pypirc', label: 'PyPI credentials', re: /(?:^|\/)\.pypirc(?:$|[/?#\s'"])/ },
599
- { itemId: 'netrc', label: 'netrc credentials', re: /(?:^|\/)\.netrc(?:$|[/?#\s'"])/ },
600
- { itemId: 'gcp_adc', label: 'GCP ADC credentials', re: /application_default_credentials\.json(?:$|[/?#\s'"])/ },
601
- { itemId: 'linux_shadow', label: 'Linux shadow file', re: /(?:^|\/)etc\/(?:shadow|sudoers)(?:$|[/?#\s'"])/ },
602
- { itemId: 'powershell_history', label: 'PowerShell history', re: /powershell\/psreadline\/consolehost_history\.txt(?:$|[/?#\s'"])/ },
604
+ { itemId: 'gnupg_private_keys', label: 'GnuPG private keys', re: /(?:^|\/)\.gnupg\/(?:private-keys-v1\.d|secring\.gpg)(?:$|[/?#\s'"`);|&<>])/ },
605
+ { itemId: 'pgpass', label: 'PostgreSQL password file', re: /(?:^|\/)(?:\.pgpass|pgpass\.conf)(?:$|[/?#\s'"`);|&<>])/ },
606
+ { itemId: 'mysql_cnf', label: 'MySQL credentials file', re: /(?:^|\/)\.my\.cnf(?:$|[/?#\s'"`);|&<>])/ },
607
+ { itemId: 'terraform_credentials', label: 'Terraform credentials', re: /credentials\.tfrc\.json(?:$|[/?#\s'"`);|&<>])/ },
608
+ { itemId: 'huggingface_token', label: 'Hugging Face token', re: /(?:^|\/)\.?huggingface\/token(?:$|[/?#\s'"`);|&<>])/ },
609
+ { itemId: 'cargo_credentials', label: 'Cargo registry credentials', re: /(?:^|\/)\.cargo\/credentials(?:\.toml)?(?:$|[/?#\s'"`);|&<>])/ },
610
+ { itemId: 'gem_credentials', label: 'RubyGems credentials', re: /(?:^|\/)\.gem\/credentials(?:$|[/?#\s'"`);|&<>])/ },
611
+ { itemId: 'composer_auth', label: 'Composer auth tokens', re: /(?:^|\/)\.composer\/auth\.json(?:$|[/?#\s'"`);|&<>])/ },
612
+ { itemId: 'npmrc', label: 'npm credentials', re: /(?:^|\/)\.npmrc(?:$|[/?#\s'"`);|&<>])/ },
613
+ { itemId: 'pypirc', label: 'PyPI credentials', re: /(?:^|\/)\.pypirc(?:$|[/?#\s'"`);|&<>])/ },
614
+ { itemId: 'netrc', label: 'netrc credentials', re: /(?:^|\/)\.netrc(?:$|[/?#\s'"`);|&<>])/ },
615
+ { itemId: 'gcp_adc', label: 'GCP ADC credentials', re: /application_default_credentials\.json(?:$|[/?#\s'"`);|&<>])/ },
616
+ { itemId: 'linux_shadow', label: 'Linux shadow file', re: /(?:^|\/)etc\/(?:shadow|sudoers)(?:$|[/?#\s'"`);|&<>])/ },
617
+ { itemId: 'powershell_history', label: 'PowerShell history', re: /powershell\/psreadline\/consolehost_history\.txt(?:$|[/?#\s'"`);|&<>])/ },
603
618
  // Anchored to real browser-profile paths (Chromium: "User Data/<profile>/Network/Cookies",
604
619
  // "<profile>/Login Data"; Firefox: key4.db / logins.json / cookies.sqlite). Bare English
605
620
  // words like "cookies" in prose, code, or a "/cookies" web route must NOT match — the
606
621
  // unanchored version over-blocked ordinary web-dev work.
607
- { itemId: 'browser_credentials', label: 'browser credential store', re: /(?:^|\/)(?:(?:default|profile [0-9]+|network)\/cookies|login data|cookies\.sqlite|key4\.db|logins\.json)(?:$|[/?#\s'"])/ },
622
+ { itemId: 'browser_credentials', label: 'browser credential store', re: /(?:^|\/)(?:(?:default|profile [0-9]+|network)\/cookies|login data|cookies\.sqlite|key4\.db|logins\.json)(?:$|[/?#\s'"`);|&<>])/ },
608
623
  ];
609
624
  for (const check of checks) {
610
625
  if (check.re.test(text))
@@ -718,6 +733,181 @@ function credentialCommandReason(value, options) {
718
733
  return undefined;
719
734
  });
720
735
  }
736
+ // ─────────────────────────────────────────────────────────────────────────────
737
+ // Remote-control AI bridges: tools that hand THIS machine's terminal / files /
738
+ // MCP tools to an AI running somewhere else (a remote MCP device, a local MCP
739
+ // server re-published on a network port). The catalog is the one place these
740
+ // are named; every surface — IDE hooks, the PowerShell / cmd / bash guards,
741
+ // MCP config discovery, the action-policy engine fact — matches through the
742
+ // patterns generated below. A package matches only in its REMOTE mode, chosen
743
+ // by the argument after the package (`desktop-commander remote`) or by the
744
+ // serving flag (`supergateway --stdio`); the same package in local stdio mode is
745
+ // an ordinary local MCP server.
746
+ //
747
+ // `remote_connector` entries (mcp-remote, supergateway --sse) connect a LOCAL
748
+ // agent to a remote server — they do not give an outside AI this machine, so
749
+ // they are labelled in the MCP inventory and never asked about.
750
+ // ─────────────────────────────────────────────────────────────────────────────
751
+ exports.REMOTE_CONTROL_CATALOG_VERSION = '2026-09-26.1';
752
+ // The first positional argument after the package (flags may precede it).
753
+ const RC_FIRST_POSITIONAL_REMOTE = String.raw `(?:\s+-{1,2}[a-z][\w-]*(?:=[^\s;&|]*)?)*\s+["']?remote["']?(?=[\s;&|)]|$)`;
754
+ const RC_SERVES_STDIO = String.raw `(?=[^;&|\n]*\s--stdio(?:[=\s]|$))`;
755
+ const RC_SERVES_PORT = String.raw `(?=[^;&|\n]*\s--(?:sse-)?(?:port|host)(?:[=\s]|$))`;
756
+ const RC_CONNECTS_URL = String.raw `(?=[^;&|\n]*\s["']?https?://)`;
757
+ exports.REMOTE_CONTROL_BRIDGES = [
758
+ {
759
+ id: 'desktop_commander_remote', title: 'Desktop Commander', kind: 'remote_control',
760
+ packages: ['@wonderwhy-er/desktop-commander'], bins: ['desktop-commander'],
761
+ mode: 'remote', modePattern: RC_FIRST_POSITIONAL_REMOTE,
762
+ label: 'Desktop Commander in remote mode',
763
+ card: "This starts Desktop Commander in remote mode, which lets an AI outside your company control this computer's terminal and files.",
764
+ },
765
+ {
766
+ id: 'supergateway_stdio_serve', title: 'Supergateway', kind: 'remote_control',
767
+ packages: ['supergateway'], bins: ['supergateway'],
768
+ mode: 'serve', modePattern: RC_SERVES_STDIO,
769
+ label: 'Supergateway publishing a local MCP server',
770
+ card: "This starts Supergateway, which publishes a local MCP server on a network port so an AI outside this computer can use its tools — often this computer's terminal and files.",
771
+ },
772
+ {
773
+ id: 'mcp_proxy_serve', title: 'mcp-proxy', kind: 'remote_control',
774
+ packages: ['mcp-proxy'], bins: ['mcp-proxy'],
775
+ mode: 'serve', modePattern: RC_SERVES_PORT,
776
+ label: 'mcp-proxy publishing a local MCP server',
777
+ card: "This starts mcp-proxy in server mode, which publishes a local MCP server on a network port so an AI outside this computer can use its tools — often this computer's terminal and files.",
778
+ },
779
+ {
780
+ id: 'mcp_remote_connector', title: 'mcp-remote', kind: 'remote_connector',
781
+ packages: ['mcp-remote'], bins: ['mcp-remote'],
782
+ mode: 'connect', modePattern: RC_CONNECTS_URL,
783
+ label: 'mcp-remote connecting to a remote MCP server',
784
+ card: 'This connects a local AI client to an MCP server on the internet. It does not give an outside AI control of this computer.',
785
+ },
786
+ {
787
+ id: 'supergateway_connector', title: 'Supergateway', kind: 'remote_connector',
788
+ packages: ['supergateway'], bins: ['supergateway'],
789
+ mode: 'connect', modePattern: String.raw `(?![^;&|\n]*\s--stdio(?:[=\s]|$))(?=[^;&|\n]*\s--(?:sse|streamablehttp)(?:[=\s]|$))`,
790
+ label: 'Supergateway connecting to a remote MCP server',
791
+ card: 'This connects a local AI client to an MCP server on the internet. It does not give an outside AI control of this computer.',
792
+ },
793
+ ];
794
+ function escapeRegexLiteral(value) {
795
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
796
+ }
797
+ const RC_EXT = String.raw `(?:\.cmd|\.exe|\.ps1)?`;
798
+ // Statement start, then transparent wrappers (`sudo`, `env X=1`, `cmd /c`, `bash -c "`, `pwsh -Command "`).
799
+ const RC_START = String.raw `(?:^|[;&|(\n])\s*`;
800
+ const RC_WRAPPERS = String.raw `(?:(?:(?:sudo|doas)(?:\s+-[a-z]+)*|env|nohup|time|exec|command|call|start|[a-z_][a-z0-9_]*=[^\s;&|]*|(?:ba|z|da|k)?sh\s+-l?c|cmd(?:\.exe)?\s+/[ck]|(?:powershell|pwsh)(?:\.exe)?(?:\s+-[a-z]+)*?\s+-c(?:ommand)?)\s+["']?)*`;
801
+ const RC_PATH = String.raw `(?:[^\s;&|"']*[\\/])?`;
802
+ const RC_LAUNCHER = String.raw `(?:(?:npx|pnpx|bunx|uvx)${RC_EXT}|npm${RC_EXT}\s+(?:exec|x)|pnpm${RC_EXT}\s+(?:dlx|exec)|yarn${RC_EXT}\s+dlx|bun${RC_EXT}\s+x|uv${RC_EXT}\s+tool\s+run|pipx${RC_EXT}\s+run)`;
803
+ // Launcher options (`-y`, `--package=x`, `-p x`, `--from x`); a value never starts with `-`.
804
+ const RC_LAUNCHER_OPTIONS = String.raw `(?:\s+-{1,2}[a-z][\w-]*(?:=[^\s;&|]*)?(?:\s+[^\s;&|-][^\s;&|]*)?)*?`;
805
+ const RC_TOKEN_END = String.raw `(?=[\s;&|)]|$)`;
806
+ /** The positional pattern for one catalog entry: program at a statement start, package, then its mode. */
807
+ function remoteControlBridgePattern(bridge) {
808
+ const names = Array.from(new Set([...bridge.packages, ...bridge.bins]))
809
+ .sort((a, b) => b.length - a.length).map(escapeRegexLiteral).join('|');
810
+ const bins = bridge.bins.slice().sort((a, b) => b.length - a.length).map(escapeRegexLiteral).join('|');
811
+ const packageToken = String.raw `["']?(?:${names})(?:@[^\s"';&|)]*|==[^\s"';&|)]*|\[[^\]\s]*\])?["']?${RC_TOKEN_END}`;
812
+ const binToken = String.raw `(?:${bins})${RC_EXT}${RC_TOKEN_END}`;
813
+ return `${RC_START}${RC_WRAPPERS}${RC_PATH}(?:${RC_LAUNCHER}${RC_LAUNCHER_OPTIONS}(?:\\s+--)?\\s+${packageToken}|${binToken})${bridge.modePattern}`;
814
+ }
815
+ const REMOTE_CONTROL_BRIDGE_REGEXES = exports.REMOTE_CONTROL_BRIDGES.map(bridge => ({ bridge, re: new RegExp(remoteControlBridgePattern(bridge), 'i') }));
816
+ /** Shared command rules for the terminal guards: one per remote-control entry, same Local Safety item. */
817
+ exports.REMOTE_CONTROL_COMMAND_RULES = exports.REMOTE_CONTROL_BRIDGES
818
+ .filter(bridge => bridge.kind === 'remote_control')
819
+ .map(bridge => ({
820
+ itemId: 'remote_control_bridge',
821
+ categoryId: 'remote_control',
822
+ severity: 'critical',
823
+ reason: bridge.card,
824
+ pattern: remoteControlBridgePattern(bridge),
825
+ commands: ['npx', 'pnpx', 'bunx', 'uvx', 'pipx', 'npm', 'pnpm', 'yarn', 'bun', 'uv', ...bridge.bins],
826
+ }));
827
+ const RC_LAUNCHER_NAME = /(?:^|[\s\\/"'(;&|])(npx|pnpx|bunx|uvx|npm\s+(?:exec|x)|pnpm\s+(?:dlx|exec)|yarn\s+dlx|bun\s+x|uv\s+tool\s+run|pipx\s+run)(?=\s)/i;
828
+ function launcherOf(matched) {
829
+ const hit = RC_LAUNCHER_NAME.exec(matched.replace(/\.(?:cmd|exe|ps1)\b/gi, ''));
830
+ return hit ? hit[1].toLowerCase().replace(/\s+/g, ' ') : 'direct';
831
+ }
832
+ function matchRemoteBridge(text, kind) {
833
+ for (const { bridge, re } of REMOTE_CONTROL_BRIDGE_REGEXES) {
834
+ if (bridge.kind !== kind)
835
+ continue;
836
+ const hit = re.exec(text);
837
+ if (hit)
838
+ return { bridge, facts: { bridge: bridge.id, mode: bridge.mode, launcher: launcherOf(hit[0]) } };
839
+ }
840
+ return undefined;
841
+ }
842
+ /** A shell line that starts a remote-control bridge (any statement, any de-obfuscated variant). */
843
+ function remoteControlBridgeInCommand(command) {
844
+ if (!command || command.length > 32_000)
845
+ return undefined;
846
+ return matchCommand(stripInertDataSegments(command), text => matchRemoteBridge(text, 'remote_control'));
847
+ }
848
+ /**
849
+ * An MCP server launch (config `command` + `args`) that is a catalogued bridge:
850
+ * a remote-control bridge first, else a remote connector. Arguments with
851
+ * spaces are re-quoted so a `--stdio "npx server"` value stays one argument.
852
+ */
853
+ function classifyRemoteMcpLaunch(command, args = []) {
854
+ if (!command)
855
+ return undefined;
856
+ const line = [command, ...args].map(part => (/\s/.test(part) ? `"${part.replace(/"/g, '')}"` : part)).join(' ');
857
+ return matchRemoteBridge(line, 'remote_control') || matchRemoteBridge(line, 'remote_connector');
858
+ }
859
+ const RC_LAUNCHERS = new Set(['npx', 'pnpx', 'bunx', 'uvx', 'npm exec', 'npm x', 'pnpm dlx', 'pnpm exec', 'yarn dlx', 'bun x', 'uv tool run', 'pipx run', 'direct']);
860
+ /** Wire-supplied facts constrained to the catalog vocabulary (backend ingest); anything else is dropped. */
861
+ function sanitizeRemoteControlFacts(raw) {
862
+ if (!raw || typeof raw !== 'object')
863
+ return undefined;
864
+ const value = raw;
865
+ const bridge = exports.REMOTE_CONTROL_BRIDGES.find(entry => entry.id === value.bridge);
866
+ if (!bridge || value.mode !== bridge.mode || typeof value.launcher !== 'string' || !RC_LAUNCHERS.has(value.launcher))
867
+ return undefined;
868
+ return { bridge: bridge.id, mode: bridge.mode, launcher: value.launcher };
869
+ }
870
+ /** The engine fact provider: the catalog id of the remote-control bridge this line starts. */
871
+ function remoteControlFactFor(command) {
872
+ return remoteControlBridgeInCommand(command)?.bridge.id;
873
+ }
874
+ /** What an "Always allow" on a bridge is bound to: the package and its mode, never the launcher that ran it. */
875
+ function remoteControlGrantKey(bridge) {
876
+ return `${bridge.packages[0]} ${bridge.mode}`;
877
+ }
878
+ /**
879
+ * Every distinct remote-control bridge a line starts (as grant keys), plus the
880
+ * line with each verbatim launch cut out — statement separators kept — so the
881
+ * caller can still read the OTHER programs on the line. A launch seen only in a
882
+ * de-obfuscated variant is not cut: the rest is over-counted, never under-counted.
883
+ */
884
+ function remoteControlLaunches(command) {
885
+ if (!command || command.length > 32_000)
886
+ return { bridges: [], rest: command || '' };
887
+ const seen = new Set();
888
+ matchCommand(stripInertDataSegments(command), text => {
889
+ for (const { bridge, re } of REMOTE_CONTROL_BRIDGE_REGEXES) {
890
+ if (bridge.kind === 'remote_control' && !seen.has(bridge.id) && re.test(text))
891
+ seen.add(bridge.id);
892
+ }
893
+ return undefined;
894
+ });
895
+ const bridges = new Set();
896
+ let rest = command;
897
+ for (const { bridge, re } of REMOTE_CONTROL_BRIDGE_REGEXES) {
898
+ if (!seen.has(bridge.id))
899
+ continue;
900
+ bridges.add(remoteControlGrantKey(bridge));
901
+ rest = rest.replace(new RegExp(re.source, 'gi'), hit => `${/^[;&|(\n]/.test(hit) ? hit[0] : ''} `);
902
+ }
903
+ return { bridges: [...bridges].sort(), rest };
904
+ }
905
+ function canonicalRemoteLaunch(match) {
906
+ const direct = match.facts.launcher === 'direct';
907
+ const program = direct ? match.bridge.bins[0] : `${match.facts.launcher} ${match.bridge.packages[0]}`;
908
+ const mode = match.bridge.mode === 'remote' ? ' remote' : match.bridge.id === 'supergateway_stdio_serve' ? ' --stdio' : ' --port';
909
+ return `${program}${mode}`;
910
+ }
721
911
  // Container-scoped execution context immediately before an `rm` hit: a
722
912
  // docker/podman/nerdctl/kubectl exec|run (incl. `compose exec/run`) earlier in
723
913
  // the SAME statement (no ; & | between). Inside that context a bare `rm -rf *`
@@ -817,7 +1007,7 @@ function astDestructiveRm(facts, inContainer = false) {
817
1007
  for (const target of targets) {
818
1008
  const t = target.value.trim().replace(/\\/g, '/');
819
1009
  const lower = t.toLowerCase();
820
- if (/^\/+\*?$/.test(t))
1010
+ if (isRootRemovalTarget(t))
821
1011
  return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
822
1012
  if (/^(?:~|\$home|\$\{home\}|%userprofile%|\$env:userprofile)(?:\/+\*?)?$/.test(lower))
823
1013
  return { itemId: 'rm_rf_home', reason: 'recursive force delete of the home directory' };
@@ -857,6 +1047,11 @@ function destructiveCommandDecision(value) {
857
1047
  return regexHit;
858
1048
  if (astHit)
859
1049
  return astHit;
1050
+ // The AST parsed the literal command only. A hit that exists only in a
1051
+ // decoded or de-obfuscated surface (`echo <base64> | base64 -d | sh`) is
1052
+ // outside what it saw, so it cannot clear it.
1053
+ if (regexRm && !RM_SCOPE_ITEMS.has(destructiveCommandReason(value, { literalOnly: true })?.itemId || ''))
1054
+ return regexHit;
860
1055
  if (regexRm) {
861
1056
  // The AST cleared the rm-scope hit; other destructive rules still apply.
862
1057
  return destructiveCommandReason(value, { skipRmScope: true });
@@ -898,8 +1093,60 @@ function cdScopedBeforeBareStar(before) {
898
1093
  // At least one real path component that is not `.`/`..`.
899
1094
  return lower.split('/').some(part => part && part !== '.' && part !== '..');
900
1095
  }
1096
+ /**
1097
+ * An absolute removal target that resolves to the filesystem root or every
1098
+ * entry in it: `/`, `//`, `/*`, `/./`, `/tmp/..`, `/var/tmp/../../*`. A named
1099
+ * path under a root (`/tmp/test-crate-cache`) is not root scope.
1100
+ */
1101
+ function isRootRemovalTarget(raw) {
1102
+ const target = raw.replace(/^["']|["']$/g, '').replace(/\\/g, '/');
1103
+ if (!target.startsWith('/'))
1104
+ return false;
1105
+ const parts = [];
1106
+ for (const part of target.split('/')) {
1107
+ if (!part || part === '.')
1108
+ continue;
1109
+ if (part === '..')
1110
+ parts.pop();
1111
+ else
1112
+ parts.push(part);
1113
+ }
1114
+ return parts.length === 0 || (parts.length === 1 && parts[0] === '*');
1115
+ }
1116
+ /** Every operand of a recursive, forced `rm` — not only the first one after the flags. */
1117
+ function rmRemovesRoot(lower) {
1118
+ for (const invocation of lower.matchAll(/(?:^|[\s;&|(`])(?:sudo\s+)?rm((?:\s+[^\s;&|)`]+)+)/g)) {
1119
+ const words = invocation[1].trim().split(/\s+/);
1120
+ let recursive = false, force = false, flagsDone = false;
1121
+ const operands = [];
1122
+ for (const word of words) {
1123
+ if (!flagsDone && word === '--') {
1124
+ flagsDone = true;
1125
+ continue;
1126
+ }
1127
+ if (!flagsDone && /^--/.test(word)) {
1128
+ if (word === '--recursive')
1129
+ recursive = true;
1130
+ if (word === '--force')
1131
+ force = true;
1132
+ continue;
1133
+ }
1134
+ if (!flagsDone && /^-[a-z]+$/.test(word)) {
1135
+ if (word.includes('r'))
1136
+ recursive = true;
1137
+ if (word.includes('f'))
1138
+ force = true;
1139
+ continue;
1140
+ }
1141
+ operands.push(word);
1142
+ }
1143
+ if (recursive && force && operands.some(isRootRemovalTarget))
1144
+ return true;
1145
+ }
1146
+ return false;
1147
+ }
901
1148
  function destructiveCommandReason(value, opts = {}) {
902
- return matchCommand(value, (text, lower) => {
1149
+ const check = (text, lower) => {
903
1150
  if (!opts.skipRmScope) {
904
1151
  // Flag cluster is order-independent (`-rf`, `-fr`, `-rfv`). Terminators
905
1152
  // include `)` so command substitution — `$(rm -rf /)` runs BEFORE the
@@ -913,6 +1160,9 @@ function destructiveCommandReason(value, opts = {}) {
913
1160
  const splitFlags = /\b(?:sudo\s+)?rm\s+((?:-(?:[a-z]+|-recursive|-force|-no-preserve-root)\s+){2,})["']?\/\*?["']?(?:\s|$|[;&|)])/.exec(lower);
914
1161
  if (splitFlags && /(?:^|\s)-(?:[a-z]*r|-recursive)/.test(splitFlags[1]) && /(?:^|\s)-(?:[a-z]*f|-force)/.test(splitFlags[1]))
915
1162
  return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
1163
+ // A root operand after another one, or reached by climbing (`rm -rf /tmp/x /`, `rm -rf /tmp/..`).
1164
+ if (rmRemovesRoot(lower))
1165
+ return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
916
1166
  // Home-ROOT only (`~`, `$HOME`, `%USERPROFILE%`, optional `/` or `/*`).
917
1167
  // A subdirectory (`~/.cache`, `~/node_modules`) is everyday cleanup.
918
1168
  if (/\b(?:sudo\s+)?rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+["']?(?:~|\$home|\$\{home\}|%userprofile%|\$env:userprofile)(?:\/\*?)?["']?(?:\s|$|[;&|)])/.test(lower)) {
@@ -963,7 +1213,11 @@ function destructiveCommandReason(value, opts = {}) {
963
1213
  if (/\bgit\s+(?:reset\s+--hard|clean\s+-f(?:d|x|dx)?\b)/i.test(text))
964
1214
  return { itemId: 'git_clean_force', reason: 'destructive git clean/reset' };
965
1215
  return undefined;
966
- });
1216
+ };
1217
+ if (!opts.literalOnly)
1218
+ return matchCommand(value, check);
1219
+ const literal = normalizeForCommand(value);
1220
+ return check(literal, literal.toLowerCase());
967
1221
  }
968
1222
  function reverseShellReason(value) {
969
1223
  return matchCommand(value, (text) => {
@@ -1101,12 +1355,29 @@ const METADATA_SEARCH_PROGRAMS = new Set([
1101
1355
  * Split on unquoted `;` / `&&` / `||` / `|` / newline so a pipe cannot hide a
1102
1356
  * later client (`rg … | curl http://169.254…`) while `rg "a|b"` stays one segment.
1103
1357
  */
1358
+ /**
1359
+ * PowerShell escape: inside `"…"` a backtick makes the next character literal
1360
+ * (`` `" `` does not close the string); outside quotes `` `" `` / `` `' `` /
1361
+ * `` `$ `` and a trailing backtick (line continuation) are escapes, not a
1362
+ * POSIX command substitution.
1363
+ */
1364
+ function isPowerShellBacktickEscape(text, i, quote) {
1365
+ if (text[i] !== '`' || i + 1 >= text.length)
1366
+ return false;
1367
+ if (quote === '"')
1368
+ return true;
1369
+ return quote === null && /["'$\s]/.test(text[i + 1]);
1370
+ }
1104
1371
  function splitUnquotedCommandSegments(command) {
1105
1372
  const out = [];
1106
1373
  let cur = '';
1107
1374
  let quote = null;
1108
1375
  for (let i = 0; i < command.length; i++) {
1109
1376
  const c = command[i];
1377
+ if (isPowerShellBacktickEscape(command, i, quote)) {
1378
+ cur += c + command[++i];
1379
+ continue;
1380
+ }
1110
1381
  if (quote) {
1111
1382
  cur += c;
1112
1383
  if (c === '\\' && i + 1 < command.length) {
@@ -1272,11 +1543,15 @@ function unquotedCode(text) {
1272
1543
  let out = '';
1273
1544
  for (let i = 0; i < text.length; i++) {
1274
1545
  const c = text[i];
1546
+ if (isPowerShellBacktickEscape(text, i, null)) {
1547
+ out += c + text[++i];
1548
+ continue;
1549
+ }
1275
1550
  if (c === "'" || c === '"' || c === '`') {
1276
1551
  const q = c;
1277
1552
  i += 1;
1278
1553
  while (i < text.length) {
1279
- if (text[i] === '\\') {
1554
+ if (text[i] === '\\' || (q === '"' && text[i] === '`')) {
1280
1555
  i += 2;
1281
1556
  continue;
1282
1557
  }
@@ -1304,7 +1579,7 @@ function stripInertDataSegments(text) {
1304
1579
  // Generic quoted-span pass: whitespace inside quotes ⇒ free text ⇒ inert,
1305
1580
  // unless the span sits in a code position (invariants 2–4 above).
1306
1581
  const src = out;
1307
- out = src.replace(/"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'/g, (whole, dq, sq, offset) => {
1582
+ out = src.replace(/"((?:[^"\\`]|\\.|`[\s\S])*)"|'((?:[^'\\]|\\.)*)'/g, (whole, dq, sq, offset) => {
1308
1583
  const body = dq !== undefined ? dq : (sq || '');
1309
1584
  if (!/\s/.test(body))
1310
1585
  return whole; // single token: argv, not prose
@@ -1417,7 +1692,58 @@ function credentialAccessCommandText(text) {
1417
1692
  return text;
1418
1693
  statements.push(current);
1419
1694
  // A real shell separator is a boundary of the preceding path, even without spaces.
1420
- return statements.map(statement => allowMetadata && (isFileMetadataQuery(statement) || isMetadataOnlyStatement(statement)) ? 'fcd_file_metadata' : statement).join('\n');
1695
+ return statements.map(statement => {
1696
+ if (!allowMetadata)
1697
+ return statement;
1698
+ if (isFileMetadataQuery(statement) || isMetadataOnlyStatement(statement))
1699
+ return 'fcd_file_metadata';
1700
+ return withoutWrittenLiterals(statement);
1701
+ }).join('\n');
1702
+ }
1703
+ /**
1704
+ * access.role of a QUOTED LITERAL, from the programs that receive it. A string
1705
+ * that is only printed or written into a file is data, whatever path it spells:
1706
+ * `'{"file_path":"~/.aws/credentials"}' | Set-Content fixture.json` and
1707
+ * `echo '…/.aws/credentials' > case.txt` open nothing. The producer is a bare
1708
+ * literal (a PowerShell value) or a printer; every consumer is a content
1709
+ * writer or formatter whose pipeline input is TEXT, never a path it opens
1710
+ * (`'~/.aws/credentials' | Get-Content` is a read and stays one). Unquoted
1711
+ * operands — the file written, a `>` target — are never masked, so writing TO a
1712
+ * credential file is still seen. Substitution, grouping, script blocks and
1713
+ * stdin redirection keep the statement as it is.
1714
+ */
1715
+ const LITERAL_PRINTERS = new Set(['echo', 'printf', 'write-output', 'write', 'write-host']);
1716
+ const LITERAL_CONTENT_SINKS = new Set([
1717
+ 'set-content', 'sc', 'add-content', 'ac', 'out-file', 'tee', 'tee-object', 'out-null', 'out-string', 'convertto-json', 'convertfrom-json',
1718
+ ]);
1719
+ const QUOTED_LITERAL_RE = /^(?:"[^"\r\n]*"|'(?:[^'\r\n]|'')*')$/;
1720
+ function withoutWrittenLiterals(statement) {
1721
+ if (/\$\(|`/.test(statement) || /[(){}<]/.test(unquotedCode(statement)))
1722
+ return statement;
1723
+ const tokens = statement.trim().match(/"[^"\r\n]*"|'(?:[^'\r\n]|'')*'|[^\s"']+/g) || [];
1724
+ if (!tokens.some(token => QUOTED_LITERAL_RE.test(token)))
1725
+ return statement;
1726
+ const segments = [[]];
1727
+ for (const token of tokens) {
1728
+ if (token === '|')
1729
+ segments.push([]);
1730
+ else
1731
+ segments[segments.length - 1].push(token);
1732
+ }
1733
+ const producer = segments[0];
1734
+ const bareLiteral = producer.length > 0 && producer.every(token => QUOTED_LITERAL_RE.test(token));
1735
+ if (!bareLiteral && !LITERAL_PRINTERS.has(leadingProgram(producer).program))
1736
+ return statement;
1737
+ if (!segments.slice(1).every(segment => LITERAL_CONTENT_SINKS.has(leadingProgram(segment).program)))
1738
+ return statement;
1739
+ // Only the producer's quoted operands are data; `>` targets and sink operands stay.
1740
+ let redirectTarget = false;
1741
+ const masked = producer.map(token => {
1742
+ const isTarget = redirectTarget;
1743
+ redirectTarget = /^[\d*&]?>>?\|?$/.test(token);
1744
+ return !isTarget && QUOTED_LITERAL_RE.test(token) ? "'fcd_written_literal'" : token;
1745
+ });
1746
+ return [masked, ...segments.slice(1)].map(segment => segment.join(' ')).join(' | ');
1421
1747
  }
1422
1748
  /**
1423
1749
  * access.role of a statement, from its LEADING PROGRAM: what does this program
@@ -1548,6 +1874,13 @@ function scanTextValue(toolName, rawValue, options, commandContext = false) {
1548
1874
  if (finding)
1549
1875
  return finding;
1550
1876
  }
1877
+ const remoteBridge = remoteControlBridgeInCommand(value);
1878
+ if (remoteBridge) {
1879
+ // Evidence is the canonical launch (launcher, package, mode) — never the argument text.
1880
+ const finding = builtIn('remote_control_bridge', 'remote_control', 'remote_control', 'local-remote-control-bridge', remoteBridge.bridge.card, canonicalRemoteLaunch(remoteBridge), remoteBridge.bridge.label, options);
1881
+ if (finding)
1882
+ return { ...finding, remoteControl: remoteBridge.facts };
1883
+ }
1551
1884
  const destructive = destructiveCommandDecision(value);
1552
1885
  if (destructive) {
1553
1886
  return builtIn(destructive.itemId, 'destructive_filesystem', 'destructive_command', 'local-destructive-command', `Blocked ${destructive.reason}.`, value, destructive.reason, options);
@@ -1562,6 +1895,213 @@ function scanTextValue(toolName, rawValue, options, commandContext = false) {
1562
1895
  }
1563
1896
  return undefined;
1564
1897
  }
1898
+ // ---- agent guard tamper: the agent switching off the hooks that police it ----
1899
+ // Every rule in this file stops working once the agent edits the file that
1900
+ // registers the hook. These rules decide on one structural fact: the TARGET is
1901
+ // an AI agent's hook/settings file and the action turns hooks off, removes or
1902
+ // rewrites that file, or uninstalls FullCourtDefense. Reading those files, or
1903
+ // editing them for anything else (permissions, MCP servers, env), never matches.
1904
+ /** Agent hook/settings files, matched on the normalized path (lowercase, `/`). */
1905
+ const AGENT_HOOK_CONFIG_RE = /(?:^|[/\s'"=:(])(?:\.claude\/settings(?:\.local)?\.json|\.cursor\/hooks\.json|\.codex\/hooks\.json|\.copilot\/settings\.json|\.gemini\/settings\.json|\.codeium\/windsurf\/hooks\.json|\.kiro\/hooks\/[^\s'"/]+\.json|(?:claudecode|claude-code)\/managed-settings(?:\.d\/[^\s'"/]+)?\.json|(?:programdata|etc|application support)\/cursor\/hooks\.json)(?=$|[\s'"`;|&)>,])/;
1906
+ /** A value that turns every non-managed hook off (Claude-format settings keys). */
1907
+ const HOOKS_OFF_SETTING_RE = /\b(?:disableAllHooks|allowManagedHooksOnly)["']?\s*[:=]\s*(?:true|\$true|1)\b/i;
1908
+ const CLAUDE_CONFIG_SET_HOOKS_OFF_RE = /\bconfig\s+set\s+(?:(?:-g|--global)\s+)?(?:disableAllHooks|allowManagedHooksOnly)\s+(?:true|1)\b/i;
1909
+ const HOOK_CONFIG_MUTATING_PROGRAMS = new Set([
1910
+ 'rm', 'rmdir', 'unlink', 'shred', 'truncate', 'del', 'erase', 'rd', 'remove-item', 'ri',
1911
+ 'mv', 'move', 'move-item', 'mi', 'ren', 'rename', 'rename-item', 'rni',
1912
+ 'tee', 'tee-object', 'sponge', 'set-content', 'add-content', 'ac', 'clear-content', 'clc', 'out-file',
1913
+ ]);
1914
+ const HOOK_CONFIG_COPY_PROGRAMS = new Set(['cp', 'copy', 'copy-item', 'cpi', 'xcopy', 'robocopy']);
1915
+ const HOOK_CONFIG_READ_ONLY_PROGRAMS = new Set([
1916
+ 'cat', 'type', 'get-content', 'gc', 'less', 'more', 'head', 'tail', 'jq', 'rg', 'grep', 'egrep', 'fgrep',
1917
+ 'findstr', 'select-string', 'sls', 'ls', 'dir', 'get-item', 'gi', 'get-childitem', 'gci', 'stat', 'test-path',
1918
+ 'code', 'cursor', 'notepad', 'wc', 'diff', 'fc', 'git', 'echo', 'write-output', 'write-host', 'printf',
1919
+ ]);
1920
+ const INLINE_INTERPRETERS = new Set(['node', 'deno', 'bun', 'python', 'python3', 'py', 'perl', 'ruby', 'pwsh', 'powershell']);
1921
+ const INLINE_FILE_MUTATION_RE = /\b(?:writeFileSync|appendFileSync|unlinkSync|rmSync|renameSync|copyFileSync|WriteAllText|WriteAllLines|WriteAllBytes|AppendAllText|os\.remove|os\.unlink|os\.replace|shutil\.move|shutil\.copy(?:file)?)\b|\[(?:system\.)?io\.file\]::(?:delete|move|copy|writealltext|writealllines)|\bopen\([^)]*,\s*['"][wa]/i;
1922
+ const FCD_PROGRAMS = new Set(['fullcourtdefense', 'fcd', 'fullcourtdefense-cli']);
1923
+ const TAMPER_PATH_KEY_RE = /(?:path|file|filename|target|dest|destination|uri)$/i;
1924
+ const DELETE_TOOL_NAMES = new Set(['deletefile', 'delete', 'removefile', 'remove', 'rm', 'unlink']);
1925
+ function mentionsAgentHookConfig(text) {
1926
+ return AGENT_HOOK_CONFIG_RE.test(normalizeForPath(text));
1927
+ }
1928
+ function segmentTokens(segment) {
1929
+ return (segment.match(/(?:[^\s"']+|"(?:[^"`]|`[\s\S])*"|'[^']*')+/g) || []).map(token => token.replace(/^["']|["']$/g, ''));
1930
+ }
1931
+ function segmentProgram(tokens) {
1932
+ let i = 0;
1933
+ while (i < tokens.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]) || /^(?:sudo|doas|env|command|builtin|exec|nohup|time|&|\.)$/i.test(tokens[i])))
1934
+ i++;
1935
+ const program = (tokens[i] || '').toLowerCase().replace(/^.*[\\/]/, '').replace(/\.(?:exe|cmd|bat|ps1)$/, '');
1936
+ return { program, rest: tokens.slice(i + 1) };
1937
+ }
1938
+ /** Targets of unquoted `>` / `>>` redirections (stderr `2>` and `=>` / `->` are not file writes). */
1939
+ function redirectTargets(segment) {
1940
+ const out = [];
1941
+ for (const match of segment.matchAll(/(?:^|[^0-9&>=-])>{1,2}\s*(["']?)([^\s"'|;&<>]+)\1/g))
1942
+ out.push(match[2]);
1943
+ return out;
1944
+ }
1945
+ function isFcdUninstall(program, rest) {
1946
+ let prog = program;
1947
+ let args = rest;
1948
+ if (prog === 'npx' || prog === 'pnpx' || prog === 'bunx') {
1949
+ const operands = args.filter(token => !token.startsWith('-'));
1950
+ prog = (operands[0] || '').toLowerCase().replace(/(?!^)@.*$/, '');
1951
+ args = operands.slice(1);
1952
+ }
1953
+ if (FCD_PROGRAMS.has(prog))
1954
+ return /^uninstall/i.test(args.find(token => !token.startsWith('-')) || '');
1955
+ if (prog === 'npm' || prog === 'pnpm' || prog === 'yarn' || prog === 'bun') {
1956
+ return args.some(token => /^(?:uninstall|remove|rm|r|un|unlink)$/i.test(token))
1957
+ && args.some(token => /^fullcourtdefense-cli(?:@|$)/i.test(token));
1958
+ }
1959
+ if (prog === 'msiexec') {
1960
+ const at = args.findIndex(token => /^[/-](?:x|uninstall)$/i.test(token));
1961
+ return at >= 0 && /fullcourtdefense/i.test(args[at + 1] || '');
1962
+ }
1963
+ if (prog === 'schtasks') {
1964
+ if (!args.some(token => /^[/-](?:delete|disable)$/i.test(token)))
1965
+ return false;
1966
+ const at = args.findIndex(token => /^[/-]tn$/i.test(token));
1967
+ return at >= 0 && isFcdTaskName(args[at + 1] || '');
1968
+ }
1969
+ if (prog === 'unregister-scheduledtask' || prog === 'disable-scheduledtask') {
1970
+ return optionValues(args, /^-taskname$/i, /^-(?:taskpath|cimsession)$/i).some(isFcdTaskName);
1971
+ }
1972
+ if (prog === 'winget')
1973
+ return /^(?:uninstall|remove)$/i.test(args[0] || '') && args.slice(1).some(token => /fullcourtdefense/i.test(token));
1974
+ if (prog === 'taskkill') {
1975
+ const at = args.findIndex(token => /^[/-]im$/i.test(token));
1976
+ return at >= 0 && /^fullcourtdefense/i.test(args[at + 1] || '');
1977
+ }
1978
+ if (prog === 'stop-process' || prog === 'spps' || prog === 'kill' || prog === 'pkill' || prog === 'killall') {
1979
+ return optionValues(args, /^-(?:name|processname)$/i, /^-id$/i).some(name => /^fullcourtdefense/i.test(name));
1980
+ }
1981
+ if (INSTALL_ROOT_REMOVERS.has(prog)) {
1982
+ return args.filter(token => !/^-/.test(token) && !/^\/[a-z]{1,3}$/i.test(token)).some(isFcdInstallRoot);
1983
+ }
1984
+ return false;
1985
+ }
1986
+ /** Scheduled tasks the installer and CLI register. A name outside the product namespace is not ours. */
1987
+ const FCD_TASK_NAMES = [
1988
+ 'FullCourtDefense Updater', 'FullCourtDefense Daemon', 'FullCourtDefense Daemon Watchdog',
1989
+ 'FullCourtDefense Recovery', 'FullCourtDefense Auto-Protect', 'FullCourtDefenseDesktopDiscover',
1990
+ ];
1991
+ const INSTALL_ROOT_REMOVERS = new Set(['rm', 'rmdir', 'unlink', 'del', 'erase', 'rd', 'remove-item', 'ri', 'rimraf', 'mv', 'move', 'move-item', 'mi', 'ren', 'rename-item', 'rni']);
1992
+ /** A task name (or wildcard) that selects a FullCourtDefense task — by its product-namespace name, not a substring. */
1993
+ function isFcdTaskName(raw) {
1994
+ const name = raw.replace(/^["']|["']$/g, '').replace(/\\$/, '').replace(/^.*\\/, '').trim();
1995
+ if (!name)
1996
+ return false;
1997
+ if (!/[*?]/.test(name))
1998
+ return /^fullcourtdefense/i.test(name);
1999
+ const glob = new RegExp(`^${name.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`, 'i');
2000
+ return FCD_TASK_NAMES.some(task => glob.test(task));
2001
+ }
2002
+ /** The install root (`Program Files\FullCourtDefense`) or the machine state root (`ProgramData\FullCourtDefense`), or a path inside them. */
2003
+ function isFcdInstallRoot(raw) {
2004
+ const path = raw.replace(/^["']|["']$/g, '').replace(/\\/g, '/').toLowerCase();
2005
+ return /(?:^|\/|%|\$(?:env:|\{env:))(?:program ?files(?: ?\(x86\))?|programfiles\(x86\)|programdata|allusersprofile)[%}]?\/fullcourtdefense(?:\/|$)/.test(path);
2006
+ }
2007
+ /** Values of a named parameter plus positional operands (skipping the values of `skipNamed`). */
2008
+ function optionValues(args, named, skipNamed) {
2009
+ const out = [];
2010
+ for (let i = 0; i < args.length; i++) {
2011
+ const token = args[i];
2012
+ if (named.test(token)) {
2013
+ if (args[i + 1] !== undefined)
2014
+ out.push(...args[i + 1].split(','));
2015
+ i++;
2016
+ continue;
2017
+ }
2018
+ if (skipNamed.test(token)) {
2019
+ i++;
2020
+ continue;
2021
+ }
2022
+ if (/^-/.test(token))
2023
+ continue;
2024
+ out.push(...token.split(','));
2025
+ }
2026
+ return out;
2027
+ }
2028
+ /**
2029
+ * Shell view: per unquoted segment, the leading program decides. Quoted prose
2030
+ * (commit messages, issue bodies, echo text) is removed before redirections
2031
+ * and off-switch keys are judged, so writing ABOUT the setting never matches.
2032
+ */
2033
+ function hookConfigShellChange(command) {
2034
+ for (const rawSegment of splitUnquotedCommandSegments(command)) {
2035
+ const raw = rawSegment.trim();
2036
+ if (!raw)
2037
+ continue;
2038
+ const { program, rest } = segmentProgram(segmentTokens(raw));
2039
+ if (isFcdUninstall(program, rest)) {
2040
+ return { itemId: 'fcd_uninstalled_by_agent', reason: 'agent removing FullCourtDefense from this machine' };
2041
+ }
2042
+ const stripped = stripInertDataSegments(raw);
2043
+ if (program === 'claude' && CLAUDE_CONFIG_SET_HOOKS_OFF_RE.test(stripped)) {
2044
+ return { itemId: 'agent_hooks_switched_off', reason: 'agent turning off AI agent hooks' };
2045
+ }
2046
+ if (!mentionsAgentHookConfig(raw))
2047
+ continue;
2048
+ if (redirectTargets(stripped).some(mentionsAgentHookConfig)) {
2049
+ return { itemId: 'agent_hook_config_removed', reason: 'agent overwriting an AI agent hook configuration file' };
2050
+ }
2051
+ if (HOOK_CONFIG_READ_ONLY_PROGRAMS.has(program))
2052
+ continue;
2053
+ const operands = rest.filter(token => !/^-/.test(token) && !/^\/[a-z]{1,3}$/i.test(token));
2054
+ if (HOOK_CONFIG_MUTATING_PROGRAMS.has(program) && operands.some(mentionsAgentHookConfig)) {
2055
+ return { itemId: 'agent_hook_config_removed', reason: 'agent removing or rewriting an AI agent hook configuration file' };
2056
+ }
2057
+ if ((program === 'new-item' || program === 'ni') && rest.some(token => /^-force$/i.test(token)) && operands.some(mentionsAgentHookConfig)) {
2058
+ return { itemId: 'agent_hook_config_removed', reason: 'agent overwriting an AI agent hook configuration file' };
2059
+ }
2060
+ if ((program === 'sed' || program === 'perl') && rest.some(token => /^-[a-z]*i/i.test(token)) && operands.some(mentionsAgentHookConfig)) {
2061
+ return { itemId: 'agent_hook_config_removed', reason: 'agent editing an AI agent hook configuration file in place' };
2062
+ }
2063
+ if (HOOK_CONFIG_COPY_PROGRAMS.has(program) && operands.length >= 2 && mentionsAgentHookConfig(operands[operands.length - 1])) {
2064
+ return { itemId: 'agent_hook_config_removed', reason: 'agent replacing an AI agent hook configuration file' };
2065
+ }
2066
+ if (INLINE_FILE_MUTATION_RE.test(INLINE_INTERPRETERS.has(program) ? raw : stripped)) {
2067
+ return { itemId: 'agent_hook_config_removed', reason: 'agent rewriting an AI agent hook configuration file from a script' };
2068
+ }
2069
+ if (HOOKS_OFF_SETTING_RE.test(stripped)) {
2070
+ return { itemId: 'agent_hooks_switched_off', reason: 'agent turning off AI agent hooks' };
2071
+ }
2072
+ }
2073
+ return undefined;
2074
+ }
2075
+ /** Tool view (file edit / write / delete tools): the target path decides, the content only for the off-switch. */
2076
+ function hookConfigToolChange(toolName, candidates) {
2077
+ const target = candidates.find(candidate => {
2078
+ const lastKey = candidate.keyPath.split('.').pop() || '';
2079
+ return TAMPER_PATH_KEY_RE.test(lastKey) && !/[\r\n]/.test(candidate.value) && candidate.value.length < 1024
2080
+ && mentionsAgentHookConfig(candidate.value);
2081
+ });
2082
+ if (!target)
2083
+ return undefined;
2084
+ if (DELETE_TOOL_NAMES.has(toolName.toLowerCase().replace(/[_-]/g, ''))) {
2085
+ return { itemId: 'agent_hook_config_removed', reason: 'agent deleting an AI agent hook configuration file', evidence: target.value };
2086
+ }
2087
+ if (candidates.some(candidate => candidate !== target && HOOKS_OFF_SETTING_RE.test(candidate.value))) {
2088
+ return { itemId: 'agent_hooks_switched_off', reason: 'agent turning off AI agent hooks', evidence: target.value };
2089
+ }
2090
+ return undefined;
2091
+ }
2092
+ function agentGuardTamperFinding(toolName, candidates, options) {
2093
+ const finding = (hit, value) => builtIn(hit.itemId, 'agent_guard_tamper', 'agent_guard_tamper', 'local-agent-guard-tamper', `Agent action would switch off the protection on this machine: ${hit.reason}.`, value, hit.reason, options);
2094
+ for (const candidate of candidates) {
2095
+ if (!isCommandContext(toolName, candidate))
2096
+ continue;
2097
+ const hit = hookConfigShellChange(candidate.value);
2098
+ const found = hit && finding(hit, candidate.value);
2099
+ if (found)
2100
+ return found;
2101
+ }
2102
+ const hit = hookConfigToolChange(toolName, candidates);
2103
+ return hit ? finding(hit, hit.evidence) : undefined;
2104
+ }
1565
2105
  /**
1566
2106
  * A string can only DO something in an actionable context: a shell command, an outbound
1567
2107
  * request, or a path/target argument. File CONTENTS being written or edited are inert —
@@ -1592,6 +2132,19 @@ function scanDeterministicToolCall(toolName, toolArgs, options) {
1592
2132
  if (custom)
1593
2133
  return custom;
1594
2134
  }
2135
+ // A tamper finding asks by default; a chained `rm -rf / ; rm ~/.cursor/hooks.json`
2136
+ // must still hit the stricter built-in block, so a non-block tamper finding
2137
+ // yields only to a block.
2138
+ const tamper = agentGuardTamperFinding(toolName, candidates, options);
2139
+ const blocks = (finding) => (finding.action || 'block') === 'block';
2140
+ if (tamper && blocks(tamper))
2141
+ return tamper;
2142
+ const other = scanBuiltInRules(toolName, toolArgs, candidates, outbound, options);
2143
+ if (tamper && !(other && blocks(other)))
2144
+ return tamper;
2145
+ return other;
2146
+ }
2147
+ function scanBuiltInRules(toolName, toolArgs, candidates, outbound, options) {
1595
2148
  for (const candidate of candidates) {
1596
2149
  const finding = scanTextValue(toolName, candidate.value, options, isCommandContext(toolName, candidate));
1597
2150
  if (!finding)