fullcourtdefense-cli 1.37.0 → 1.39.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 (61) hide show
  1. package/dist/askDialog.d.ts +35 -3
  2. package/dist/askDialog.js +100 -7
  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 +21 -1
  8. package/dist/callerEnv.js +40 -0
  9. package/dist/commands/cmdGuard.d.ts +1 -0
  10. package/dist/commands/cmdGuard.js +14 -1
  11. package/dist/commands/daemon.d.ts +20 -0
  12. package/dist/commands/daemon.js +310 -28
  13. package/dist/commands/desktopChatGuard.d.ts +20 -0
  14. package/dist/commands/desktopChatGuard.js +60 -11
  15. package/dist/commands/deterministicGuard.js +1730 -132
  16. package/dist/commands/doctor.js +5 -1
  17. package/dist/commands/hook.d.ts +16 -1
  18. package/dist/commands/hook.js +286 -49
  19. package/dist/commands/mcpGateway.js +16 -10
  20. package/dist/commands/posixShellGuard.js +3 -2
  21. package/dist/commands/shellGuard.js +25 -6
  22. package/dist/commands/uninstallAll.js +5 -0
  23. package/dist/commands/watchdog.d.ts +8 -0
  24. package/dist/commands/watchdog.js +19 -0
  25. package/dist/daemonForensics.d.ts +11 -0
  26. package/dist/daemonForensics.js +25 -1
  27. package/dist/devConfirm.d.ts +55 -5
  28. package/dist/devConfirm.js +24 -9
  29. package/dist/distress.d.ts +10 -0
  30. package/dist/distress.js +273 -32
  31. package/dist/gateReports.js +15 -0
  32. package/dist/hookIo.d.ts +20 -2
  33. package/dist/hookIo.js +106 -15
  34. package/dist/hookSlim.js +14 -5
  35. package/dist/housekeeping.js +1 -0
  36. package/dist/housekeepingOps.d.ts +5 -5
  37. package/dist/housekeepingOps.js +17 -2
  38. package/dist/index.js +26 -0
  39. package/dist/monitorFastPath.d.ts +39 -3
  40. package/dist/monitorFastPath.js +67 -8
  41. package/dist/nativeHook.d.ts +21 -0
  42. package/dist/nativeHook.js +95 -17
  43. package/dist/notify.js +51 -1
  44. package/dist/pendingDrainer.d.ts +14 -4
  45. package/dist/pendingDrainer.js +74 -24
  46. package/dist/perfSnapshot.d.ts +40 -2
  47. package/dist/perfSnapshot.js +117 -31
  48. package/dist/policyCacheFiles.d.ts +10 -0
  49. package/dist/policyCacheFiles.js +75 -0
  50. package/dist/runtimeConfig.d.ts +6 -0
  51. package/dist/runtimeConfig.js +14 -0
  52. package/dist/shellAst.js +3 -1
  53. package/dist/telemetry.d.ts +66 -1
  54. package/dist/telemetry.js +127 -5
  55. package/dist/testRunGuard.d.ts +28 -0
  56. package/dist/testRunGuard.js +228 -0
  57. package/dist/verdictIpc.js +159 -36
  58. package/dist/verdictIpcClient.d.ts +80 -3
  59. package/dist/verdictIpcClient.js +214 -26
  60. package/dist/version.json +1 -1
  61. package/package.json +4 -2
@@ -15,7 +15,9 @@
15
15
  *
16
16
  * and answers the IDE `allow` or `deny`. The IDE's own card is the fallback only
17
17
  * where no dialog is possible (no display, dialog binary missing) or where the
18
- * org chose it (`FCD_ASK_CHANNEL=ide`). The envelope never reaches the IDE.
18
+ * org chose it (`FCD_ASK_CHANNEL=ide`) — and never for a client that reports a
19
+ * permission mode in which it may answer its own card (v139 F-13 / F-33): our
20
+ * dialog asks there, or the action is denied. The envelope never reaches the IDE.
19
21
  *
20
22
  * Deterministic all the way: the rule that asked is named in the envelope, the
21
23
  * grant is bound to that rule's hash, and nothing here re-evaluates the action.
