fullcourtdefense-cli 1.35.9 → 1.35.10

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 (49) hide show
  1. package/dist/askDialog.js +1 -2
  2. package/dist/callerEnv.d.ts +10 -0
  3. package/dist/callerEnv.js +18 -1
  4. package/dist/commandNarrative.d.ts +8 -0
  5. package/dist/commandNarrative.js +69 -10
  6. package/dist/commands/agentCi.js +3 -1
  7. package/dist/commands/daemon.js +6 -1
  8. package/dist/commands/deterministicGuard.d.ts +1 -1
  9. package/dist/commands/deterministicGuard.js +313 -27
  10. package/dist/commands/discover.js +23 -0
  11. package/dist/commands/discoverAgentFiles.d.ts +11 -0
  12. package/dist/commands/discoverAgentFiles.js +107 -0
  13. package/dist/commands/discoverExtensions.d.ts +64 -0
  14. package/dist/commands/discoverExtensions.js +386 -0
  15. package/dist/commands/hook.d.ts +28 -4
  16. package/dist/commands/hook.js +61 -16
  17. package/dist/commands/local-scan.js +1 -0
  18. package/dist/commands/mcpGateway.js +273 -54
  19. package/dist/commands/protectedRun.d.ts +1 -1
  20. package/dist/commands/protectedRun.js +3 -3
  21. package/dist/commands/watchdog.js +2 -0
  22. package/dist/credentialGrant.d.ts +43 -0
  23. package/dist/credentialGrant.js +504 -0
  24. package/dist/daemonForensics.d.ts +27 -0
  25. package/dist/daemonForensics.js +47 -0
  26. package/dist/detectorBaseline.d.ts +2 -2
  27. package/dist/detectorBaseline.js +2 -2
  28. package/dist/devConfirm.d.ts +48 -1
  29. package/dist/devConfirm.js +233 -111
  30. package/dist/envDiagnostics.js +4 -2
  31. package/dist/hookSlim.js +4 -3
  32. package/dist/ideExtensionRisk.d.ts +110 -0
  33. package/dist/ideExtensionRisk.js +172 -0
  34. package/dist/machineActionVerify.d.ts +34 -8
  35. package/dist/machineActionVerify.js +76 -16
  36. package/dist/mcpProtocol.d.ts +124 -0
  37. package/dist/mcpProtocol.js +302 -0
  38. package/dist/mcpToolReview.d.ts +2 -1
  39. package/dist/mcpToolReview.js +6 -4
  40. package/dist/notify.d.ts +7 -0
  41. package/dist/notify.js +61 -0
  42. package/dist/shellQuoteMask.d.ts +25 -0
  43. package/dist/shellQuoteMask.js +319 -0
  44. package/dist/telemetry.d.ts +2 -0
  45. package/dist/verdictIpc.js +16 -0
  46. package/dist/verdictIpcClient.d.ts +32 -12
  47. package/dist/verdictIpcClient.js +55 -26
  48. package/dist/version.json +1 -1
  49. package/package.json +7 -2
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(', ')}.`,
@@ -526,6 +526,9 @@ async function runDaemon(args, config) {
526
526
  // of an OLDER version is the upgrade itself, not something to alarm on.
527
527
  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
528
  }
529
+ else if (postmortem.cause === 'machine_restart') {
530
+ 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
+ }
529
532
  else {
530
533
  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
534
  }
@@ -1097,7 +1100,7 @@ async function runDaemon(args, config) {
1097
1100
  // whose enrolled fingerprint drifted can set FCD_MACHINE_ACTION_ANY_MACHINE_OK=1
1098
1101
  // locally while they re-enroll.
1099
1102
  const localIdentity = (0, machineIdentity_1.getMachineIdentity)();
1100
- const verdict = (0, machineActionVerify_1.verifyMachineAction)(action, { localMachineId: localIdentity.machineId });
1103
+ const verdict = (0, machineActionVerify_1.verifyMachineAction)(action, { localMachineId: localIdentity.machineId, apiUrl: creds.apiUrl });
1101
1104
  if (!verdict.ok) {
1102
1105
  log(`Remote action REJECTED: ${action.type} (${action.id}) — ${verdict.reason}`);
1103
1106
  await reportMachineAction(action.id, 'failed', { error: `Action verification failed: ${verdict.reason}` }, action.signature?.signature);
@@ -1505,6 +1508,8 @@ async function runDaemon(args, config) {
1505
1508
  lastAliveAt: unreportedCrash.lastAliveAt,
1506
1509
  detectedAt: unreportedCrash.detectedAt,
1507
1510
  detectedBy: unreportedCrash.detectedBy,
1511
+ cause: unreportedCrash.cause,
1512
+ machineBootedAt: unreportedCrash.machineBootedAt,
1508
1513
  }
1509
1514
  : undefined,
1510
1515
  });
@@ -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' | '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