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
@@ -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,
package/dist/askDialog.js CHANGED
@@ -141,8 +141,7 @@ async function resolveAskWithDialog(verdict, options = {}) {
141
141
  return { ...clean, resolution: 'ide' };
142
142
  const grantHash = envelope.ruleHash || envelope.policyHash;
143
143
  const grantLimits = envelope.grantLimits === null ? undefined : (envelope.grantLimits || { ttlMs: devConfirm_1.GRANT_TTL_MS });
144
- const allowAlways = envelope.remember !== 'none' && Boolean(grantHash) && Boolean(grantLimits)
145
- && (envelope.scope.event !== 'shell' || (envelope.scope.programs || []).length > 0);
144
+ const allowAlways = envelope.remember !== 'none' && Boolean(grantHash) && Boolean(grantLimits) && (0, devConfirm_1.grantOffered)(envelope.scope);
146
145
  const scopeLabel = allowAlways ? (0, devConfirm_1.describeGrantScope)(envelope.scope, { policyId: envelope.policyId, policyName: envelope.policyName }) : undefined;
147
146
  const limitsLabel = allowAlways && grantLimits ? (0, devConfirm_1.describeGrantLimits)(grantLimits) : undefined;
148
147
  const dialog = options.dialog || devConfirm_1.confirmNativeActionDetailed;
@@ -23,6 +23,16 @@ export declare function callerEnv(): NodeJS.ProcessEnv;
23
23
  * from the daemon. No snapshot (older client) = this process's env, as before.
24
24
  */
25
25
  export declare function withCallerEnv<T>(snapshot: unknown, fn: () => T): T;
26
+ /**
27
+ * How long the IDE lets an approval-capable hook run before it kills the hook
28
+ * (and runs the action): Claude Code / Copilot / VS Code and Cursor: 90s.
29
+ * FCD_HOOK_IDE_TIMEOUT_MS narrows it for drills.
30
+ */
31
+ export declare const IDE_HOOK_TIMEOUT_MS = 90000;
32
+ export declare const IDE_TIMEOUT_MARGIN_MS = 15000;
33
+ export declare function ideHookTimeoutMs(env?: NodeJS.ProcessEnv): number;
34
+ /** Absolute time every wait inside a hook must end by, leaving the margin before the IDE's timeout. */
35
+ export declare function ideWaitCutoff(startedAt: number, ideTimeoutMs?: number): number;
26
36
  /** CI / pipeline environments — no human at a keyboard. Shared by hooks and discovery. */
27
37
  export declare function isCiEnvironment(env?: NodeJS.ProcessEnv): boolean;
28
38
  /**
package/dist/callerEnv.js CHANGED
@@ -1,9 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.CALLER_ENV_KEYS = void 0;
3
+ exports.IDE_TIMEOUT_MARGIN_MS = exports.IDE_HOOK_TIMEOUT_MS = exports.CALLER_ENV_KEYS = void 0;
4
4
  exports.snapshotCallerEnv = snapshotCallerEnv;
5
5
  exports.callerEnv = callerEnv;
6
6
  exports.withCallerEnv = withCallerEnv;
7
+ exports.ideHookTimeoutMs = ideHookTimeoutMs;
8
+ exports.ideWaitCutoff = ideWaitCutoff;
7
9
  exports.isCiEnvironment = isCiEnvironment;
8
10
  exports.isHumanPresent = isHumanPresent;
9
11
  const async_hooks_1 = require("async_hooks");
@@ -66,6 +68,21 @@ function withCallerEnv(snapshot, fn) {
66
68
  }
67
69
  return requestEnv.run(env, fn);
68
70
  }
71
+ /**
72
+ * How long the IDE lets an approval-capable hook run before it kills the hook
73
+ * (and runs the action): Claude Code / Copilot / VS Code and Cursor: 90s.
74
+ * FCD_HOOK_IDE_TIMEOUT_MS narrows it for drills.
75
+ */
76
+ exports.IDE_HOOK_TIMEOUT_MS = 90_000;
77
+ exports.IDE_TIMEOUT_MARGIN_MS = 15_000;
78
+ function ideHookTimeoutMs(env = callerEnv()) {
79
+ const override = Number(env.FCD_HOOK_IDE_TIMEOUT_MS);
80
+ return override > 0 ? override : exports.IDE_HOOK_TIMEOUT_MS;
81
+ }
82
+ /** Absolute time every wait inside a hook must end by, leaving the margin before the IDE's timeout. */
83
+ function ideWaitCutoff(startedAt, ideTimeoutMs = ideHookTimeoutMs()) {
84
+ return startedAt + ideTimeoutMs - Math.min(exports.IDE_TIMEOUT_MARGIN_MS, ideTimeoutMs / 4);
85
+ }
69
86
  /** CI / pipeline environments — no human at a keyboard. Shared by hooks and discovery. */
70
87
  function isCiEnvironment(env = callerEnv()) {
71
88
  return env.GITHUB_ACTIONS === 'true'
@@ -20,6 +20,14 @@ export interface CommandStep {
20
20
  text: string;
21
21
  /** The grammar recognised the program and its verb. */
22
22
  known: boolean;
23
+ /** A credential stored in a variable by this step (`$tok = gcloud auth print-access-token`). */
24
+ captures?: {
25
+ variable: string;
26
+ issuer?: {
27
+ name: string;
28
+ domains: string[];
29
+ };
30
+ };
23
31
  }
24
32
  /** Quote-aware split on `;`, `&&`, `||`, newlines. Pipes stay inside a step. */
25
33
  export declare function splitCommandSteps(command: string): string[];
@@ -20,6 +20,12 @@ exports.describeShellCommand = describeShellCommand;
20
20
  exports.describeShellCommandLines = describeShellCommandLines;
21
21
  const MAX_STEPS = 14;
22
22
  const MAX_LINE = 220;
23
+ /** Token printers whose output can be assigned; `issuer` = who accepts the token (identity tokens go to their audience). */
24
+ const TOKEN_PRINTERS = [
25
+ { re: /^gcloud(?:\.cmd|\.exe)?\s+(?:[\w-]+\s+)*?auth\s+(?:application-default\s+)?print-access-token\b/i, issuer: { name: 'Google', domains: ['googleapis.com'] } },
26
+ { re: /^gcloud(?:\.cmd|\.exe)?\s+(?:[\w-]+\s+)*?auth\s+print-identity-token\b/i },
27
+ { re: /^gh(?:\.exe)?\s+auth\s+token\b/i, issuer: { name: 'GitHub', domains: ['github.com', 'githubusercontent.com'] } },
28
+ ];
23
29
  /** Quote-aware split on `;`, `&&`, `||`, newlines. Pipes stay inside a step. */
24
30
  function splitCommandSteps(command) {
25
31
  const steps = [];
@@ -158,7 +164,11 @@ function describeSecretVersions(toks, verbAt, where) {
158
164
  return `${state[verb]} ${version}${secret(flagSecret)} in Secret Manager${where}`;
159
165
  return `Runs \`gcloud secrets versions ${verb}\`${where}`;
160
166
  }
161
- function describeGcloud(toks) {
167
+ /**
168
+ * `captured` = the variable the command's output is assigned to (`$tok = gcloud …`,
169
+ * `TOKEN=$(gcloud …)`): the output never reaches the terminal.
170
+ */
171
+ function describeGcloud(toks, captured) {
162
172
  let i = 1;
163
173
  while (i < toks.length && toks[i].startsWith('-'))
164
174
  i++;
@@ -210,8 +220,10 @@ function describeGcloud(toks) {
210
220
  return `Reads the contents of ${isStorageUri(first) ? `an object in storage bucket ${bucketOf(first)}` : shortValue(first, 60)}`;
211
221
  if (verb === 'rm')
212
222
  return `Deletes ${named(first)}`;
213
- if (/^print-(?:access|identity)-token$/.test(verb))
214
- return 'Prints a cloud access token to the terminal';
223
+ if (/^print-(?:access|identity)-token$/.test(verb)) {
224
+ const kind = verb === 'print-identity-token' ? 'identity' : 'access';
225
+ return captured ? `Stores a cloud ${kind} token in variable ${captured}` : `Prints a cloud ${kind} token to the terminal`;
226
+ }
215
227
  if (verb === 'login')
216
228
  return 'Signs in to Google Cloud';
217
229
  if (verb === 'add-iam-policy-binding' || verb === 'remove-iam-policy-binding') {
@@ -334,7 +346,7 @@ function peelGroups(text) {
334
346
  }
335
347
  return value;
336
348
  }
337
- function describeOne(segmentIn) {
349
+ function describeOne(segmentIn, captured) {
338
350
  const segment = segmentIn.trim();
339
351
  const [head, ...pipe] = splitPipe(segment);
340
352
  const tail = describePipeTail(pipe);
@@ -370,8 +382,13 @@ function describeOne(segmentIn) {
370
382
  }
371
383
  return step(`Sets variable ${name}${interpolates ? ' from other values' : ` to ${shortValue(bare, 60)}`}`);
372
384
  }
373
- const inner = rhs.startsWith('(') ? peelGroups(rhs) : rhs.replace(/\)\s*(?:\.\w+\(\))*$/, '');
374
- const innerStep = describeOne(inner);
385
+ const substituted = /^\$\((.*)\)$/s.exec(rhs) || /^`(.*)`$/s.exec(rhs);
386
+ const inner = substituted ? substituted[1].trim() : rhs.startsWith('(') ? peelGroups(rhs) : rhs.replace(/\)\s*(?:\.\w+\(\))*$/, '');
387
+ const innerStep = describeOne(inner, name);
388
+ const printer = TOKEN_PRINTERS.find(p => p.re.test(inner));
389
+ if (printer) {
390
+ return { segment, text: shortValue(`${innerStep.text}${tail}`, MAX_LINE), known: innerStep.known, captures: { variable: name, ...(printer.issuer ? { issuer: printer.issuer } : {}) } };
391
+ }
375
392
  return { segment, text: shortValue(`Stores in ${name}: ${innerStep.text.charAt(0).toLowerCase()}${innerStep.text.slice(1)}${tail}`, MAX_LINE), known: innerStep.known };
376
393
  }
377
394
  // A bare string statement: PowerShell writes it to the output (`"len=$($t.Length)"`).
@@ -412,7 +429,7 @@ function describeOne(segmentIn) {
412
429
  case 'exit':
413
430
  case 'return': return step('Ends the script');
414
431
  case 'gcloud': {
415
- const text = describeGcloud(rest);
432
+ const text = describeGcloud(rest, captured);
416
433
  return text ? step(`${text}${redirectNote}`) : step(`Runs \`gcloud\``, false);
417
434
  }
418
435
  case 'gsutil': {
@@ -475,7 +492,7 @@ function describeOne(segmentIn) {
475
492
  if (/\b(?:create|edit|merge|close|delete|comment)\b/.test(sub))
476
493
  return step(`Changes GitHub ${sub.split(' ')[0]} (${sub.split(' ')[1]})`);
477
494
  if (/^auth token\b/.test(sub))
478
- return step('Prints the GitHub token');
495
+ return step(captured ? `Stores the GitHub token in variable ${captured}` : 'Prints the GitHub token');
479
496
  return step(`Runs \`gh ${sub}\``, false);
480
497
  }
481
498
  case 'npm':
@@ -691,17 +708,59 @@ function resolveStorageVariables(steps) {
691
708
  return { ...step, text };
692
709
  });
693
710
  }
711
+ /**
712
+ * A token stored in a variable, then used later on the line: name the host(s)
713
+ * that receive it and whether each is the token's issuer. Hosts are literal
714
+ * URLs in the using step, or a variable assigned a literal URL earlier.
715
+ */
716
+ function noteTokenDestinations(steps) {
717
+ const urlVars = new Map();
718
+ for (const step of steps) {
719
+ const assign = /^\$?([\w:]+)\s*=\s*["']?https?:\/\/([^/\s"'`$]+)/i.exec(step.segment);
720
+ if (assign)
721
+ urlVars.set(assign[1].toLowerCase(), assign[2].toLowerCase());
722
+ }
723
+ return steps.map((step, index) => {
724
+ if (!step.captures)
725
+ return step;
726
+ const { variable, issuer } = step.captures;
727
+ const ref = new RegExp(`\\$\\{?${variable.replace(/[^\w:]/g, '')}\\}?(?![\\w])`, 'i');
728
+ const hosts = new Set();
729
+ for (const later of steps.slice(index + 1)) {
730
+ if (!ref.test(later.segment))
731
+ continue;
732
+ for (const m of later.segment.matchAll(/https?:\/\/([^/\s"'`<>]+)/gi))
733
+ if (!/[$({]/.test(m[1]))
734
+ hosts.add(m[1].toLowerCase().replace(/:\d+$/, ''));
735
+ for (const m of later.segment.matchAll(/\$\{?([\w:]+)\}?/g)) {
736
+ const host = urlVars.get(m[1].toLowerCase());
737
+ if (host)
738
+ hosts.add(host);
739
+ }
740
+ }
741
+ if (!hosts.size)
742
+ return step;
743
+ const described = [...hosts].sort().map(host => {
744
+ if (!issuer)
745
+ return host;
746
+ const own = issuer.domains.some(d => host === d || host.endsWith(`.${d}`));
747
+ return own ? `${host} (${issuer.name}, the token's issuer)` : `${host}, which is not ${issuer.name}`;
748
+ });
749
+ return { ...step, text: shortValue(`${step.text}, then sends it to ${described.join('; ')}`, MAX_LINE) };
750
+ });
751
+ }
694
752
  /** One plain-words line per step, in order. */
695
753
  function describeShellCommand(command) {
696
754
  const steps = splitCommandSteps(command);
697
- const out = resolveStorageVariables(steps.slice(0, MAX_STEPS).map(segment => {
755
+ const described = steps.map(segment => {
698
756
  try {
699
757
  return describeOne(segment);
700
758
  }
701
759
  catch {
702
760
  return { segment, text: 'Runs a command the grammar does not recognise', known: false };
703
761
  }
704
- }));
762
+ });
763
+ const out = resolveStorageVariables(noteTokenDestinations(described).slice(0, MAX_STEPS));
705
764
  if (steps.length > MAX_STEPS)
706
765
  out.push({ segment: '', text: `… and ${steps.length - MAX_STEPS} more step${steps.length - MAX_STEPS === 1 ? '' : 's'}`, known: false });
707
766
  return out;
@@ -216,6 +216,8 @@ function agentFileRemediation(tags) {
216
216
  fixes.push('remove inline secret/password/token references and use a managed secret store');
217
217
  if (tags.includes('jailbreak_hint'))
218
218
  fixes.push('remove jailbreak/bypass instructions and add hierarchy language that system and security policies win');
219
+ if (tags.includes('hooks_disabled'))
220
+ fixes.push('remove "disableAllHooks": true (or a managed "allowManagedHooksOnly": true) so Claude Code runs the Full Court Defense hook again');
219
221
  if (tags.includes('no_runtime_hook'))
220
222
  fixes.push('install the Full Court Defense hook/gateway so risky tool calls are checked before execution');
221
223
  if (fixes.length === 0)
@@ -266,7 +268,7 @@ function evaluateAgentGate(args) {
266
268
  }
267
269
  for (const file of agentFiles.filter(item => item.riskTags.length > 0)) {
268
270
  findings.push(finding({
269
- severity: file.riskTags.includes('allows_shell') || file.riskTags.includes('broad_filesystem') ? 'high' : 'medium',
271
+ severity: file.riskTags.includes('allows_shell') || file.riskTags.includes('broad_filesystem') || file.riskTags.includes('hooks_disabled') ? 'high' : 'medium',
270
272
  category: 'agent-file',
271
273
  title: `Risky agent instruction: ${file.title}`,
272
274
  detail: `${file.filePath} contains agent risk tags: ${file.riskTags.join(', ')}.`,
@@ -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) {
@@ -526,6 +549,16 @@ async function runDaemon(args, config) {
526
549
  // of an OLDER version is the upgrade itself, not something to alarm on.
527
550
  log(`Previous daemon (v${postmortem.version}) was replaced by this upgrade to v${current} — the updater's forced stop is expected, not a crash. Post-mortem recorded for the timeline.`);
528
551
  }
552
+ else if (postmortem.cause === 'machine_restart') {
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.`);
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
+ }
529
562
  else {
530
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.`);
531
564
  }
@@ -1064,13 +1097,23 @@ async function runDaemon(args, config) {
1064
1097
  return;
1065
1098
  }
1066
1099
  const startedAt = marker.startedAt ? Date.parse(marker.startedAt) : NaN;
1067
- 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) {
1068
1109
  try {
1069
1110
  fs.unlinkSync(pendingUpgradeFile());
1070
1111
  }
1071
1112
  catch { /* ignore */ }
1072
1113
  await reportMachineAction(marker.actionId, 'failed', {
1073
- 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.`,
1074
1117
  }, marker.signatureProof);
1075
1118
  log(`Upgrade verification failed: still on ${current || 'unknown'}, target was ${marker.target} (action ${marker.actionId}).`);
1076
1119
  await uploadLogTail();
@@ -1097,7 +1140,7 @@ async function runDaemon(args, config) {
1097
1140
  // whose enrolled fingerprint drifted can set FCD_MACHINE_ACTION_ANY_MACHINE_OK=1
1098
1141
  // locally while they re-enroll.
1099
1142
  const localIdentity = (0, machineIdentity_1.getMachineIdentity)();
1100
- const verdict = (0, machineActionVerify_1.verifyMachineAction)(action, { localMachineId: localIdentity.machineId });
1143
+ const verdict = (0, machineActionVerify_1.verifyMachineAction)(action, { localMachineId: localIdentity.machineId, apiUrl: creds.apiUrl });
1101
1144
  if (!verdict.ok) {
1102
1145
  log(`Remote action REJECTED: ${action.type} (${action.id}) — ${verdict.reason}`);
1103
1146
  await reportMachineAction(action.id, 'failed', { error: `Action verification failed: ${verdict.reason}` }, action.signature?.signature);
@@ -1505,6 +1548,8 @@ async function runDaemon(args, config) {
1505
1548
  lastAliveAt: unreportedCrash.lastAliveAt,
1506
1549
  detectedAt: unreportedCrash.detectedAt,
1507
1550
  detectedBy: unreportedCrash.detectedBy,
1551
+ cause: unreportedCrash.cause,
1552
+ machineBootedAt: unreportedCrash.machineBootedAt,
1508
1553
  }
1509
1554
  : undefined,
1510
1555
  });
@@ -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';
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 {