@@ -56,8 +58,31 @@ export interface AskEnvelope {
56
58
  reason: string;
57
59
  /** The client the runtime event records as `agentClient` (cursor, claude-code, …): the dialog names the same agent as the console. */
58
60
  agentClient?: string;
61
+ /**
62
+ * The asking call's correlation identity (session / run / tool call id): the
63
+ * dialog's outcome is recorded by this process, outside that call's
64
+ * evaluation, and must name the same action. Older hook builds omit it.
65
+ */
66
+ identity?: {
67
+ sessionId?: string;
68
+ runId?: string;
69
+ instanceId?: string;
70
+ attemptId?: string;
71
+ };
72
+ /**
73
+ * The permission mode the asking client reported (Claude Code `permission_mode`).
74
+ * A mode in which the IDE may answer its own card (`idePromptState` = auto) never
75
+ * gets the question: our dialog asks, or the action is denied. Absent: the client
76
+ * does not say (Cursor, VS Code, Copilot CLI) or an older hook build.
77
+ */
78
+ ideMode?: string;
59
79
  }
60
- export type AskResolution = 'allow' | 'allow_always' | 'deny' | 'timeout' | 'ide';
80
+ /**
81
+ * How an ask ended in the per-event process. `ide`: handed to the IDE's own card.
82
+ * `ide_auto`: no FullCourtDefense dialog was possible and the IDE runs in a mode that
83
+ * may answer its own card — denied, never handed over.
84
+ */
85
+ export type AskResolution = 'allow' | 'allow_always' | 'deny' | 'timeout' | 'ide' | 'ide_auto';
61
86
  export interface HookVerdict {
62
87
  stdout: string;
63
88
  stderr: string;
@@ -81,12 +106,19 @@ export declare function takeAskEnvelope(stdout: string): {
81
106
  * Last step of the per-event hook process (full and slim entries alike): an `ask`
82
107
  * verdict is put to the developer in the FullCourtDefense dialog and becomes the
83
108
  * IDE's allow / deny; anything else — and an ask where no dialog is possible — is
84
- * written as-is with the envelope removed. Then the process exits.
109
+ * written as-is with the envelope removed (an ask the IDE may answer on its own is
110
+ * denied instead, v139 F-13). Then the process exits.
85
111
  */
86
112
  export declare function emitVerdictForIde(args: {
87
113
  event?: string;
88
114
  agentClient?: string;
89
115
  }, result: HookVerdict, entry: 'ipc' | 'local' | 'slim-ipc' | 'slim-local'): Promise<never>;
116
+ /**
117
+ * The verdict when the dialog step itself failed (emitVerdictForIde's catch): the dialog
118
+ * step must never break the verdict, so the IDE ask goes out with the envelope stripped —
119
+ * or, where the IDE may answer its own card, a deny (v139 F-13). Never throws.
120
+ */
121
+ export declare function verdictAfterDialogError(result: HookVerdict, clientLabel?: string): HookVerdict;
90
122
  /**
91
123
  * Resolve an `ask` verdict with the developer. Returns the verdict to hand to the
92
124
  * IDE (envelope removed) and how it was resolved. Any verdict without an envelope,
package/dist/askDialog.js CHANGED
@@ -5,6 +5,7 @@ exports.askChannel = askChannel;
5
5
  exports.attachAskEnvelope = attachAskEnvelope;
6
6
  exports.takeAskEnvelope = takeAskEnvelope;
7
7
  exports.emitVerdictForIde = emitVerdictForIde;
8
+ exports.verdictAfterDialogError = verdictAfterDialogError;
8
9
  exports.resolveAskWithDialog = resolveAskWithDialog;
9
10
  /**
10
11
  * The developer's question, answered in OUR dialog — one flow for every IDE.
@@ -23,13 +24,16 @@ exports.resolveAskWithDialog = resolveAskWithDialog;
23
24
  *
24
25
  * and answers the IDE `allow` or `deny`. The IDE's own card is the fallback only
25
26
  * where no dialog is possible (no display, dialog binary missing) or where the
26
- * org chose it (`FCD_ASK_CHANNEL=ide`). The envelope never reaches the IDE.
27
+ * org chose it (`FCD_ASK_CHANNEL=ide`) — and never for a client that reports a
28
+ * permission mode in which it may answer its own card (v139 F-13 / F-33): our
29
+ * dialog asks there, or the action is denied. The envelope never reaches the IDE.
27
30
  *
28
31
  * Deterministic all the way: the rule that asked is named in the envelope, the
29
32
  * grant is bound to that rule's hash, and nothing here re-evaluates the action.
30
33
  */
31
34
  const devConfirm_1 = require("./devConfirm");
32
35
  const telemetry_1 = require("./telemetry");
36
+ const actionIdentity_1 = require("./actionIdentity");
33
37
  const hookIo_1 = require("./hookIo");
34
38
  const callerEnv_1 = require("./callerEnv");
35
39
  /** Field on the verdict JSON that carries the envelope. Stripped before the IDE sees the verdict. */
@@ -96,7 +100,8 @@ function rewriteDecision(decision, allowed, userMsg, agentMsg) {
96
100
  * Last step of the per-event hook process (full and slim entries alike): an `ask`
97
101
  * verdict is put to the developer in the FullCourtDefense dialog and becomes the
98
102
  * IDE's allow / deny; anything else — and an ask where no dialog is possible — is
99
- * written as-is with the envelope removed. Then the process exits.
103
+ * written as-is with the envelope removed (an ask the IDE may answer on its own is
104
+ * denied instead, v139 F-13). Then the process exits.
100
105
  */
101
106
  async function emitVerdictForIde(args, result, entry) {
102
107
  // This process ends with the verdict below: an upload the dialog's outcome
@@ -118,9 +123,8 @@ async function emitVerdictForIde(args, result, entry) {
118
123
  final = resolved;
119
124
  }
120
125
  catch (err) {
121
- // The dialog step must never break the verdict: fall back to the IDE ask (envelope stripped).
122
126
  (0, hookIo_1.dbg)({ phase: 'ask_dialog_error', event: args.event, entry, error: err instanceof Error ? err.message : String(err) });
123
- final = { ...result, stdout: takeAskEnvelope(result.stdout).stdout };
127
+ final = verdictAfterDialogError(result, args.agentClient);
124
128
  }
125
129
  if (final.stderr) {
126
130
  try {
@@ -136,6 +140,22 @@ async function emitVerdictForIde(args, result, entry) {
136
140
  }
137
141
  return process.exit(final.exitCode);
138
142
  }
143
+ /**
144
+ * The verdict when the dialog step itself failed (emitVerdictForIde's catch): the dialog
145
+ * step must never break the verdict, so the IDE ask goes out with the envelope stripped —
146
+ * or, where the IDE may answer its own card, a deny (v139 F-13). Never throws.
147
+ */
148
+ function verdictAfterDialogError(result, clientLabel) {
149
+ const taken = takeAskEnvelope(result.stdout);
150
+ let final = { ...result, stdout: taken.stdout };
151
+ try {
152
+ if (taken.decision && taken.envelope && decisionAsks(taken.decision) && (0, devConfirm_1.idePromptState)(taken.envelope.ideMode) === 'auto') {
153
+ final = denyNoHumanPrompt({ ...final, envelope: taken.envelope }, taken.decision, taken.envelope, clientLabel);
154
+ }
155
+ }
156
+ catch { /* the verdict must still be written */ }
157
+ return final;
158
+ }
139
159
  /**
140
160
  * Resolve an `ask` verdict with the developer. Returns the verdict to hand to the
141
161
  * IDE (envelope removed) and how it was resolved. Any verdict without an envelope,
@@ -154,8 +174,18 @@ async function resolveAskWithDialog(verdict, options = {}) {
154
174
  // left for a person to read and answer: the IDE's own ask card takes the
155
175
  // question instead, so the hook still answers inside the IDE's window.
156
176
  const dialogTimeoutMs = Math.min(options.timeoutMs ?? exports.ASK_DIALOG_TIMEOUT_MS, options.deadlineAt === undefined ? Number.POSITIVE_INFINITY : options.deadlineAt - Date.now());
157
- if (askChannel() === 'ide' || !canAsk || dialogTimeoutMs < MIN_DIALOG_TIMEOUT_MS)
158
- return { ...clean, resolution: 'ide' };
177
+ // v139 F-13 / F-33: the IDE's card is a channel only where it can reach a person. A
178
+ // client that reports a mode in which it may answer its own card (Claude Code
179
+ // bypassPermissions, acceptEdits, auto, dontAsk) never gets the question, whatever
180
+ // FCD_ASK_CHANNEL / FCD_HOOK_IDE_TIMEOUT_MS say (both reachable from an IDE settings
181
+ // env block the agent can write): our dialog asks, or — no dialog possible here — the
182
+ // action is denied. A client that does not say keeps today's routing; its card's
183
+ // answer is recorded as unverified and never becomes memory (settlePostExecution).
184
+ const ideMayAnswerItself = (0, devConfirm_1.idePromptState)(envelope.ideMode) === 'auto';
185
+ const dialogPossible = canAsk && dialogTimeoutMs >= MIN_DIALOG_TIMEOUT_MS;
186
+ if (!dialogPossible || (askChannel() === 'ide' && !ideMayAnswerItself)) {
187
+ return ideMayAnswerItself ? denyNoHumanPrompt(clean, decision, envelope, options.clientLabel) : { ...clean, resolution: 'ide' };
188
+ }
159
189
  const grantHash = envelope.ruleHash || envelope.policyHash;
160
190
  const grantLimits = envelope.grantLimits === null ? undefined : (envelope.grantLimits || { ttlMs: devConfirm_1.GRANT_TTL_MS });
161
191
  const allowAlways = envelope.remember !== 'none' && Boolean(grantHash) && Boolean(grantLimits) && (0, devConfirm_1.grantOffered)(envelope.scope);
@@ -186,8 +216,16 @@ async function resolveAskWithDialog(verdict, options = {}) {
186
216
  outcome = 'unavailable';
187
217
  }
188
218
  if (outcome === 'unavailable')
189
- return { ...clean, resolution: 'ide' };
219
+ return ideMayAnswerItself ? denyNoHumanPrompt(clean, decision, envelope, options.clientLabel) : { ...clean, resolution: 'ide' };
190
220
  const policyLabel = envelope.policyName ? ` (${envelope.policyName})` : '';
221
+ // The outcome is the asking call's: its client and identity, not this process's.
222
+ // No latency / verdict path: the ask itself recorded the hook's wait (one health
223
+ // sample per call), and the person's answer time is confirmLatencyMs.
224
+ const outcomeFacts = (askOutcome) => ({
225
+ askOutcome,
226
+ ...(envelope.agentClient || options.clientLabel ? { agentClient: envelope.agentClient || options.clientLabel } : {}),
227
+ ...envelopeIdentity(envelope),
228
+ });
191
229
  if (outcome === 'allow' || outcome === 'allow_always') {
192
230
  // Same bookkeeping the post-execution hook would have done for an IDE Allow.
193
231
  (0, devConfirm_1.settleDeveloperConfirmation)(envelope.event, envelope.toolName, envelope.argsDigest);
@@ -210,6 +248,7 @@ async function resolveAskWithDialog(verdict, options = {}) {
210
248
  approvalScope: 'developer',
211
249
  approvedBy: 'developer',
212
250
  confirmLatencyMs: latencyMs,
251
+ ...outcomeFacts(granted ? 'allowed_always' : 'allowed'),
213
252
  });
214
253
  (0, telemetry_1.triggerFlush)(false);
215
254
  const agentMsg = granted
@@ -230,6 +269,9 @@ async function resolveAskWithDialog(verdict, options = {}) {
230
269
  policyHash: envelope.policyHash,
231
270
  approvalScope: 'developer',
232
271
  confirmLatencyMs: latencyMs,
272
+ // No answer is not a rule's block: the action did not run (the IDE gets the
273
+ // same deny), but nobody decided against it — recorded as an unanswered ask.
274
+ ...outcomeFacts(timedOut ? 'unanswered' : 'denied'),
233
275
  });
234
276
  (0, telemetry_1.triggerFlush)(true);
235
277
  const userMsg = timedOut
@@ -240,3 +282,54 @@ async function resolveAskWithDialog(verdict, options = {}) {
240
282
  : `FullCourtDefense: the developer denied this ${envelope.event} in the confirmation dialog. Do not retry or work around it.`;
241
283
  return { ...clean, stdout: JSON.stringify(rewriteDecision(decision, false, userMsg, agentMsg)), exitCode: 0, resolution: outcome };
242
284
  }
285
+ /**
286
+ * v139 F-13: the ask cannot reach a person — no FullCourtDefense dialog is possible here
287
+ * and the IDE runs in a mode that may answer its own card (or refuse it unseen). Denied
288
+ * with an honest record (nobody answered), and the pending marker is dropped so no later
289
+ * post-execution hook can settle it.
290
+ */
291
+ function denyNoHumanPrompt(clean, decision, envelope, clientLabel) {
292
+ try {
293
+ (0, devConfirm_1.rejectDeveloperConfirmation)(envelope.event, envelope.toolName, envelope.argsDigest);
294
+ }
295
+ catch { /* the deny below still stands */ }
296
+ const policyLabel = envelope.policyName ? ` (${envelope.policyName})` : '';
297
+ const agentClient = envelope.agentClient || clientLabel;
298
+ const client = agentClient || 'the agent';
299
+ const mode = envelope.ideMode || 'auto-approve';
300
+ try {
301
+ (0, telemetry_1.spoolEvent)({
302
+ decision: 'block',
303
+ toolName: envelope.toolName,
304
+ operation: envelope.operation,
305
+ // The gateway's "confirmation not possible" family: nobody could be asked, which the
306
+ // fleet keeps as a block alert (backend fleet-block-alerts.ts), unlike a dialog timeout.
307
+ reason: `developer confirmation not possible (the FullCourtDefense dialog cannot be shown here and ${client} runs in "${mode}" mode, which may answer its own prompt without showing it to a person) — denied${policyLabel}`,
308
+ ruleId: envelope.policyId,
309
+ policyHash: envelope.policyHash,
310
+ approvalScope: 'developer',
311
+ // Nobody answered: not a rule's block and not a person's deny.
312
+ askOutcome: 'unanswered',
313
+ ...(agentClient ? { agentClient } : {}),
314
+ ...envelopeIdentity(envelope),
315
+ });
316
+ (0, telemetry_1.triggerFlush)(true);
317
+ }
318
+ catch { /* the deny below still stands */ }
319
+ const userMsg = `FullCourtDefense stopped this ${envelope.event}: a rule asks you to confirm it${policyLabel}, but ${client} is in "${mode}" mode, which may approve its own prompts without showing them to you, and the FullCourtDefense dialog cannot be shown here. Switch the agent to a mode that asks you, or run it where the FullCourtDefense dialog can appear, to be asked.`;
320
+ const agentMsg = `${userMsg} Do not retry or work around it.`;
321
+ return { ...clean, stdout: JSON.stringify(rewriteDecision(decision, false, userMsg, agentMsg)), exitCode: 0, resolution: 'ide_auto' };
322
+ }
323
+ /** The asking call's identity from the envelope: well-formed parts only. */
324
+ function envelopeIdentity(envelope) {
325
+ const raw = envelope.identity;
326
+ if (!raw || typeof raw !== 'object')
327
+ return {};
328
+ const out = {};
329
+ for (const key of ['sessionId', 'runId', 'instanceId', 'attemptId']) {
330
+ const value = (0, actionIdentity_1.identityPart)(raw[key]);
331
+ if (value)
332
+ out[key] = value;
333
+ }
334
+ return out;
335
+ }
@@ -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,7 +11,7 @@
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", "FCD_HOOK_DEBUG", "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
16
  /**
17
17
  * Keys a decision reads only for WHETHER they are set (cloudGrantTools: static
@@ -60,6 +60,26 @@ export declare function ideHookTimeoutMs(env?: NodeJS.ProcessEnv): number;
60
60
  export declare function applyIdeTimeoutArg(seconds: string | undefined, env?: NodeJS.ProcessEnv): void;
61
61
  /** Absolute time every wait inside a hook must end by, leaving the margin before the IDE's timeout. */
62
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;
63
83
  /** CI / pipeline environments — no human at a keyboard. Shared by hooks and discovery. */
64
84
  export declare function isCiEnvironment(env?: NodeJS.ProcessEnv): boolean;
65
85
  /**