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.
- package/dist/askDialog.d.ts +12 -0
- package/dist/askDialog.js +46 -3
- package/dist/awakeClock.d.ts +34 -0
- package/dist/awakeClock.js +81 -0
- package/dist/backupManifest.d.ts +14 -1
- package/dist/backupManifest.js +180 -19
- package/dist/callerEnv.d.ts +49 -2
- package/dist/callerEnv.js +88 -2
- package/dist/commands/cmdGuard.d.ts +1 -0
- package/dist/commands/cmdGuard.js +14 -1
- package/dist/commands/daemon.d.ts +30 -0
- package/dist/commands/daemon.js +656 -196
- package/dist/commands/desktopChatGuard.d.ts +20 -0
- package/dist/commands/desktopChatGuard.js +62 -11
- package/dist/commands/discoverPaths.d.ts +12 -9
- package/dist/commands/discoverPaths.js +30 -0
- package/dist/commands/doctor.js +5 -1
- package/dist/commands/ephemeralStack.js +7 -0
- package/dist/commands/flushSpool.d.ts +2 -0
- package/dist/commands/flushSpool.js +9 -2
- package/dist/commands/hook.d.ts +23 -0
- package/dist/commands/hook.js +376 -111
- package/dist/commands/httpGatewayCommand.d.ts +1 -0
- package/dist/commands/httpGatewayCommand.js +7 -1
- package/dist/commands/installClaudeHook.js +32 -10
- package/dist/commands/installCursorHook.js +20 -9
- package/dist/commands/login.js +8 -1
- package/dist/commands/mcpGateway.d.ts +21 -0
- package/dist/commands/mcpGateway.js +106 -42
- package/dist/commands/posixShellGuard.js +3 -2
- package/dist/commands/refreshPolicyCache.d.ts +28 -0
- package/dist/commands/refreshPolicyCache.js +59 -0
- package/dist/commands/shellGuard.js +25 -6
- package/dist/commands/uninstallAll.js +5 -0
- package/dist/commands/watchdog.d.ts +10 -0
- package/dist/commands/watchdog.js +49 -5
- package/dist/config.d.ts +36 -0
- package/dist/config.js +78 -12
- package/dist/daemonDetectorBoot.d.ts +1 -0
- package/dist/daemonDetectorBoot.js +12 -0
- package/dist/daemonForensics.d.ts +25 -1
- package/dist/daemonForensics.js +57 -1
- package/dist/detectorInProcess.d.ts +18 -0
- package/dist/detectorInProcess.js +234 -0
- package/dist/detectorInThread.d.ts +15 -0
- package/dist/detectorInThread.js +129 -0
- package/dist/devConfirm.d.ts +13 -0
- package/dist/devConfirm.js +3 -2
- package/dist/distress.d.ts +12 -0
- package/dist/distress.js +275 -32
- package/dist/fileWriteCanon.d.ts +17 -0
- package/dist/fileWriteCanon.js +141 -0
- package/dist/gateReports.d.ts +144 -0
- package/dist/gateReports.js +503 -0
- package/dist/hookIo.d.ts +77 -0
- package/dist/hookIo.js +410 -9
- package/dist/hookSlim.d.ts +15 -8
- package/dist/hookSlim.js +199 -28
- package/dist/housekeeping.d.ts +64 -0
- package/dist/housekeeping.js +385 -0
- package/dist/housekeepingOps.d.ts +150 -0
- package/dist/housekeepingOps.js +233 -0
- package/dist/housekeepingWorker.d.ts +1 -0
- package/dist/housekeepingWorker.js +113 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +45 -0
- package/dist/localDetectionData.d.ts +10 -0
- package/dist/localDetectionData.js +48 -0
- package/dist/localDetectionUpdates.d.ts +2 -2
- package/dist/localDetectionUpdates.js +4 -4
- package/dist/localSafetySnapshot.d.ts +48 -2
- package/dist/localSafetySnapshot.js +241 -25
- package/dist/machineIdentity.d.ts +7 -0
- package/dist/machineIdentity.js +20 -2
- package/dist/monitorFastPath.d.ts +227 -0
- package/dist/monitorFastPath.js +1257 -0
- package/dist/nativeHook.d.ts +21 -0
- package/dist/nativeHook.js +93 -12
- package/dist/notify.js +51 -1
- package/dist/pendingDrain.d.ts +82 -0
- package/dist/pendingDrain.js +215 -0
- package/dist/pendingDrainer.d.ts +40 -0
- package/dist/pendingDrainer.js +267 -0
- package/dist/perfSnapshot.d.ts +41 -3
- package/dist/perfSnapshot.js +142 -32
- package/dist/policyCacheFiles.d.ts +83 -0
- package/dist/policyCacheFiles.js +395 -0
- package/dist/policyGateHealth.d.ts +8 -0
- package/dist/policyGateHealth.js +17 -0
- package/dist/policyRefresh.d.ts +28 -0
- package/dist/policyRefresh.js +69 -0
- package/dist/protectionMaintenance.d.ts +15 -0
- package/dist/protectionMaintenance.js +43 -1
- package/dist/runtimeConfig.d.ts +83 -20
- package/dist/runtimeConfig.js +292 -57
- package/dist/selfTest.d.ts +6 -1
- package/dist/selfTest.js +11 -4
- package/dist/telemetry.d.ts +201 -4
- package/dist/telemetry.js +616 -97
- package/dist/testRunGuard.d.ts +28 -0
- package/dist/testRunGuard.js +228 -0
- package/dist/verdictIpc.d.ts +60 -0
- package/dist/verdictIpc.js +655 -47
- package/dist/verdictIpcClient.d.ts +253 -19
- package/dist/verdictIpcClient.js +471 -65
- package/dist/version.json +1 -1
- package/package.json +5 -3
package/dist/askDialog.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
+
}
|
package/dist/backupManifest.d.ts
CHANGED
|
@@ -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,
|
|
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;
|
package/dist/backupManifest.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/callerEnv.d.ts
CHANGED
|
@@ -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
|
|
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
|
/**
|