fullcourtdefense-cli 1.36.4 → 1.38.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 (107) hide show
  1. package/dist/askDialog.d.ts +12 -0
  2. package/dist/askDialog.js +46 -3
  3. package/dist/awakeClock.d.ts +34 -0
  4. package/dist/awakeClock.js +81 -0
  5. package/dist/backupManifest.d.ts +14 -1
  6. package/dist/backupManifest.js +180 -19
  7. package/dist/callerEnv.d.ts +49 -2
  8. package/dist/callerEnv.js +88 -2
  9. package/dist/commands/cmdGuard.d.ts +1 -0
  10. package/dist/commands/cmdGuard.js +14 -1
  11. package/dist/commands/daemon.d.ts +30 -0
  12. package/dist/commands/daemon.js +656 -196
  13. package/dist/commands/desktopChatGuard.d.ts +20 -0
  14. package/dist/commands/desktopChatGuard.js +62 -11
  15. package/dist/commands/discoverPaths.d.ts +12 -9
  16. package/dist/commands/discoverPaths.js +30 -0
  17. package/dist/commands/doctor.js +5 -1
  18. package/dist/commands/ephemeralStack.js +7 -0
  19. package/dist/commands/flushSpool.d.ts +2 -0
  20. package/dist/commands/flushSpool.js +9 -2
  21. package/dist/commands/hook.d.ts +23 -0
  22. package/dist/commands/hook.js +376 -111
  23. package/dist/commands/httpGatewayCommand.d.ts +1 -0
  24. package/dist/commands/httpGatewayCommand.js +7 -1
  25. package/dist/commands/installClaudeHook.js +32 -10
  26. package/dist/commands/installCursorHook.js +20 -9
  27. package/dist/commands/login.js +8 -1
  28. package/dist/commands/mcpGateway.d.ts +21 -0
  29. package/dist/commands/mcpGateway.js +106 -42
  30. package/dist/commands/posixShellGuard.js +3 -2
  31. package/dist/commands/refreshPolicyCache.d.ts +28 -0
  32. package/dist/commands/refreshPolicyCache.js +59 -0
  33. package/dist/commands/shellGuard.js +25 -6
  34. package/dist/commands/uninstallAll.js +5 -0
  35. package/dist/commands/watchdog.d.ts +10 -0
  36. package/dist/commands/watchdog.js +49 -5
  37. package/dist/config.d.ts +36 -0
  38. package/dist/config.js +78 -12
  39. package/dist/daemonDetectorBoot.d.ts +1 -0
  40. package/dist/daemonDetectorBoot.js +12 -0
  41. package/dist/daemonForensics.d.ts +25 -1
  42. package/dist/daemonForensics.js +57 -1
  43. package/dist/detectorInProcess.d.ts +18 -0
  44. package/dist/detectorInProcess.js +234 -0
  45. package/dist/detectorInThread.d.ts +15 -0
  46. package/dist/detectorInThread.js +129 -0
  47. package/dist/devConfirm.d.ts +13 -0
  48. package/dist/devConfirm.js +3 -2
  49. package/dist/distress.d.ts +12 -0
  50. package/dist/distress.js +275 -32
  51. package/dist/fileWriteCanon.d.ts +17 -0
  52. package/dist/fileWriteCanon.js +141 -0
  53. package/dist/gateReports.d.ts +144 -0
  54. package/dist/gateReports.js +503 -0
  55. package/dist/hookIo.d.ts +77 -0
  56. package/dist/hookIo.js +410 -9
  57. package/dist/hookSlim.d.ts +15 -8
  58. package/dist/hookSlim.js +199 -28
  59. package/dist/housekeeping.d.ts +64 -0
  60. package/dist/housekeeping.js +385 -0
  61. package/dist/housekeepingOps.d.ts +150 -0
  62. package/dist/housekeepingOps.js +233 -0
  63. package/dist/housekeepingWorker.d.ts +1 -0
  64. package/dist/housekeepingWorker.js +113 -0
  65. package/dist/index.d.ts +1 -1
  66. package/dist/index.js +45 -0
  67. package/dist/localDetectionData.d.ts +10 -0
  68. package/dist/localDetectionData.js +48 -0
  69. package/dist/localDetectionUpdates.d.ts +2 -2
  70. package/dist/localDetectionUpdates.js +4 -4
  71. package/dist/localSafetySnapshot.d.ts +48 -2
  72. package/dist/localSafetySnapshot.js +241 -25
  73. package/dist/machineIdentity.d.ts +7 -0
  74. package/dist/machineIdentity.js +20 -2
  75. package/dist/monitorFastPath.d.ts +227 -0
  76. package/dist/monitorFastPath.js +1257 -0
  77. package/dist/nativeHook.d.ts +21 -0
  78. package/dist/nativeHook.js +93 -12
  79. package/dist/notify.js +51 -1
  80. package/dist/pendingDrain.d.ts +82 -0
  81. package/dist/pendingDrain.js +215 -0
  82. package/dist/pendingDrainer.d.ts +40 -0
  83. package/dist/pendingDrainer.js +267 -0
  84. package/dist/perfSnapshot.d.ts +41 -3
  85. package/dist/perfSnapshot.js +142 -32
  86. package/dist/policyCacheFiles.d.ts +83 -0
  87. package/dist/policyCacheFiles.js +395 -0
  88. package/dist/policyGateHealth.d.ts +8 -0
  89. package/dist/policyGateHealth.js +17 -0
  90. package/dist/policyRefresh.d.ts +28 -0
  91. package/dist/policyRefresh.js +69 -0
  92. package/dist/protectionMaintenance.d.ts +15 -0
  93. package/dist/protectionMaintenance.js +43 -1
  94. package/dist/runtimeConfig.d.ts +83 -20
  95. package/dist/runtimeConfig.js +292 -57
  96. package/dist/selfTest.d.ts +6 -1
  97. package/dist/selfTest.js +11 -4
  98. package/dist/telemetry.d.ts +201 -4
  99. package/dist/telemetry.js +616 -97
  100. package/dist/testRunGuard.d.ts +28 -0
  101. package/dist/testRunGuard.js +228 -0
  102. package/dist/verdictIpc.d.ts +60 -0
  103. package/dist/verdictIpc.js +655 -47
  104. package/dist/verdictIpcClient.d.ts +253 -19
  105. package/dist/verdictIpcClient.js +471 -65
  106. package/dist/version.json +1 -1
  107. package/package.json +5 -3
