fullcourtdefense-cli 1.35.10 → 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.
@@ -268,6 +268,7 @@ export interface ShellAstOperationComparison {
268
268
  agree: boolean;
269
269
  }
270
270
  export declare function setShellAstProvider(provider: ((command: string) => ShellAstFactsLike | undefined) | undefined): void;
271
+ export declare function setRemoteControlProvider(provider: ((command: string) => string | undefined) | undefined): void;
271
272
  export declare function setShellAstMode(mode: ShellAstMode): void;
272
273
  export declare function getShellAstMode(): ShellAstMode;
273
274
  /** Shadow telemetry: the comparison recorded by the most recent top-level shell classification (consumed once). */
@@ -42,6 +42,7 @@ __export(actionPolicyEngine_exports, {
42
42
  resolveApprovalOptions: () => resolveApprovalOptions,
43
43
  roleExceptionError: () => roleExceptionError,
44
44
  ruleNeedsContentFact: () => ruleNeedsContentFact,
45
+ setRemoteControlProvider: () => setRemoteControlProvider,
45
46
  setShellAstMode: () => setShellAstMode,
46
47
  setShellAstProvider: () => setShellAstProvider,
47
48
  takeShellAstOperationComparison: () => takeShellAstOperationComparison,
@@ -6777,6 +6778,7 @@ var OPERATION_MATCH_SKIP_FIELDS = /* @__PURE__ */ new Set([
6777
6778
  "tool.status",
6778
6779
  "operation.status",
6779
6780
  "agent.baseline",
6781
+ "toolArgs.remoteControl",
6780
6782
  // Identity / metadata — NOT action signals. Excluded so the tool/agent name can never
6781
6783
  // pollute operation matching.
6782
6784
  "toolName",
@@ -8160,6 +8162,10 @@ var lastShellAstOperationComparison;
8160
8162
  function setShellAstProvider(provider) {
8161
8163
  shellAstProvider = provider;
8162
8164
  }
8165
+ var remoteControlProvider;
8166
+ function setRemoteControlProvider(provider) {
8167
+ remoteControlProvider = provider;
8168
+ }
8163
8169
  function setShellAstMode(mode) {
8164
8170
  shellAstMode = mode;
8165
8171
  }
@@ -8719,9 +8725,22 @@ function inferToolContext(toolName, args) {
8719
8725
  context["destination.type"] = context["request.destination.type"];
8720
8726
  context["destination.domain"] = context["request.destination.domain"];
8721
8727
  }
8728
+ applyRemoteControlFact(toolName, args, context);
8722
8729
  applyWebReadFacts(toolName, args, context);
8723
8730
  return { operation, context };
8724
8731
  }
8732
+ function applyRemoteControlFact(toolName, args, context) {
8733
+ delete context["toolArgs.remoteControl"];
8734
+ const shellText = remoteControlProvider ? shellCommandTextOf(toolName, args) : void 0;
8735
+ if (!shellText || !remoteControlProvider) return;
8736
+ let bridge;
8737
+ try {
8738
+ bridge = remoteControlProvider(shellText);
8739
+ } catch {
8740
+ bridge = void 0;
8741
+ }
8742
+ if (bridge) context["toolArgs.remoteControl"] = bridge;
8743
+ }
8725
8744
  var WEB_READ_TOOL_NAMES = /* @__PURE__ */ new Set(["webfetch", "fetchwebpage", "webextract"]);
8726
8745
  function applyWebReadFacts(toolName, args, context) {
8727
8746
  if (!WEB_READ_TOOL_NAMES.has(toolName.toLowerCase().replace(/[_\-.\s]/g, ""))) return;
@@ -8855,6 +8874,7 @@ function inferBundledToolContext(toolName, args) {
8855
8874
  context["destination.type"] = isInternalHost(context["destination.domain"]) ? "internal" : "external";
8856
8875
  }
8857
8876
  }
8877
+ applyRemoteControlFact(toolName, args, context);
8858
8878
  applyWebReadFacts(toolName, args, context);
8859
8879
  return { operation, context };
8860
8880
  }
@@ -8975,6 +8995,7 @@ function inventoryToolMatchesActionPolicy(toolName, toolActions, policies) {
8975
8995
  resolveApprovalOptions,
8976
8996
  roleExceptionError,
8977
8997
  ruleNeedsContentFact,
8998
+ setRemoteControlProvider,
8978
8999
  setShellAstMode,
8979
9000
  setShellAstProvider,
8980
9001
  takeShellAstOperationComparison,
@@ -106,6 +106,8 @@ const FIXED_INTERCEPTED_COMMANDS = [
106
106
  const CMD_HIGH_FREQUENCY_COMMANDS = new Set([
107
107
  'docker', 'git', 'kubectl', 'aws', 'gcloud', 'az', 'terraform', 'pulumi', 'wsl',
108
108
  'curl', 'wget', 'taskkill', 'robocopy', 'xcopy', 'psql', 'mysql', 'mongo', 'mongosh', 'sqlcmd',
109
+ // Package managers: `npm exec` / `pnpm dlx` bridges are covered in PowerShell and the IDE; cmd keeps `npx`.
110
+ 'npm', 'pnpm', 'yarn', 'bun', 'uv',
109
111
  ]);
110
112
  /**
111
113
  * Anchors of the SHARED command rules (deterministicGuard ↔ shellGuard) that
@@ -466,7 +466,26 @@ function hookConfigFiles() {
466
466
  // ---------------------------------------------------------------------------
467
467
  // The resident loop
468
468
  // ---------------------------------------------------------------------------
469
+ /**
470
+ * A daemon started while the updater replaces the install (logon trigger, a
471
+ * pre-fix watchdog, a manual start) would put the old build — and its own
472
+ * update kick — into the middle of msiexec. Wait for the run instead. Bounded
473
+ * by the lock's max age; stage 2 relaunches the daemon when it finishes.
474
+ */
475
+ async function waitForActiveUpdaterRun() {
476
+ let run = (0, selfUpdate_1.readActiveUpdaterRun)();
477
+ if (!run)
478
+ return;
479
+ log(`An updater run is installing${run.targetVersion ? ` ${run.targetVersion}` : ''} (${run.stage || 'running'}, since ${run.startedAt || 'unknown'}) — daemon start waits for it to finish.`);
480
+ const deadline = Date.now() + (0, selfUpdate_1.updaterLockMaxAgeMs)();
481
+ while (run && Date.now() < deadline) {
482
+ await new Promise(resolve => setTimeout(resolve, 5_000));
483
+ run = (0, selfUpdate_1.readActiveUpdaterRun)();
484
+ }
485
+ log('Updater run finished — continuing daemon start.');
486
+ }
469
487
  async function runDaemon(args, config) {
488
+ await waitForActiveUpdaterRun();
470
489
  if (!acquirePidLock(await detectRecycledPidOwner())) {
471
490
  console.log(`${COLOR.yellow}Another FullCourtDefense daemon is already running (pid file: ${pidFile()}).${COLOR.reset}`);
472
491
  return;
@@ -518,7 +537,11 @@ async function runDaemon(args, config) {
518
537
  log(`Shield key UNAVAILABLE${ph && !ph.spawnOk ? ' — powershell.exe could not start (EDR/policy block?), so the DPAPI-protected key cannot be decrypted' : ''}. Telemetry/control-plane sync disabled; retrying periodically.`);
519
538
  }
520
539
  // Crash forensics: was the previous daemon killed without a clean shutdown?
540
+ const previousMarker = (0, daemonForensics_1.readAliveMarker)();
521
541
  const postmortem = (0, daemonForensics_1.recordDaemonStart)({ version: cliVersion(), isPidAlive });
542
+ if (previousMarker?.cleanExit?.reason === 'stopped-for-update' && previousMarker.pid !== process.pid) {
543
+ log(`Previous daemon (v${previousMarker.version || '?'}) was stopped for update by the updater (${previousMarker.cleanExit.at}) — expected, not a crash.`);
544
+ }
522
545
  if (postmortem) {
523
546
  const current = cliVersion();
524
547
  if (postmortem.version && current && postmortem.version !== current) {
@@ -529,6 +552,13 @@ async function runDaemon(args, config) {
529
552
  else if (postmortem.cause === 'machine_restart') {
530
553
  log(`Previous daemon (v${postmortem.version || '?'}) stopped when the machine restarted (booted ${postmortem.machineBootedAt || 'after its last heartbeat'}; last alive ${postmortem.lastAliveAt || 'unknown'}). Expected, not a crash. Post-mortem recorded for the timeline.`);
531
554
  }
555
+ else if ((0, selfUpdate_1.deathDuringUpdaterRun)(postmortem.lastAliveAt, (0, selfUpdate_1.readActiveUpdaterRun)() || (0, selfUpdate_1.readLastUpdaterResult)())) {
556
+ // Same version, but an updater run was replacing the install when it
557
+ // died (an update that failed, or a run whose marker stamp was lost):
558
+ // stopped for update — never an EDR/AV kill in the console.
559
+ (0, daemonForensics_1.markPostmortemReported)();
560
+ log(`Previous daemon (v${postmortem.version || '?'}) stopped for update — an updater run was replacing the install when it died (last alive ${postmortem.lastAliveAt || 'unknown'}). Not a crash; not reported as one.`);
561
+ }
532
562
  else {
533
563
  log(`Previous daemon (v${postmortem.version || '?'}) died UNCLEANLY — last alive ${postmortem.lastAliveAt || 'unknown'}, no shutdown recorded. Likely killed externally (EDR/AV, OOM, or forced termination). Post-mortem recorded; reporting upstream.`);
534
564
  }
@@ -1067,13 +1097,23 @@ async function runDaemon(args, config) {
1067
1097
  return;
1068
1098
  }
1069
1099
  const startedAt = marker.startedAt ? Date.parse(marker.startedAt) : NaN;
1070
- if (!Number.isFinite(startedAt) || Date.now() - startedAt > PENDING_UPGRADE_STALE_MS) {
1100
+ // The install is still running: its own outcome settles this, not a timer.
1101
+ if ((0, selfUpdate_1.readActiveUpdaterRun)())
1102
+ return;
1103
+ // The updater recorded a failed run for this target after the kick —
1104
+ // report its reason now instead of waiting out the stale timer.
1105
+ const lastRun = (0, selfUpdate_1.readLastUpdaterResult)();
1106
+ const runFailed = lastRun?.outcome === 'failed' && lastRun.targetVersion === marker.target
1107
+ && Number.isFinite(startedAt) && Date.parse(lastRun.finishedAt || '') >= startedAt;
1108
+ if (runFailed || !Number.isFinite(startedAt) || Date.now() - startedAt > PENDING_UPGRADE_STALE_MS) {
1071
1109
  try {
1072
1110
  fs.unlinkSync(pendingUpgradeFile());
1073
1111
  }
1074
1112
  catch { /* ignore */ }
1075
1113
  await reportMachineAction(marker.actionId, 'failed', {
1076
- error: `The updater was triggered but the CLI still reports ${current || 'unknown'} after ${Math.round(PENDING_UPGRADE_STALE_MS / 60_000)} minutes (target ${marker.target}). Check %ProgramData%\\FullCourtDefense\\updater.log on the machine.`,
1114
+ error: runFailed
1115
+ ? `The updater could not install ${marker.target}: ${lastRun?.error || 'unknown error'} The machine stays on ${current || 'unknown'}; see %ProgramData%\\FullCourtDefense\\updater.log.`
1116
+ : `The updater was triggered but the CLI still reports ${current || 'unknown'} after ${Math.round(PENDING_UPGRADE_STALE_MS / 60_000)} minutes (target ${marker.target}). Check %ProgramData%\\FullCourtDefense\\updater.log on the machine.`,
1077
1117
  }, marker.signatureProof);
1078
1118
  log(`Upgrade verification failed: still on ${current || 'unknown'}, target was ${marker.target} (action ${marker.actionId}).`);
1079
1119
  await uploadLogTail();
@@ -1,5 +1,5 @@
1
1
  export type DeterministicDirection = 'request' | 'response';
2
- export type DeterministicCategory = 'sensitive_file' | 'metadata_ssrf' | 'credential_command' | 'destructive_command' | 'destructive_sql' | 'infra_destroy' | 'reverse_shell' | 'secret_exfiltration' | 'honeypot' | 'agent_guard_tamper';
2
+ export type DeterministicCategory = 'sensitive_file' | 'metadata_ssrf' | 'credential_command' | 'destructive_command' | 'destructive_sql' | 'infra_destroy' | 'reverse_shell' | 'secret_exfiltration' | 'remote_control' | 'honeypot' | 'agent_guard_tamper';
3
3
  /**
4
4
  * Per-rule enforcement action (org-configured, shipped in the Local Safety
5
5
  * snapshot). Absent everywhere = 'block' — the only behavior older CLIs and
@@ -34,6 +34,8 @@ export interface DeterministicFinding {
34
34
  * findings — the seam 'mask' uses to redact tool responses. Never spooled.
35
35
  */
36
36
  matchedSecret?: string;
37
+ /** Catalog facts for remote_control findings (bridge id, mode, launcher) — no argument text. */
38
+ remoteControl?: RemoteControlFacts;
37
39
  }
38
40
  export interface LocalSafetyCustomBlock {
39
41
  id: string;
@@ -90,7 +92,7 @@ export declare const DEFAULT_DISABLED_BUILT_INS: ReadonlySet<string>;
90
92
  export interface SharedCommandRule {
91
93
  itemId: string;
92
94
  /** Catalog category id (local-safety-catalog.ts). */
93
- categoryId: 'credential_commands';
95
+ categoryId: 'credential_commands' | 'remote_control';
94
96
  severity: 'critical' | 'high' | 'medium' | 'low';
95
97
  pattern: string;
96
98
  reason: string;
@@ -106,6 +108,64 @@ export interface SharedCommandRule {
106
108
  * see DEFAULT_DISABLED_BUILT_INS). Ids match local-safety-catalog.ts.
107
109
  */
108
110
  export declare const CREDENTIAL_COMMAND_RULES: readonly SharedCommandRule[];
111
+ export declare const REMOTE_CONTROL_CATALOG_VERSION = "2026-09-26.1";
112
+ export interface RemoteControlFacts {
113
+ /** Catalog entry id. */
114
+ bridge: string;
115
+ /** Catalog mode label: 'remote' | 'serve' | 'connect'. */
116
+ mode: string;
117
+ /** How it was started: 'npx', 'npm exec', 'pnpm dlx', …, or 'direct' for an installed binary. */
118
+ launcher: string;
119
+ }
120
+ export interface RemoteControlBridge {
121
+ id: string;
122
+ title: string;
123
+ kind: 'remote_control' | 'remote_connector';
124
+ /** Registry package names (npm / PyPI), as typed after a launcher. */
125
+ packages: string[];
126
+ /** Executable names when installed globally or run from node_modules/.bin. */
127
+ bins: string[];
128
+ mode: 'remote' | 'serve' | 'connect';
129
+ /** Regex source applied right after the package token (JS + .NET, IgnoreCase). */
130
+ modePattern: string;
131
+ /** Short label for the console and evidence. */
132
+ label: string;
133
+ /** Decision Card sentence — what starting it means, in plain words. */
134
+ card: string;
135
+ }
136
+ export declare const REMOTE_CONTROL_BRIDGES: readonly RemoteControlBridge[];
137
+ /** The positional pattern for one catalog entry: program at a statement start, package, then its mode. */
138
+ export declare function remoteControlBridgePattern(bridge: RemoteControlBridge): string;
139
+ /** Shared command rules for the terminal guards: one per remote-control entry, same Local Safety item. */
140
+ export declare const REMOTE_CONTROL_COMMAND_RULES: readonly SharedCommandRule[];
141
+ export interface RemoteControlMatch {
142
+ bridge: RemoteControlBridge;
143
+ facts: RemoteControlFacts;
144
+ }
145
+ /** A shell line that starts a remote-control bridge (any statement, any de-obfuscated variant). */
146
+ export declare function remoteControlBridgeInCommand(command: string): RemoteControlMatch | undefined;
147
+ /**
148
+ * An MCP server launch (config `command` + `args`) that is a catalogued bridge:
149
+ * a remote-control bridge first, else a remote connector. Arguments with
150
+ * spaces are re-quoted so a `--stdio "npx server"` value stays one argument.
151
+ */
152
+ export declare function classifyRemoteMcpLaunch(command: string | undefined, args?: readonly string[]): RemoteControlMatch | undefined;
153
+ /** Wire-supplied facts constrained to the catalog vocabulary (backend ingest); anything else is dropped. */
154
+ export declare function sanitizeRemoteControlFacts(raw: unknown): RemoteControlFacts | undefined;
155
+ /** The engine fact provider: the catalog id of the remote-control bridge this line starts. */
156
+ export declare function remoteControlFactFor(command: string): string | undefined;
157
+ /** What an "Always allow" on a bridge is bound to: the package and its mode, never the launcher that ran it. */
158
+ export declare function remoteControlGrantKey(bridge: RemoteControlBridge): string;
159
+ /**
160
+ * Every distinct remote-control bridge a line starts (as grant keys), plus the
161
+ * line with each verbatim launch cut out — statement separators kept — so the
162
+ * caller can still read the OTHER programs on the line. A launch seen only in a
163
+ * de-obfuscated variant is not cut: the rest is over-counted, never under-counted.
164
+ */
165
+ export declare function remoteControlLaunches(command: string): {
166
+ bridges: string[];
167
+ rest: string;
168
+ };
109
169
  export type ShellAstMode = 'off' | 'shadow' | 'primary';
110
170
  /** Structural subset of shellAst.ts ShellFacts that the guard reads. */
111
171
  export interface ShellAstFactsLike {
@@ -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;
@@ -446,7 +453,7 @@ const DEFAULT_WARN_BUILT_INS = new Set(['env_workspace']);
446
453
  * snapshot built before the item existed (older backend) must not turn a new
447
454
  * ask-first rule into a hard block. An explicit org 'block' still wins.
448
455
  */
449
- const DEFAULT_ASK_BUILT_INS = new Set(['agent_hooks_switched_off', 'agent_hook_config_removed', 'fcd_uninstalled_by_agent']);
456
+ const DEFAULT_ASK_BUILT_INS = new Set(['agent_hooks_switched_off', 'agent_hook_config_removed', 'fcd_uninstalled_by_agent', 'remote_control_bridge']);
450
457
  function actionFor(itemId, options) {
451
458
  const action = toLocalSafetyRuleAction(options?.itemActions?.[itemId]);
452
459
  if (action)
@@ -726,6 +733,181 @@ function credentialCommandReason(value, options) {
726
733
  return undefined;
727
734
  });
728
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
+ }
729
911
  // Container-scoped execution context immediately before an `rm` hit: a
730
912
  // docker/podman/nerdctl/kubectl exec|run (incl. `compose exec/run`) earlier in
731
913
  // the SAME statement (no ; & | between). Inside that context a bare `rm -rf *`
@@ -1173,12 +1355,29 @@ const METADATA_SEARCH_PROGRAMS = new Set([
1173
1355
  * Split on unquoted `;` / `&&` / `||` / `|` / newline so a pipe cannot hide a
1174
1356
  * later client (`rg … | curl http://169.254…`) while `rg "a|b"` stays one segment.
1175
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
+ }
1176
1371
  function splitUnquotedCommandSegments(command) {
1177
1372
  const out = [];
1178
1373
  let cur = '';
1179
1374
  let quote = null;
1180
1375
  for (let i = 0; i < command.length; i++) {
1181
1376
  const c = command[i];
1377
+ if (isPowerShellBacktickEscape(command, i, quote)) {
1378
+ cur += c + command[++i];
1379
+ continue;
1380
+ }
1182
1381
  if (quote) {
1183
1382
  cur += c;
1184
1383
  if (c === '\\' && i + 1 < command.length) {
@@ -1344,11 +1543,15 @@ function unquotedCode(text) {
1344
1543
  let out = '';
1345
1544
  for (let i = 0; i < text.length; i++) {
1346
1545
  const c = text[i];
1546
+ if (isPowerShellBacktickEscape(text, i, null)) {
1547
+ out += c + text[++i];
1548
+ continue;
1549
+ }
1347
1550
  if (c === "'" || c === '"' || c === '`') {
1348
1551
  const q = c;
1349
1552
  i += 1;
1350
1553
  while (i < text.length) {
1351
- if (text[i] === '\\') {
1554
+ if (text[i] === '\\' || (q === '"' && text[i] === '`')) {
1352
1555
  i += 2;
1353
1556
  continue;
1354
1557
  }
@@ -1376,7 +1579,7 @@ function stripInertDataSegments(text) {
1376
1579
  // Generic quoted-span pass: whitespace inside quotes ⇒ free text ⇒ inert,
1377
1580
  // unless the span sits in a code position (invariants 2–4 above).
1378
1581
  const src = out;
1379
- out = src.replace(/"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'/g, (whole, dq, sq, offset) => {
1582
+ out = src.replace(/"((?:[^"\\`]|\\.|`[\s\S])*)"|'((?:[^'\\]|\\.)*)'/g, (whole, dq, sq, offset) => {
1380
1583
  const body = dq !== undefined ? dq : (sq || '');
1381
1584
  if (!/\s/.test(body))
1382
1585
  return whole; // single token: argv, not prose
@@ -1671,6 +1874,13 @@ function scanTextValue(toolName, rawValue, options, commandContext = false) {
1671
1874
  if (finding)
1672
1875
  return finding;
1673
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
+ }
1674
1884
  const destructive = destructiveCommandDecision(value);
1675
1885
  if (destructive) {
1676
1886
  return builtIn(destructive.itemId, 'destructive_filesystem', 'destructive_command', 'local-destructive-command', `Blocked ${destructive.reason}.`, value, destructive.reason, options);
@@ -1716,7 +1926,7 @@ function mentionsAgentHookConfig(text) {
1716
1926
  return AGENT_HOOK_CONFIG_RE.test(normalizeForPath(text));
1717
1927
  }
1718
1928
  function segmentTokens(segment) {
1719
- return (segment.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []).map(token => token.replace(/^["']|["']$/g, ''));
1929
+ return (segment.match(/(?:[^\s"']+|"(?:[^"`]|`[\s\S])*"|'[^']*')+/g) || []).map(token => token.replace(/^["']|["']$/g, ''));
1720
1930
  }
1721
1931
  function segmentProgram(tokens) {
1722
1932
  let i = 0;
@@ -1732,7 +1942,7 @@ function redirectTargets(segment) {
1732
1942
  out.push(match[2]);
1733
1943
  return out;
1734
1944
  }
1735
- function isFcdUninstall(program, rest, segment) {
1945
+ function isFcdUninstall(program, rest) {
1736
1946
  let prog = program;
1737
1947
  let args = rest;
1738
1948
  if (prog === 'npx' || prog === 'pnpx' || prog === 'bunx') {
@@ -1746,18 +1956,75 @@ function isFcdUninstall(program, rest, segment) {
1746
1956
  return args.some(token => /^(?:uninstall|remove|rm|r|un|unlink)$/i.test(token))
1747
1957
  && args.some(token => /^fullcourtdefense-cli(?:@|$)/i.test(token));
1748
1958
  }
1749
- if (!/fullcourtdefense/i.test(segment))
1750
- return false;
1751
- if (prog === 'msiexec')
1752
- return args.some(token => /^[/-](?:x|uninstall)$/i.test(token));
1753
- if (prog === 'schtasks')
1754
- return args.some(token => /^[/-](?:delete|disable)$/i.test(token));
1755
- if (prog === 'unregister-scheduledtask' || prog === 'disable-scheduledtask')
1756
- return true;
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
+ }
1757
1972
  if (prog === 'winget')
1758
- return /^(?:uninstall|remove)$/i.test(args[0] || '');
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
+ }
1759
1984
  return false;
1760
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
+ }
1761
2028
  /**
1762
2029
  * Shell view: per unquoted segment, the leading program decides. Quoted prose
1763
2030
  * (commit messages, issue bodies, echo text) is removed before redirections
@@ -1769,7 +2036,7 @@ function hookConfigShellChange(command) {
1769
2036
  if (!raw)
1770
2037
  continue;
1771
2038
  const { program, rest } = segmentProgram(segmentTokens(raw));
1772
- if (isFcdUninstall(program, rest, raw)) {
2039
+ if (isFcdUninstall(program, rest)) {
1773
2040
  return { itemId: 'fcd_uninstalled_by_agent', reason: 'agent removing FullCourtDefense from this machine' };
1774
2041
  }
1775
2042
  const stripped = stripInertDataSegments(raw);