@@ -56,6 +56,17 @@ export interface AskEnvelope {
56
56
  reason: string;
57
57
  /** The client the runtime event records as `agentClient` (cursor, claude-code, …): the dialog names the same agent as the console. */
58
58
  agentClient?: string;
59
+ /**
60
+ * The asking call's correlation identity (session / run / tool call id): the
61
+ * dialog's outcome is recorded by this process, outside that call's
62
+ * evaluation, and must name the same action. Older hook builds omit it.
63
+ */
64
+ identity?: {
65
+ sessionId?: string;
66
+ runId?: string;
67
+ instanceId?: string;
68
+ attemptId?: string;
69
+ };
59
70
  }
60
71
  export type AskResolution = 'allow' | 'allow_always' | 'deny' | 'timeout' | 'ide';
61
72
  export interface HookVerdict {
@@ -96,6 +107,7 @@ export declare function resolveAskWithDialog(verdict: HookVerdict, options?: {
96
107
  clientLabel?: string;
97
108
  timeoutMs?: number;
98
109
  dialog?: DialogFn;
110
+ deadlineAt?: number;
99
111
  }): Promise<HookVerdict & {
100
112
  resolution?: AskResolution;
101
113
  envelope?: AskEnvelope;
package/dist/askDialog.js CHANGED
@@ -30,12 +30,15 @@ exports.resolveAskWithDialog = resolveAskWithDialog;
30
30
  */
31
31
  const devConfirm_1 = require("./devConfirm");
32
32
  const telemetry_1 = require("./telemetry");
33
+ const actionIdentity_1 = require("./actionIdentity");
33
34
  const hookIo_1 = require("./hookIo");
34
35
  const callerEnv_1 = require("./callerEnv");
35
36
  /** Field on the verdict JSON that carries the envelope. Stripped before the IDE sees the verdict. */
36
37
  exports.ASK_ENVELOPE_FIELD = 'fcdAsk';
37
38
  /** Default wait for the person. Cursor's hook window is 90 s, Claude's 60 s by default; stay inside both. */
38
39
  exports.ASK_DIALOG_TIMEOUT_MS = 55_000;
40
+ /** Less time than this before the IDE's cutoff is not enough to read and answer a dialog. */
41
+ const MIN_DIALOG_TIMEOUT_MS = 5_000;
39
42
  /** Which channel asks the developer. `dialog` (default) = our OS dialog, `ide` = the IDE's own card. */
40
43
  function askChannel() {
41
44
  return ((0, callerEnv_1.callerEnv)().FCD_ASK_CHANNEL || '').trim().toLowerCase() === 'ide' ? 'ide' : 'dialog';
@@ -97,9 +100,20 @@ function rewriteDecision(decision, allowed, userMsg, agentMsg) {
97
100
  * written as-is with the envelope removed. Then the process exits.
98
101
  */
99
102
  async function emitVerdictForIde(args, result, entry) {
103
+ // This process ends with the verdict below: an upload the dialog's outcome
104
+ // asks for (allow, decline, timeout) starts after stdout, at exit — never
105
+ // while the IDE still waits for the answer.
106
+ try {
107
+ (0, telemetry_1.deferFlushUntilExit)();
108
+ }
109
+ catch { /* best-effort */ }
100
110
  let final = result;
101
111
  try {
102
- const resolved = await resolveAskWithDialog(result, { clientLabel: args.agentClient });
112
+ // This is the per-event process the IDE started: its clock started with
113
+ // the process, so the dialog must end before the IDE's hook timeout does
114
+ // (a killed hook runs the action without anyone's answer).
115
+ const processStartedAt = Date.now() - Math.round(process.uptime() * 1000);
116
+ const resolved = await resolveAskWithDialog(result, { clientLabel: args.agentClient, deadlineAt: (0, callerEnv_1.ideWaitCutoff)(processStartedAt) });
103
117
  if (resolved.resolution)
104
118
  (0, hookIo_1.dbg)({ phase: 'ask_dialog', event: args.event, entry, resolution: resolved.resolution, tool: resolved.envelope?.toolName });
105
119
  final = resolved;
@@ -137,7 +151,11 @@ async function resolveAskWithDialog(verdict, options = {}) {
137
151
  const clean = { ...verdict, stdout: taken.stdout, envelope };
138
152
  // An injected dialog (tests, embedding hosts) only needs a person; the OS dialog also needs a display.
139
153
  const canAsk = options.dialog ? (0, devConfirm_1.isHumanPresent)() : (0, devConfirm_1.nativeDialogAvailable)();
140
- if (askChannel() === 'ide' || !canAsk)
154
+ // Wait = min(configured, time left before the IDE's cutoff). Too little time
155
+ // left for a person to read and answer: the IDE's own ask card takes the
156
+ // question instead, so the hook still answers inside the IDE's window.
157
+ const dialogTimeoutMs = Math.min(options.timeoutMs ?? exports.ASK_DIALOG_TIMEOUT_MS, options.deadlineAt === undefined ? Number.POSITIVE_INFINITY : options.deadlineAt - Date.now());
158
+ if (askChannel() === 'ide' || !canAsk || dialogTimeoutMs < MIN_DIALOG_TIMEOUT_MS)
141
159
  return { ...clean, resolution: 'ide' };
142
160
  const grantHash = envelope.ruleHash || envelope.policyHash;
143
161
  const grantLimits = envelope.grantLimits === null ? undefined : (envelope.grantLimits || { ttlMs: devConfirm_1.GRANT_TTL_MS });
@@ -163,7 +181,7 @@ async function resolveAskWithDialog(verdict, options = {}) {
163
181
  let outcome = 'unavailable';
164
182
  let latencyMs = 0;
165
183
  try {
166
- ({ outcome, latencyMs } = await dialog(reason, options.timeoutMs ?? exports.ASK_DIALOG_TIMEOUT_MS, { allowAlways, alwaysScopeLabel: scopeLabel, alwaysLimitsLabel: limitsLabel, alwaysCovers, alwaysNotOffered, subject }));
184
+ ({ outcome, latencyMs } = await dialog(reason, dialogTimeoutMs, { allowAlways, alwaysScopeLabel: scopeLabel, alwaysLimitsLabel: limitsLabel, alwaysCovers, alwaysNotOffered, subject }));
167
185
  }
168
186
  catch {
169
187
  outcome = 'unavailable';
@@ -171,6 +189,14 @@ async function resolveAskWithDialog(verdict, options = {}) {
171
189
  if (outcome === 'unavailable')
172
190
  return { ...clean, resolution: 'ide' };
173
191
  const policyLabel = envelope.policyName ? ` (${envelope.policyName})` : '';
192
+ // The outcome is the asking call's: its client and identity, not this process's.
193
+ // No latency / verdict path: the ask itself recorded the hook's wait (one health
194
+ // sample per call), and the person's answer time is confirmLatencyMs.
195
+ const outcomeFacts = (askOutcome) => ({
196
+ askOutcome,
197
+ ...(envelope.agentClient || options.clientLabel ? { agentClient: envelope.agentClient || options.clientLabel } : {}),
198
+ ...envelopeIdentity(envelope),
199
+ });
174
200
  if (outcome === 'allow' || outcome === 'allow_always') {
175
201
  // Same bookkeeping the post-execution hook would have done for an IDE Allow.
176
202
  (0, devConfirm_1.settleDeveloperConfirmation)(envelope.event, envelope.toolName, envelope.argsDigest);
@@ -193,6 +219,7 @@ async function resolveAskWithDialog(verdict, options = {}) {
193
219
  approvalScope: 'developer',
194
220
  approvedBy: 'developer',
195
221
  confirmLatencyMs: latencyMs,
222
+ ...outcomeFacts(granted ? 'allowed_always' : 'allowed'),
196
223
  });
197
224
  (0, telemetry_1.triggerFlush)(false);
198
225
  const agentMsg = granted
@@ -213,6 +240,9 @@ async function resolveAskWithDialog(verdict, options = {}) {
213
240
  policyHash: envelope.policyHash,
214
241
  approvalScope: 'developer',
215
242
  confirmLatencyMs: latencyMs,
243
+ // No answer is not a rule's block: the action did not run (the IDE gets the
244
+ // same deny), but nobody decided against it — recorded as an unanswered ask.
245
+ ...outcomeFacts(timedOut ? 'unanswered' : 'denied'),
216
246
  });
217
247
  (0, telemetry_1.triggerFlush)(true);
218
248
  const userMsg = timedOut
@@ -223,3 +253,16 @@ async function resolveAskWithDialog(verdict, options = {}) {
223
253
  : `FullCourtDefense: the developer denied this ${envelope.event} in the confirmation dialog. Do not retry or work around it.`;
224
254
  return { ...clean, stdout: JSON.stringify(rewriteDecision(decision, false, userMsg, agentMsg)), exitCode: 0, resolution: outcome };
225
255
  }
256
+ /** The asking call's identity from the envelope: well-formed parts only. */
257
+ function envelopeIdentity(envelope) {
258
+ const raw = envelope.identity;
259
+ if (!raw || typeof raw !== 'object')
260
+ return {};
261
+ const out = {};
262
+ for (const key of ['sessionId', 'runId', 'instanceId', 'attemptId']) {
263
+ const value = (0, actionIdentity_1.identityPart)(raw[key]);
264
+ if (value)
265
+ out[key] = value;
266
+ }
267
+ return out;
268
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Was the MACHINE asleep, or only the daemon's event loop?
3
+ *
4
+ * The daemon detects a resume from sleep by a beat that observes far more
5
+ * wall time than its interval. A long synchronous stretch on its own loop
6
+ * looks the same: 2026-10-02 06:58, a 201 s protect-all pass ended and two
7
+ * seconds later the daemon logged "Resume detected: 237s ... suspended",
8
+ * re-reported in and recorded network_down [x30] from timeouts that ran out
9
+ * inside the stall — while hook.log showed hooks every few seconds throughout.
10
+ *
11
+ * This clock ticks on a worker thread, which keeps running while the main
12
+ * loop is blocked and stops only when the whole process (the machine) does.
13
+ * A long main-loop gap with no long gap here was a stall, not a suspend.
14
+ * One thread, a 1 s timer, no process and no I/O.
15
+ */
16
+ export declare const AWAKE_TICK_MS = 1000;
17
+ /** A worker gap this short through a long main-loop gap: the machine stayed awake. */
18
+ export declare const AWAKE_GAP_MAX_MS = 10000;
19
+ export interface AwakeClock {
20
+ /** Longest gap between ticks since the previous call (including the time since the last tick). Undefined once the clock is gone. */
21
+ takeMaxGapMs(): number | undefined;
22
+ stop(): void;
23
+ }
24
+ export declare function startAwakeClock(tickMs?: number): AwakeClock | undefined;
25
+ /**
26
+ * Pure: what a gap of `driftMs` between two beats of the daemon's loop was.
27
+ * Without the awake clock (it could not start) every long gap stays a suspend,
28
+ * as before.
29
+ */
30
+ export declare function classifyClockJump(input: {
31
+ driftMs: number;
32
+ jumpMs: number;
33
+ awakeGapMs?: number;
34
+ }): 'none' | 'stall' | 'suspend';
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AWAKE_GAP_MAX_MS = exports.AWAKE_TICK_MS = void 0;
4
+ exports.startAwakeClock = startAwakeClock;
5
+ exports.classifyClockJump = classifyClockJump;
6
+ const worker_threads_1 = require("worker_threads");
7
+ /**
8
+ * Was the MACHINE asleep, or only the daemon's event loop?
9
+ *
10
+ * The daemon detects a resume from sleep by a beat that observes far more
11
+ * wall time than its interval. A long synchronous stretch on its own loop
12
+ * looks the same: 2026-10-02 06:58, a 201 s protect-all pass ended and two
13
+ * seconds later the daemon logged "Resume detected: 237s ... suspended",
14
+ * re-reported in and recorded network_down [x30] from timeouts that ran out
15
+ * inside the stall — while hook.log showed hooks every few seconds throughout.
16
+ *
17
+ * This clock ticks on a worker thread, which keeps running while the main
18
+ * loop is blocked and stops only when the whole process (the machine) does.
19
+ * A long main-loop gap with no long gap here was a stall, not a suspend.
20
+ * One thread, a 1 s timer, no process and no I/O.
21
+ */
22
+ exports.AWAKE_TICK_MS = 1_000;
23
+ /** A worker gap this short through a long main-loop gap: the machine stayed awake. */
24
+ exports.AWAKE_GAP_MAX_MS = 10_000;
25
+ function startAwakeClock(tickMs = exports.AWAKE_TICK_MS) {
26
+ try {
27
+ // [0] last tick (wall ms), [1] longest gap between ticks since the last read.
28
+ const shared = new Float64Array(new SharedArrayBuffer(16));
29
+ shared[0] = Date.now();
30
+ const source = `
31
+ const { workerData } = require('worker_threads');
32
+ const clock = new Float64Array(workerData);
33
+ let last = Date.now();
34
+ setInterval(() => {
35
+ const now = Date.now();
36
+ if (now - last > clock[1]) clock[1] = now - last;
37
+ last = now;
38
+ clock[0] = now;
39
+ }, ${Math.max(100, Math.floor(tickMs))});
40
+ `;
41
+ const worker = new worker_threads_1.Worker(source, {
42
+ eval: true,
43
+ workerData: shared.buffer,
44
+ resourceLimits: { maxOldGenerationSizeMb: 16, maxYoungGenerationSizeMb: 4 },
45
+ stdout: true,
46
+ stderr: true,
47
+ });
48
+ worker.unref();
49
+ let alive = true;
50
+ worker.on('error', () => { alive = false; });
51
+ worker.on('exit', () => { alive = false; });
52
+ return {
53
+ takeMaxGapMs() {
54
+ if (!alive)
55
+ return undefined;
56
+ const gap = Math.max(shared[1], Date.now() - shared[0]);
57
+ shared[1] = 0;
58
+ return gap;
59
+ },
60
+ stop() {
61
+ alive = false;
62
+ void worker.terminate().catch(() => { });
63
+ },
64
+ };
65
+ }
66
+ catch {
67
+ return undefined;
68
+ }
69
+ }
70
+ /**
71
+ * Pure: what a gap of `driftMs` between two beats of the daemon's loop was.
72
+ * Without the awake clock (it could not start) every long gap stays a suspend,
73
+ * as before.
74
+ */
75
+ function classifyClockJump(input) {
76
+ if (input.driftMs < input.jumpMs)
77
+ return 'none';
78
+ if (input.awakeGapMs !== undefined && input.awakeGapMs < exports.AWAKE_GAP_MAX_MS)
79
+ return 'stall';
80
+ return 'suspend';
81
+ }
@@ -1,7 +1,20 @@
1
+ /** Distinct recent versions kept per config, besides its oldest (original) copy. */
2
+ export declare const KEEP_RECENT_BACKUPS = 3;
1
3
  export declare function backupManifestPath(): string;
2
4
  /** Every recorded backup path (absent/corrupt manifest = empty list). */
3
5
  export declare function readBackupManifest(): string[];
4
- /** Record one backup file path. Deduped, capped, never throws. */
6
+ /** Record one backup file path. Deduped, never throws. */
5
7
  export declare function recordConfigBackup(backupPath: string): void;
6
8
  /** Drop entries whose backup file no longer exists (post-cleanup compaction). */
7
9
  export declare function pruneBackupManifest(): void;
10
+ /** This config's safety copies next to it (`<name>.fcd-backup-<ms>`), oldest first. */
11
+ export declare function listConfigBackups(file: string): string[];
12
+ /**
13
+ * Back `file` up next to itself before FullCourtDefense rewrites it — once per
14
+ * distinct content: a version already kept (a rewrite that only flips the
15
+ * runtime path back and forth, a pass that writes the same content) adds no
16
+ * copy. Afterwards the config keeps its oldest copy (the original, before
17
+ * FullCourtDefense first changed it) and its KEEP_RECENT newest. Returns the
18
+ * new copy, or undefined when none was needed (or it failed). Never throws.
19
+ */
20
+ export declare function createConfigBackup(file: string, now?: number): string | undefined;
@@ -33,10 +33,13 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.KEEP_RECENT_BACKUPS = void 0;
36
37
  exports.backupManifestPath = backupManifestPath;
37
38
  exports.readBackupManifest = readBackupManifest;
38
39
  exports.recordConfigBackup = recordConfigBackup;
39
40
  exports.pruneBackupManifest = pruneBackupManifest;
41
+ exports.listConfigBackups = listConfigBackups;
42
+ exports.createConfigBackup = createConfigBackup;
40
43
  const fs = __importStar(require("fs"));
41
44
  const os = __importStar(require("os"));
42
45
  const path = __importStar(require("path"));
@@ -51,11 +54,21 @@ const path = __importStar(require("path"));
51
54
  * authoritative list at cleanup time (union-ed with the known-dir scan for
52
55
  * backups created by older CLI versions).
53
56
  *
57
+ * Bounded (2026-10: 5,517 copies, 9.8 MB, and a manifest silently cut at its
58
+ * newest 2,000 entries, so uninstall missed most of them): a config keeps the
59
+ * copy taken before FullCourtDefense first changed it plus its last
60
+ * KEEP_RECENT distinct versions (createConfigBackup), and the manifest drops
61
+ * only entries whose file is gone — never a live one.
62
+ *
54
63
  * Best-effort by design: a manifest write failure must never break a protect
55
64
  * operation, and cleanup falls back to the directory scan.
56
65
  */
57
66
  const MANIFEST_NAME = 'backup-manifest.json';
58
- const MAX_ENTRIES = 2000;
67
+ /** Size guard for the manifest file itself; reached only after every missing-file entry is dropped. */
68
+ const MAX_ENTRIES = 20_000;
69
+ /** Distinct recent versions kept per config, besides its oldest (original) copy. */
70
+ exports.KEEP_RECENT_BACKUPS = 3;
71
+ const BACKUP_INFIX = '.fcd-backup-';
59
72
  function backupManifestPath() {
60
73
  return path.join(os.homedir(), '.fullcourtdefense', MANIFEST_NAME);
61
74
  }
@@ -70,23 +83,105 @@ function readBackupManifest() {
70
83
  return [];
71
84
  }
72
85
  }
73
- /** Record one backup file path. Deduped, capped, never throws. */
74
- function recordConfigBackup(backupPath) {
86
+ function sleepSync(ms) {
87
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
88
+ }
89
+ /**
90
+ * Read-modify-write the manifest under a short lock, written atomically
91
+ * (temp + rename): concurrent protect passes (daemon, CLI, hooks) no longer
92
+ * drop each other's entries. Bounded wait; never throws.
93
+ */
94
+ function updateManifest(change) {
95
+ const file = backupManifestPath();
96
+ const lock = `${file}.lock`;
97
+ let held = false;
75
98
  try {
76
- const existing = readBackupManifest();
77
- if (existing.includes(backupPath))
78
- return;
79
- const entries = [...existing, backupPath].slice(-MAX_ENTRIES);
80
- const file = backupManifestPath();
81
99
  fs.mkdirSync(path.dirname(file), { recursive: true });
82
- fs.writeFileSync(file, `${JSON.stringify({ backups: entries }, null, 2)}\n`, 'utf8');
100
+ // Protect passes only (daemon, housekeeping worker, CLI) — never a hook: a short wait is fine.
101
+ const deadline = Date.now() + 2_000;
102
+ for (let attempt = 0; attempt < 1_000 && !held; attempt += 1) {
103
+ try {
104
+ fs.writeFileSync(lock, String(process.pid), { flag: 'wx' });
105
+ held = true;
106
+ }
107
+ catch (error) {
108
+ // EPERM/EACCES: Windows reports a lock file being deleted that way — still contention.
109
+ const code = error.code;
110
+ if (code !== 'EEXIST' && code !== 'EPERM' && code !== 'EACCES')
111
+ break;
112
+ try {
113
+ if (Date.now() - fs.statSync(lock).mtimeMs > 10_000) {
114
+ fs.unlinkSync(lock);
115
+ continue;
116
+ }
117
+ }
118
+ catch { /* released meanwhile */ }
119
+ if (Date.now() >= deadline)
120
+ break;
121
+ sleepSync(5);
122
+ }
123
+ }
124
+ const before = readBackupManifest();
125
+ const next = change(before);
126
+ if (!next)
127
+ return;
128
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
129
+ try {
130
+ if (next.length === 0) {
131
+ fs.rmSync(file, { force: true });
132
+ return;
133
+ }
134
+ fs.writeFileSync(tmp, `${JSON.stringify({ backups: next }, null, 2)}\n`, 'utf8');
135
+ for (let attempt = 0;; attempt += 1) {
136
+ try {
137
+ fs.renameSync(tmp, file);
138
+ break;
139
+ }
140
+ catch (error) {
141
+ // Windows: a reader (or a scanner) holding the file open fails the rename for a moment.
142
+ const code = error.code;
143
+ if (attempt >= 20 || (code !== 'EPERM' && code !== 'EACCES' && code !== 'EBUSY'))
144
+ throw error;
145
+ sleepSync(10);
146
+ }
147
+ }
148
+ }
149
+ catch {
150
+ try {
151
+ fs.unlinkSync(tmp);
152
+ }
153
+ catch { /* not there */ }
154
+ }
83
155
  }
84
156
  catch { /* best effort — cleanup still has the directory scan */ }
157
+ finally {
158
+ if (held) {
159
+ try {
160
+ fs.unlinkSync(lock);
161
+ }
162
+ catch { /* gone */ }
163
+ }
164
+ }
165
+ }
166
+ /** Drop entries whose file is gone; only then, if still over the guard, the oldest. */
167
+ function bounded(entries) {
168
+ if (entries.length <= MAX_ENTRIES)
169
+ return entries;
170
+ const live = entries.filter(entry => { try {
171
+ return fs.existsSync(entry);
172
+ }
173
+ catch {
174
+ return true;
175
+ } });
176
+ return live.length <= MAX_ENTRIES ? live : live.slice(-MAX_ENTRIES);
177
+ }
178
+ /** Record one backup file path. Deduped, never throws. */
179
+ function recordConfigBackup(backupPath) {
180
+ updateManifest(existing => (existing.includes(backupPath) ? undefined : bounded([...existing, backupPath])));
85
181
  }
86
182
  /** Drop entries whose backup file no longer exists (post-cleanup compaction). */
87
183
  function pruneBackupManifest() {
88
- try {
89
- const existing = readBackupManifest();
184
+ updateManifest((existing) => {
90
185
  const remaining = existing.filter(entry => {
91
186
  try {
92
187
  return fs.existsSync(entry);
@@ -95,14 +190,80 @@ function pruneBackupManifest() {
95
190
  return true;
96
191
  }
97
192
  });
98
- if (remaining.length === existing.length)
99
- return;
100
- const file = backupManifestPath();
101
- if (remaining.length === 0) {
102
- fs.rmSync(file, { force: true });
103
- return;
193
+ return remaining.length === existing.length ? undefined : remaining;
194
+ });
195
+ }
196
+ /** This config's safety copies next to it (`<name>.fcd-backup-<ms>`), oldest first. */
197
+ function listConfigBackups(file) {
198
+ const dir = path.dirname(file);
199
+ const prefix = `${path.basename(file)}${BACKUP_INFIX}`;
200
+ try {
201
+ return fs.readdirSync(dir)
202
+ .filter(name => name.startsWith(prefix) && /^\d+$/.test(name.slice(prefix.length)))
203
+ .sort((a, b) => Number(a.slice(prefix.length)) - Number(b.slice(prefix.length)))
204
+ .map(name => path.join(dir, name));
205
+ }
206
+ catch {
207
+ return [];
208
+ }
209
+ }
210
+ function sameContent(file, content) {
211
+ try {
212
+ return fs.statSync(file).size === content.length && fs.readFileSync(file).equals(content);
213
+ }
214
+ catch {
215
+ return false;
216
+ }
217
+ }
218
+ /**
219
+ * Back `file` up next to itself before FullCourtDefense rewrites it — once per
220
+ * distinct content: a version already kept (a rewrite that only flips the
221
+ * runtime path back and forth, a pass that writes the same content) adds no
222
+ * copy. Afterwards the config keeps its oldest copy (the original, before
223
+ * FullCourtDefense first changed it) and its KEEP_RECENT newest. Returns the
224
+ * new copy, or undefined when none was needed (or it failed). Never throws.
225
+ */
226
+ function createConfigBackup(file, now = Date.now()) {
227
+ try {
228
+ const content = fs.readFileSync(file);
229
+ const existing = listConfigBackups(file);
230
+ if (existing.some(copy => sameContent(copy, content)))
231
+ return undefined;
232
+ let stamp = now;
233
+ let backup = '';
234
+ for (let attempt = 0; attempt < 5; attempt += 1, stamp += 1) {
235
+ backup = `${file}${BACKUP_INFIX}${stamp}`;
236
+ try {
237
+ fs.copyFileSync(file, backup, fs.constants.COPYFILE_EXCL);
238
+ break;
239
+ }
240
+ catch (error) {
241
+ if (error.code !== 'EEXIST' || attempt === 4)
242
+ throw error;
243
+ }
104
244
  }
105
- fs.writeFileSync(file, `${JSON.stringify({ backups: remaining }, null, 2)}\n`, 'utf8');
245
+ // Backups can embed live credentials and land in project-local dirs no
246
+ // scan can enumerate — record each one so uninstall can delete them all.
247
+ recordConfigBackup(backup);
248
+ const all = [...existing, backup];
249
+ const keep = new Set([all[0], ...all.slice(-exports.KEEP_RECENT_BACKUPS)]);
250
+ const pruned = [];
251
+ for (const copy of all) {
252
+ if (keep.has(copy))
253
+ continue;
254
+ try {
255
+ fs.rmSync(copy, { force: true });
256
+ pruned.push(copy);
257
+ }
258
+ catch { /* left for uninstall */ }
259
+ }
260
+ if (pruned.length > 0) {
261
+ const gone = new Set(pruned);
262
+ updateManifest(entries => entries.filter(entry => !gone.has(entry)));
263
+ }
264
+ return backup;
265
+ }
266
+ catch {
267
+ return undefined;
106
268
  }
107
- catch { /* best effort */ }
108
269
  }
@@ -11,10 +11,23 @@
11
11
  *
12
12
  * Node built-ins only: this leaf is on the slim hook's warm-path module graph.
13
13
  */
14
- export declare const CALLER_ENV_KEYS: readonly ["FCD_HUMAN_PRESENT", "FCD_EPHEMERAL", "FULLCOURTDEFENSE_EPHEMERAL", "CI", "GITHUB_ACTIONS", "GITLAB_CI", "TF_BUILD", "JENKINS_URL", "BUILDKITE", "CIRCLECI", "DISPLAY", "WAYLAND_DISPLAY", "FCD_ASK_CHANNEL", "FCD_SESSION_ID", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "FCD_RUN_ID", "GITHUB_RUN_ID", "CI_PIPELINE_ID", "FCD_WORKLOAD_INSTANCE_ID", "CLAUDECODE", "CLAUDE_PROJECT_DIR", "VSCODE_PID", "TERM_PROGRAM", "WORKSPACE_PATH", "FCD_HOOK_IDE_TIMEOUT_MS", "FCD_TAINT_APPROVE_ONCE_DISABLED", "FCD_TAINT_APPROVAL_TIMEOUT_MS", "CLOUDSDK_CORE_PROJECT", "CLOUDSDK_ACTIVE_CONFIG_NAME", "CLOUDSDK_CONFIG", "APPDATA", "AWS_PROFILE", "AWS_DEFAULT_PROFILE", "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE", "AWS_ACCESS_KEY_ID", "KUBECONFIG", "HELM_KUBECONTEXT", "HELM_NAMESPACE", "HELM_KUBEAPISERVER", "HELM_KUBETOKEN", "HELM_KUBEASUSER", "DOCKER_HOST", "DOCKER_CONTEXT", "AZURE_CONFIG_DIR", "AZURE_DEFAULTS_GROUP", "TF_WORKSPACE", "TF_DATA_DIR", "PGHOST", "PGDATABASE", "PGSERVICE", "MYSQL_HOST"];
14
+ export declare const CALLER_ENV_KEYS: readonly ["FCD_HUMAN_PRESENT", "FCD_EPHEMERAL", "FULLCOURTDEFENSE_EPHEMERAL", "CI", "GITHUB_ACTIONS", "GITLAB_CI", "TF_BUILD", "JENKINS_URL", "BUILDKITE", "CIRCLECI", "DISPLAY", "WAYLAND_DISPLAY", "FCD_ASK_CHANNEL", "FCD_SESSION_ID", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "FCD_RUN_ID", "GITHUB_RUN_ID", "CI_PIPELINE_ID", "FCD_WORKLOAD_INSTANCE_ID", "CLAUDECODE", "CLAUDE_PROJECT_DIR", "VSCODE_PID", "TERM_PROGRAM", "WORKSPACE_PATH", "FCD_HOOK_IDE_TIMEOUT_MS", "FCD_TAINT_APPROVE_ONCE_DISABLED", "FCD_TAINT_APPROVAL_TIMEOUT_MS", "FCD_HOOK_DEBUG", "FCD_MONITOR_HINTS", "FCD_PERF_PROBE", "CLOUDSDK_CORE_PROJECT", "CLOUDSDK_ACTIVE_CONFIG_NAME", "CLOUDSDK_CONFIG", "APPDATA", "AWS_PROFILE", "AWS_DEFAULT_PROFILE", "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE", "AWS_ACCESS_KEY_ID", "KUBECONFIG", "HELM_KUBECONTEXT", "HELM_NAMESPACE", "HELM_KUBEAPISERVER", "HELM_KUBETOKEN", "HELM_KUBEASUSER", "DOCKER_HOST", "DOCKER_CONTEXT", "AZURE_CONFIG_DIR", "AZURE_DEFAULTS_GROUP", "TF_WORKSPACE", "TF_DATA_DIR", "PGHOST", "PGDATABASE", "PGSERVICE", "MYSQL_HOST"];
15
15
  export type CallerEnvSnapshot = Partial<Record<(typeof CALLER_ENV_KEYS)[number], string>>;
16
+ /**
17
+ * Keys a decision reads only for WHETHER they are set (cloudGrantTools: static
18
+ * AWS keys beat every profile; a helm API server / token / impersonation makes
19
+ * the target unknown) — and some are credentials (HELM_KUBETOKEN is a bearer
20
+ * token). A snapshot that is written to disk keeps only that they are set.
21
+ */
22
+ export declare const PRESENCE_ONLY_CALLER_ENV_KEYS: readonly (typeof CALLER_ENV_KEYS)[number][];
16
23
  /** The keys above that are set in `env` — what the thin client sends. */
17
24
  export declare function snapshotCallerEnv(env?: NodeJS.ProcessEnv): CallerEnvSnapshot;
25
+ /**
26
+ * snapshotCallerEnv for a snapshot that is STORED (the monitor fast path's
27
+ * local queue): the same decision facts, without credential values — a
28
+ * presence-only key keeps '1' when set (non-blank), '' when blank.
29
+ */
30
+ export declare function storableCallerEnv(env?: NodeJS.ProcessEnv): CallerEnvSnapshot;
18
31
  /** The environment decision facts are read from: the requesting caller's inside a daemon request, else this process's. */
19
32
  export declare function callerEnv(): NodeJS.ProcessEnv;
20
33
  /**
@@ -26,13 +39,47 @@ export declare function withCallerEnv<T>(snapshot: unknown, fn: () => T): T;
26
39
  /**
27
40
  * How long the IDE lets an approval-capable hook run before it kills the hook
28
41
  * (and runs the action): Claude Code / Copilot / VS Code and Cursor: 90s.
29
- * FCD_HOOK_IDE_TIMEOUT_MS narrows it for drills.
42
+ * FCD_HOOK_IDE_TIMEOUT_MS narrows it — per hook entry (applyIdeTimeoutArg) and
43
+ * for drills.
30
44
  */
31
45
  export declare const IDE_HOOK_TIMEOUT_MS = 90000;
32
46
  export declare const IDE_TIMEOUT_MARGIN_MS = 15000;
33
47
  export declare function ideHookTimeoutMs(env?: NodeJS.ProcessEnv): number;
48
+ /**
49
+ * The installer writes each hook entry's real IDE timeout into its command
50
+ * (`--ide-timeout <seconds>`, the same number as the entry's `timeout`): Claude
51
+ * Code PostToolUse 10s, UserPromptSubmit 30s, PreToolUse 90s; Cursor per event.
52
+ * A hook the IDE kills runs the action, so every wait must end before THAT
53
+ * timeout, not before a fixed 90s. Applied, in the hook's own process only, as
54
+ * its FCD_HOOK_IDE_TIMEOUT_MS — a caller-env key, so the daemon evaluating the
55
+ * call applies it too (older daemons already honour it). It only ever narrows
56
+ * the 90s default: an entry configured longer keeps today's waits. An explicit
57
+ * FCD_HOOK_IDE_TIMEOUT_MS (drills) wins; a command line without the argument
58
+ * (older installs) keeps the default.
59
+ */
60
+ export declare function applyIdeTimeoutArg(seconds: string | undefined, env?: NodeJS.ProcessEnv): void;
34
61
  /** Absolute time every wait inside a hook must end by, leaving the margin before the IDE's timeout. */
35
62
  export declare function ideWaitCutoff(startedAt: number, ideTimeoutMs?: number): number;
63
+ /**
64
+ * One truthiness rule for FCD_* switches: `1`, `true`, `yes`, `on` (any case,
65
+ * surrounding spaces ignored). Unset, empty, `0`, `false`, `off` and anything
66
+ * else are off. New switches use it; the existing decision switches
67
+ * (FCD_HUMAN_PRESENT, FCD_EPHEMERAL, ...) keep their exact historical spellings,
68
+ * because widening them would change a verdict for the same input.
69
+ */
70
+ export declare function envFlag(value: string | undefined): boolean;
71
+ /**
72
+ * The test-run markers set on THIS process (names; empty for every install).
73
+ * scripts/lib/no-real-dialog.js sets FCD_TEST_NO_REAL_DIALOG, the sandboxed
74
+ * daemon harnesses FCD_TEST_SANDBOX_ROOT, scripts/lib/fake-os-dialog.js
75
+ * FCD_FAKE_DIALOG_PID. No install, MSI, scheduled task or IDE hook config sets
76
+ * one. Read from process.env, never from a caller snapshot: the guards they
77
+ * drive (testRunGuard.ts, devConfirm, notify) protect this process's desktop
78
+ * and profile.
79
+ */
80
+ export declare function testRunMarkers(env?: NodeJS.ProcessEnv): string[];
81
+ /** A test run that promised no real OS dialog or notification on this desktop (devConfirm, notify). */
82
+ export declare function noRealUiUnderTest(env?: NodeJS.ProcessEnv): boolean;
36
83
  /** CI / pipeline environments — no human at a keyboard. Shared by hooks and discovery. */
37
84
  export declare function isCiEnvironment(env?: NodeJS.ProcessEnv): boolean;
38
85
  /**