fullcourtdefense-cli 1.26.3 → 1.26.5
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/commands/daemon.js +40 -31
- package/dist/commands/hook.js +36 -102
- package/dist/commands/installClaudeHook.js +17 -3
- package/dist/commands/installCursorHook.js +24 -4
- package/dist/distress.d.ts +6 -0
- package/dist/distress.js +6 -0
- package/dist/hookIo.d.ts +43 -0
- package/dist/hookIo.js +188 -0
- package/dist/hookSlim.d.ts +32 -0
- package/dist/hookSlim.js +142 -0
- package/dist/index.js +4 -14
- package/dist/pollLoop.d.ts +72 -0
- package/dist/pollLoop.js +116 -0
- package/dist/verdictIpc.d.ts +12 -75
- package/dist/verdictIpc.js +30 -160
- package/dist/verdictIpcClient.d.ts +76 -0
- package/dist/verdictIpcClient.js +183 -0
- package/dist/version.json +1 -1
- package/package.json +3 -1
package/dist/hookIo.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.HOOK_DEBUG_VERBOSE = exports.HOOK_DEBUG_LOG = void 0;
|
|
37
|
+
exports.dbg = dbg;
|
|
38
|
+
exports.readStdin = readStdin;
|
|
39
|
+
exports.mapHookFlags = mapHookFlags;
|
|
40
|
+
exports.parseHookFlags = parseHookFlags;
|
|
41
|
+
exports.slimHookEntryPath = slimHookEntryPath;
|
|
42
|
+
const fs = __importStar(require("fs"));
|
|
43
|
+
const os = __importStar(require("os"));
|
|
44
|
+
const path = __importStar(require("path"));
|
|
45
|
+
/**
|
|
46
|
+
* Hook I/O leaf — the pieces of the hook runtime that BOTH entries share:
|
|
47
|
+
*
|
|
48
|
+
* - `dist/hookSlim.js` (the slim hook entry installed into IDE hook configs)
|
|
49
|
+
* - `dist/index.js hook` (the full CLI's hook command, and the daemon's
|
|
50
|
+
* in-process evaluator)
|
|
51
|
+
*
|
|
52
|
+
* COLD-START CONTRACT: this module is on the slim entry's module graph, which
|
|
53
|
+
* is loaded on EVERY IDE hook event. It may import Node built-ins ONLY —
|
|
54
|
+
* every additional module here is bytes the customer's EDR re-scans per
|
|
55
|
+
* docker command. `scripts/test-slim-hook.js` enforces the graph budget.
|
|
56
|
+
*/
|
|
57
|
+
exports.HOOK_DEBUG_LOG = path.join(os.homedir(), '.fullcourtdefense-hook.log');
|
|
58
|
+
/**
|
|
59
|
+
* Verbose diagnostics (FCD_HOOK_DEBUG=1) additionally log content previews
|
|
60
|
+
* (raw payload + prompt text snippets). Default logging keeps operational
|
|
61
|
+
* metadata only — user prompt content must never sit in a plaintext file on
|
|
62
|
+
* a customer laptop unless support explicitly opts in.
|
|
63
|
+
*/
|
|
64
|
+
exports.HOOK_DEBUG_VERBOSE = process.env.FCD_HOOK_DEBUG === '1';
|
|
65
|
+
/** Rotate the hook log when it grows past this size (same scheme as daemon.log). */
|
|
66
|
+
const DEBUG_LOG_MAX_BYTES = 1_000_000;
|
|
67
|
+
// Checked once per (short-lived) hook process: unbounded hook.log growth was
|
|
68
|
+
// real customer disk churn — and every append to a huge file re-triggers AV
|
|
69
|
+
// scanning. One stat per process is enough for a hook run; the resident
|
|
70
|
+
// daemon (verdict IPC evaluation) re-checks every 500 writes so a long
|
|
71
|
+
// uptime can't outgrow the cap either.
|
|
72
|
+
let debugLogRotationChecked = false;
|
|
73
|
+
let debugLogWritesSinceCheck = 0;
|
|
74
|
+
/** Append a diagnostic line so we can see exactly what the IDE invoked + the verdict. */
|
|
75
|
+
function dbg(obj) {
|
|
76
|
+
try {
|
|
77
|
+
debugLogWritesSinceCheck += 1;
|
|
78
|
+
if (!debugLogRotationChecked || debugLogWritesSinceCheck >= 500) {
|
|
79
|
+
debugLogRotationChecked = true;
|
|
80
|
+
debugLogWritesSinceCheck = 0;
|
|
81
|
+
try {
|
|
82
|
+
if (fs.existsSync(exports.HOOK_DEBUG_LOG) && fs.statSync(exports.HOOK_DEBUG_LOG).size > DEBUG_LOG_MAX_BYTES) {
|
|
83
|
+
fs.renameSync(exports.HOOK_DEBUG_LOG, `${exports.HOOK_DEBUG_LOG}.1`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch { /* rotation is best-effort */ }
|
|
87
|
+
}
|
|
88
|
+
fs.appendFileSync(exports.HOOK_DEBUG_LOG, JSON.stringify({ t: new Date().toISOString(), ...obj }) + '\n');
|
|
89
|
+
}
|
|
90
|
+
catch { /* ignore */ }
|
|
91
|
+
}
|
|
92
|
+
function readStdin() {
|
|
93
|
+
return new Promise((resolve) => {
|
|
94
|
+
const stdin = process.stdin;
|
|
95
|
+
if (stdin.isTTY) {
|
|
96
|
+
resolve('');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// Read raw BYTES (no setEncoding) and decode the whole payload as UTF-8 once.
|
|
100
|
+
// Per-chunk string decoding + concat corrupts multibyte chars (e.g. Hebrew),
|
|
101
|
+
// turning them into Latin-1 mojibake before they ever reach the backend.
|
|
102
|
+
const chunks = [];
|
|
103
|
+
let settled = false;
|
|
104
|
+
const finish = () => {
|
|
105
|
+
if (settled)
|
|
106
|
+
return;
|
|
107
|
+
settled = true;
|
|
108
|
+
let buf = Buffer.concat(chunks);
|
|
109
|
+
// Strip UTF-8 BOM at the byte level (Cursor on Windows prepends EF BB BF).
|
|
110
|
+
if (buf.length >= 3 && buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) {
|
|
111
|
+
buf = buf.subarray(3);
|
|
112
|
+
}
|
|
113
|
+
resolve(buf.toString('utf8'));
|
|
114
|
+
};
|
|
115
|
+
stdin.on('data', (chunk) => {
|
|
116
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
117
|
+
});
|
|
118
|
+
stdin.on('end', finish);
|
|
119
|
+
stdin.on('error', finish);
|
|
120
|
+
// Safety: if nothing arrives, don't hang forever.
|
|
121
|
+
setTimeout(finish, 2000).unref?.();
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* kebab-case CLI flags -> the camelCase HookArgs shape. ONE mapping shared by
|
|
126
|
+
* the full CLI entry (`index.ts` case 'hook') and the slim entry, so the
|
|
127
|
+
* daemon's IPC evaluator always receives identical argument shapes no matter
|
|
128
|
+
* which entry the IDE invoked.
|
|
129
|
+
*/
|
|
130
|
+
function mapHookFlags(flags) {
|
|
131
|
+
return {
|
|
132
|
+
event: flags.event,
|
|
133
|
+
apiUrl: flags['api-url'],
|
|
134
|
+
shieldId: flags['shield-id'],
|
|
135
|
+
shieldKey: flags['shield-key'],
|
|
136
|
+
shadow: flags.shadow,
|
|
137
|
+
enforce: flags.enforce,
|
|
138
|
+
failClosed: flags['fail-closed'],
|
|
139
|
+
localOnly: flags['local-only'],
|
|
140
|
+
timeout: flags.timeout,
|
|
141
|
+
approvalMode: flags['approval-mode'],
|
|
142
|
+
approvalTimeoutMs: flags['approval-timeout-ms'],
|
|
143
|
+
approvalPollMs: flags['approval-poll-ms'],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Minimal `--flag value` / `--flag=value` parser with the same semantics as
|
|
148
|
+
* the full CLI's parseArgs, restricted to what a hook command line carries.
|
|
149
|
+
* Positionals (the literal `hook` subcommand kept in installed commands for
|
|
150
|
+
* the managed-entry repair marker) are ignored.
|
|
151
|
+
*/
|
|
152
|
+
function parseHookFlags(argv) {
|
|
153
|
+
const flags = {};
|
|
154
|
+
for (let i = 0; i < argv.length; i++) {
|
|
155
|
+
const arg = argv[i];
|
|
156
|
+
if (!arg.startsWith('-'))
|
|
157
|
+
continue;
|
|
158
|
+
const key = arg.startsWith('--') ? arg.slice(2) : arg.slice(1);
|
|
159
|
+
const eqIdx = key.indexOf('=');
|
|
160
|
+
if (eqIdx !== -1) {
|
|
161
|
+
flags[key.slice(0, eqIdx)] = key.slice(eqIdx + 1);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const next = argv[i + 1];
|
|
165
|
+
if (next && !next.startsWith('-')) {
|
|
166
|
+
flags[key] = next;
|
|
167
|
+
i++;
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
flags[key] = 'true';
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return flags;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Absolute path of the slim hook entry that sits next to a given CLI script
|
|
177
|
+
* (`dist/index.js` -> `dist/hookSlim.js`). Installers prefer the slim entry
|
|
178
|
+
* when it exists; older installs (pre-slim dist) keep pointing at index.js.
|
|
179
|
+
*/
|
|
180
|
+
function slimHookEntryPath(cliScriptPath) {
|
|
181
|
+
try {
|
|
182
|
+
const candidate = path.join(path.dirname(cliScriptPath), 'hookSlim.js');
|
|
183
|
+
return fs.existsSync(candidate) ? candidate : undefined;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SLIM HOOK ENTRY — what the IDE actually spawns on every hook event.
|
|
4
|
+
*
|
|
5
|
+
* The per-event tax on a hook used to be loading the ENTIRE CLI (dist/index.js
|
|
6
|
+
* and its transitive graph) just to ask the resident daemon one question over
|
|
7
|
+
* the verdict pipe. On a normal laptop that is tens of milliseconds; on a
|
|
8
|
+
* locked-down corporate machine whose EDR re-scans every loaded file, it is
|
|
9
|
+
* the 1-5s "sticky" pause a docker-heavy customer feels dozens of times an
|
|
10
|
+
* hour. This entry exists to make the common path (daemon up, IPC verdict)
|
|
11
|
+
* load almost nothing:
|
|
12
|
+
*
|
|
13
|
+
* node boot -> hookSlim.js -> hookIo.js + verdictIpcClient.js (3 small files,
|
|
14
|
+
* Node built-ins only) -> named-pipe verdict -> print -> exit.
|
|
15
|
+
*
|
|
16
|
+
* FALLBACK PARITY: when the daemon is unreachable, this entry lazily requires
|
|
17
|
+
* the full hook evaluation (`commands/hook.evaluateHookRequest`) — literally
|
|
18
|
+
* the same code the full CLI runs — so behavior with the daemon down is
|
|
19
|
+
* byte-identical to `index.js hook`. The slim win applies exactly where it
|
|
20
|
+
* matters: the warm, common case.
|
|
21
|
+
*
|
|
22
|
+
* FAIL-OPEN CONTRACT (same as the full hook): any unexpected error must never
|
|
23
|
+
* surface as a stack trace, a nonzero exit, or a blocked command. The ONE
|
|
24
|
+
* exception is the admin kill-switch — a crash on a suspended machine still
|
|
25
|
+
* denies, so a broken install can never unlock a machine an admin suspended.
|
|
26
|
+
*
|
|
27
|
+
* COLD-START CONTRACT: static imports here are restricted to the two leaves
|
|
28
|
+
* below (Node built-ins only). Everything else loads lazily, and ONLY on the
|
|
29
|
+
* fallback path. `scripts/test-slim-hook.js` fails CI if the warm-path module
|
|
30
|
+
* graph grows.
|
|
31
|
+
*/
|
|
32
|
+
export {};
|
package/dist/hookSlim.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* SLIM HOOK ENTRY — what the IDE actually spawns on every hook event.
|
|
5
|
+
*
|
|
6
|
+
* The per-event tax on a hook used to be loading the ENTIRE CLI (dist/index.js
|
|
7
|
+
* and its transitive graph) just to ask the resident daemon one question over
|
|
8
|
+
* the verdict pipe. On a normal laptop that is tens of milliseconds; on a
|
|
9
|
+
* locked-down corporate machine whose EDR re-scans every loaded file, it is
|
|
10
|
+
* the 1-5s "sticky" pause a docker-heavy customer feels dozens of times an
|
|
11
|
+
* hour. This entry exists to make the common path (daemon up, IPC verdict)
|
|
12
|
+
* load almost nothing:
|
|
13
|
+
*
|
|
14
|
+
* node boot -> hookSlim.js -> hookIo.js + verdictIpcClient.js (3 small files,
|
|
15
|
+
* Node built-ins only) -> named-pipe verdict -> print -> exit.
|
|
16
|
+
*
|
|
17
|
+
* FALLBACK PARITY: when the daemon is unreachable, this entry lazily requires
|
|
18
|
+
* the full hook evaluation (`commands/hook.evaluateHookRequest`) — literally
|
|
19
|
+
* the same code the full CLI runs — so behavior with the daemon down is
|
|
20
|
+
* byte-identical to `index.js hook`. The slim win applies exactly where it
|
|
21
|
+
* matters: the warm, common case.
|
|
22
|
+
*
|
|
23
|
+
* FAIL-OPEN CONTRACT (same as the full hook): any unexpected error must never
|
|
24
|
+
* surface as a stack trace, a nonzero exit, or a blocked command. The ONE
|
|
25
|
+
* exception is the admin kill-switch — a crash on a suspended machine still
|
|
26
|
+
* denies, so a broken install can never unlock a machine an admin suspended.
|
|
27
|
+
*
|
|
28
|
+
* COLD-START CONTRACT: static imports here are restricted to the two leaves
|
|
29
|
+
* below (Node built-ins only). Everything else loads lazily, and ONLY on the
|
|
30
|
+
* fallback path. `scripts/test-slim-hook.js` fails CI if the warm-path module
|
|
31
|
+
* graph grows.
|
|
32
|
+
*/
|
|
33
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
34
|
+
const hookIo_1 = require("./hookIo");
|
|
35
|
+
const verdictIpcClient_1 = require("./verdictIpcClient");
|
|
36
|
+
function emitFailOpen(event, message) {
|
|
37
|
+
// Mirror of the full hook's failOpenOutcome, reachable only when even the
|
|
38
|
+
// lazy fallback could not load (corrupt install). Best effort all the way
|
|
39
|
+
// down; the kill-switch check and spool are each allowed to fail alone.
|
|
40
|
+
let suspended = false;
|
|
41
|
+
try {
|
|
42
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
43
|
+
suspended = require('./runtimeConfig').isSuspendedByCache();
|
|
44
|
+
}
|
|
45
|
+
catch { /* runtime cache unreadable — stay fail-open */ }
|
|
46
|
+
try {
|
|
47
|
+
(0, hookIo_1.dbg)({ phase: 'slim_fail_open', event, error: message.slice(0, 300), suspended });
|
|
48
|
+
}
|
|
49
|
+
catch { /* never throw */ }
|
|
50
|
+
try {
|
|
51
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
52
|
+
const telemetry = require('./telemetry');
|
|
53
|
+
telemetry.setVerdictPath('fail_open');
|
|
54
|
+
telemetry.spoolEvent({
|
|
55
|
+
decision: suspended ? 'block' : 'allow',
|
|
56
|
+
toolName: event || 'unknown',
|
|
57
|
+
operation: `${event || 'unknown'}:hook_error`,
|
|
58
|
+
reason: suspended
|
|
59
|
+
? `Hook error on a SUSPENDED machine — denied (kill-switch holds): ${message.slice(0, 200)}`
|
|
60
|
+
: `Hook error failed open: ${message.slice(0, 200)}`,
|
|
61
|
+
offlineEnforced: true,
|
|
62
|
+
...(suspended ? { severity: 'critical', source: 'backend' } : {}),
|
|
63
|
+
});
|
|
64
|
+
telemetry.triggerFlush(true);
|
|
65
|
+
}
|
|
66
|
+
catch { /* telemetry unavailable — the verdict below still stands */ }
|
|
67
|
+
const verdict = suspended
|
|
68
|
+
? {
|
|
69
|
+
continue: false,
|
|
70
|
+
permission: 'deny',
|
|
71
|
+
user_message: 'This machine is suspended by your security team — all AI agent actions are blocked. Contact your admin to resume it.',
|
|
72
|
+
agent_message: 'FullCourtDefense: this machine is suspended by an administrator. Stop — do not retry any action until it is resumed.',
|
|
73
|
+
}
|
|
74
|
+
: { continue: true, permission: 'allow' };
|
|
75
|
+
try {
|
|
76
|
+
process.stdout.write(JSON.stringify(verdict) + '\n');
|
|
77
|
+
}
|
|
78
|
+
catch { /* stdout gone */ }
|
|
79
|
+
process.exit(0);
|
|
80
|
+
}
|
|
81
|
+
async function main() {
|
|
82
|
+
const startedAt = Date.now();
|
|
83
|
+
const flags = (0, hookIo_1.parseHookFlags)(process.argv.slice(2));
|
|
84
|
+
const args = (0, hookIo_1.mapHookFlags)(flags);
|
|
85
|
+
try {
|
|
86
|
+
const raw = await (0, hookIo_1.readStdin)();
|
|
87
|
+
// FAST PATH: the resident daemon computes the verdict with everything warm.
|
|
88
|
+
// Bounded hard (connect ~250ms, response ~2.5s) — any non-verdict outcome
|
|
89
|
+
// falls through to the full evaluation below. FCD_VERDICT_IPC=off opts out.
|
|
90
|
+
if (process.env.FCD_VERDICT_IPC !== 'off') {
|
|
91
|
+
const ipc = await (0, verdictIpcClient_1.requestDaemonVerdict)(args, raw, undefined, startedAt);
|
|
92
|
+
if (ipc.outcome === 'verdict') {
|
|
93
|
+
(0, hookIo_1.dbg)({ phase: 'ipc_verdict', event: args.event, exitCode: ipc.exitCode, entry: 'slim' });
|
|
94
|
+
if (ipc.stderr) {
|
|
95
|
+
try {
|
|
96
|
+
process.stderr.write(ipc.stderr);
|
|
97
|
+
}
|
|
98
|
+
catch { /* stderr gone */ }
|
|
99
|
+
}
|
|
100
|
+
if (ipc.stdout) {
|
|
101
|
+
try {
|
|
102
|
+
process.stdout.write(ipc.stdout);
|
|
103
|
+
}
|
|
104
|
+
catch { /* stdout gone */ }
|
|
105
|
+
}
|
|
106
|
+
process.exit(ipc.exitCode ?? 0);
|
|
107
|
+
}
|
|
108
|
+
(0, hookIo_1.dbg)({ phase: 'ipc_unavailable', event: args.event, detail: ipc.detail, entry: 'slim' });
|
|
109
|
+
}
|
|
110
|
+
// FALLBACK: daemon down/busy — load the full evaluation lazily. This is
|
|
111
|
+
// the exact code `index.js hook` runs (including its own fail-open and
|
|
112
|
+
// kill-switch handling), so daemon-down behavior is identical to the
|
|
113
|
+
// pre-slim hook. evaluateHookRequest never rejects by contract.
|
|
114
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
115
|
+
const { evaluateHookRequest } = require('./commands/hook');
|
|
116
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
117
|
+
const { loadConfig } = require('./config');
|
|
118
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
119
|
+
const { markVerdictTiming } = require('./telemetry');
|
|
120
|
+
const result = await markVerdictTiming({ startedAt, path: 'local' }, () => evaluateHookRequest(args, raw, loadConfig()));
|
|
121
|
+
if (result.stderr) {
|
|
122
|
+
try {
|
|
123
|
+
process.stderr.write(result.stderr);
|
|
124
|
+
}
|
|
125
|
+
catch { /* stderr gone */ }
|
|
126
|
+
}
|
|
127
|
+
if (result.stdout) {
|
|
128
|
+
try {
|
|
129
|
+
process.stdout.write(result.stdout);
|
|
130
|
+
}
|
|
131
|
+
catch { /* stdout gone */ }
|
|
132
|
+
}
|
|
133
|
+
process.exit(result.exitCode);
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
emitFailOpen(args.event, err instanceof Error ? err.message : String(err));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Main-guarded so importing this module (tests, tooling) never runs the hook.
|
|
140
|
+
if (require.main === module) {
|
|
141
|
+
void main();
|
|
142
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -44,6 +44,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
44
44
|
// touching this file.
|
|
45
45
|
const config_1 = require("./config");
|
|
46
46
|
const hook_1 = require("./commands/hook");
|
|
47
|
+
const hookIo_1 = require("./hookIo");
|
|
47
48
|
const fs = __importStar(require("fs"));
|
|
48
49
|
const path = __importStar(require("path"));
|
|
49
50
|
function readCliVersion() {
|
|
@@ -679,20 +680,9 @@ async function main() {
|
|
|
679
680
|
break;
|
|
680
681
|
}
|
|
681
682
|
case 'hook': {
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
shieldId: flags['shield-id'],
|
|
686
|
-
shieldKey: flags['shield-key'],
|
|
687
|
-
shadow: flags.shadow,
|
|
688
|
-
enforce: flags.enforce,
|
|
689
|
-
failClosed: flags['fail-closed'],
|
|
690
|
-
localOnly: flags['local-only'],
|
|
691
|
-
timeout: flags.timeout,
|
|
692
|
-
approvalMode: flags['approval-mode'],
|
|
693
|
-
approvalTimeoutMs: flags['approval-timeout-ms'],
|
|
694
|
-
approvalPollMs: flags['approval-poll-ms'],
|
|
695
|
-
};
|
|
683
|
+
// ONE flag mapping shared with the slim entry (hookSlim.ts) — the daemon
|
|
684
|
+
// IPC evaluator must see identical argument shapes from both entries.
|
|
685
|
+
const args = (0, hookIo_1.mapHookFlags)(flags);
|
|
696
686
|
await (0, hook_1.hookCommand)(args, config);
|
|
697
687
|
break;
|
|
698
688
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-healing poll scheduler for the resident daemon.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS (a real production incident, 2026-08-22)
|
|
5
|
+
* The daemon's loops schedule the next tick only AFTER the current attempt
|
|
6
|
+
* settles — deliberate, so a slow control plane can never stack overlapping
|
|
7
|
+
* polls on a developer's laptop. The hidden cost: an attempt that NEVER
|
|
8
|
+
* settles kills the loop for the lifetime of the process.
|
|
9
|
+
*
|
|
10
|
+
* That is not theoretical. `fetch` can hang indefinitely despite its
|
|
11
|
+
* `AbortSignal.timeout` when the underlying socket dies without a FIN —
|
|
12
|
+
* laptop sleep, VPN/Wi-Fi change, or a corporate proxy silently dropping an
|
|
13
|
+
* idle connection. On the incident machine the bundle poll hung at 15:59 and
|
|
14
|
+
* never ran again, while heartbeats, hooks, and the watchdog all kept working
|
|
15
|
+
* for three hours. The machine therefore looked perfectly healthy in the
|
|
16
|
+
* console — but the bundle poll is the ONLY delivery path for remote actions,
|
|
17
|
+
* so every admin fix (upgrade, repair protection, collect diagnostics)
|
|
18
|
+
* silently expired unacknowledged. The worst possible failure mode: a machine
|
|
19
|
+
* we cannot reach that reports itself fine.
|
|
20
|
+
*
|
|
21
|
+
* The fix is a hard per-attempt deadline. When it expires we abandon the
|
|
22
|
+
* attempt (logging + distress so the fleet SEES it) and schedule the next
|
|
23
|
+
* tick anyway. The orphaned promise gets a permanent catch so a late
|
|
24
|
+
* rejection can never surface as an unhandled rejection and take the daemon
|
|
25
|
+
* down with it.
|
|
26
|
+
*
|
|
27
|
+
* Pure and injectable (timers + clock) so the "loop survives a poll that
|
|
28
|
+
* never settles" property is unit-tested without waiting on real minutes.
|
|
29
|
+
*/
|
|
30
|
+
export interface PollLoopHandle {
|
|
31
|
+
stop(): void;
|
|
32
|
+
/** Attempts abandoned at the deadline — exposed for tests/diagnostics. */
|
|
33
|
+
stalledCount(): number;
|
|
34
|
+
}
|
|
35
|
+
export interface PollLoopInput {
|
|
36
|
+
/** One attempt. May reject, and may never settle — both are survivable. */
|
|
37
|
+
run: () => Promise<unknown>;
|
|
38
|
+
/**
|
|
39
|
+
* Base cadence, read fresh before every tick so the server can retune a
|
|
40
|
+
* whole fleet (bundle `pollIntervalMs`) without a new CLI.
|
|
41
|
+
*/
|
|
42
|
+
intervalMs: () => number;
|
|
43
|
+
/**
|
|
44
|
+
* Hard ceiling for a single attempt. Keep it below the interval so an
|
|
45
|
+
* abandoned attempt cannot overlap the next one for long.
|
|
46
|
+
*/
|
|
47
|
+
deadlineMs: number;
|
|
48
|
+
/** Called once per abandoned attempt (log + distress at the call site). */
|
|
49
|
+
onStalled?: (deadlineMs: number) => void;
|
|
50
|
+
/** Called when an attempt rejects. Never rethrow from here. */
|
|
51
|
+
onError?: (error: unknown) => void;
|
|
52
|
+
/** Cadence jitter fraction (0..1, default 0.2) — a proxied fleet must not poll in lockstep. */
|
|
53
|
+
jitter?: number;
|
|
54
|
+
/** Injectable timers for tests. */
|
|
55
|
+
timers?: {
|
|
56
|
+
setTimeout: (fn: () => void, ms: number) => unknown;
|
|
57
|
+
clearTimeout: (handle: unknown) => void;
|
|
58
|
+
};
|
|
59
|
+
/** Injectable randomness for deterministic tests. */
|
|
60
|
+
random?: () => number;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Await `run()` but never longer than `deadlineMs`. Resolves 'settled' when the
|
|
64
|
+
* attempt finished (fulfilled or rejected) and 'abandoned' when the deadline
|
|
65
|
+
* won the race.
|
|
66
|
+
*/
|
|
67
|
+
export declare function runWithDeadline(input: Pick<PollLoopInput, 'run' | 'deadlineMs' | 'onError' | 'timers'>): Promise<'settled' | 'abandoned'>;
|
|
68
|
+
/**
|
|
69
|
+
* Start a loop that runs `run()` forever on a jittered cadence and CANNOT be
|
|
70
|
+
* killed by an attempt that hangs or throws.
|
|
71
|
+
*/
|
|
72
|
+
export declare function startPollLoop(input: PollLoopInput): PollLoopHandle;
|
package/dist/pollLoop.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Self-healing poll scheduler for the resident daemon.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS (a real production incident, 2026-08-22)
|
|
6
|
+
* The daemon's loops schedule the next tick only AFTER the current attempt
|
|
7
|
+
* settles — deliberate, so a slow control plane can never stack overlapping
|
|
8
|
+
* polls on a developer's laptop. The hidden cost: an attempt that NEVER
|
|
9
|
+
* settles kills the loop for the lifetime of the process.
|
|
10
|
+
*
|
|
11
|
+
* That is not theoretical. `fetch` can hang indefinitely despite its
|
|
12
|
+
* `AbortSignal.timeout` when the underlying socket dies without a FIN —
|
|
13
|
+
* laptop sleep, VPN/Wi-Fi change, or a corporate proxy silently dropping an
|
|
14
|
+
* idle connection. On the incident machine the bundle poll hung at 15:59 and
|
|
15
|
+
* never ran again, while heartbeats, hooks, and the watchdog all kept working
|
|
16
|
+
* for three hours. The machine therefore looked perfectly healthy in the
|
|
17
|
+
* console — but the bundle poll is the ONLY delivery path for remote actions,
|
|
18
|
+
* so every admin fix (upgrade, repair protection, collect diagnostics)
|
|
19
|
+
* silently expired unacknowledged. The worst possible failure mode: a machine
|
|
20
|
+
* we cannot reach that reports itself fine.
|
|
21
|
+
*
|
|
22
|
+
* The fix is a hard per-attempt deadline. When it expires we abandon the
|
|
23
|
+
* attempt (logging + distress so the fleet SEES it) and schedule the next
|
|
24
|
+
* tick anyway. The orphaned promise gets a permanent catch so a late
|
|
25
|
+
* rejection can never surface as an unhandled rejection and take the daemon
|
|
26
|
+
* down with it.
|
|
27
|
+
*
|
|
28
|
+
* Pure and injectable (timers + clock) so the "loop survives a poll that
|
|
29
|
+
* never settles" property is unit-tested without waiting on real minutes.
|
|
30
|
+
*/
|
|
31
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
32
|
+
exports.runWithDeadline = runWithDeadline;
|
|
33
|
+
exports.startPollLoop = startPollLoop;
|
|
34
|
+
const MIN_DELAY_MS = 5_000;
|
|
35
|
+
/**
|
|
36
|
+
* Await `run()` but never longer than `deadlineMs`. Resolves 'settled' when the
|
|
37
|
+
* attempt finished (fulfilled or rejected) and 'abandoned' when the deadline
|
|
38
|
+
* won the race.
|
|
39
|
+
*/
|
|
40
|
+
async function runWithDeadline(input) {
|
|
41
|
+
const timers = input.timers || { setTimeout: (fn, ms) => setTimeout(fn, ms), clearTimeout: handle => clearTimeout(handle) };
|
|
42
|
+
// The catch is attached NOW and stays attached: if this attempt is abandoned
|
|
43
|
+
// and rejects minutes later, the rejection is already handled.
|
|
44
|
+
let attemptSettled = false;
|
|
45
|
+
const attempt = (async () => {
|
|
46
|
+
try {
|
|
47
|
+
await input.run();
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
try {
|
|
51
|
+
input.onError?.(error);
|
|
52
|
+
}
|
|
53
|
+
catch { /* a reporter must never break the loop */ }
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
attemptSettled = true;
|
|
57
|
+
}
|
|
58
|
+
})();
|
|
59
|
+
let deadlineHandle;
|
|
60
|
+
const deadline = new Promise(resolve => {
|
|
61
|
+
deadlineHandle = timers.setTimeout(() => resolve(), input.deadlineMs);
|
|
62
|
+
});
|
|
63
|
+
await Promise.race([attempt, deadline]);
|
|
64
|
+
timers.clearTimeout(deadlineHandle);
|
|
65
|
+
return attemptSettled ? 'settled' : 'abandoned';
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Start a loop that runs `run()` forever on a jittered cadence and CANNOT be
|
|
69
|
+
* killed by an attempt that hangs or throws.
|
|
70
|
+
*/
|
|
71
|
+
function startPollLoop(input) {
|
|
72
|
+
const timers = input.timers || { setTimeout: (fn, ms) => setTimeout(fn, ms), clearTimeout: handle => clearTimeout(handle) };
|
|
73
|
+
const random = input.random || Math.random;
|
|
74
|
+
const jitter = Math.min(Math.max(input.jitter ?? 0.2, 0), 0.9);
|
|
75
|
+
let stopped = false;
|
|
76
|
+
let stalled = 0;
|
|
77
|
+
let tickHandle;
|
|
78
|
+
const nextDelay = () => {
|
|
79
|
+
const base = input.intervalMs();
|
|
80
|
+
const safeBase = Number.isFinite(base) && base > 0 ? base : MIN_DELAY_MS;
|
|
81
|
+
const factor = 1 - jitter + random() * jitter * 2;
|
|
82
|
+
return Math.max(MIN_DELAY_MS, Math.round(safeBase * factor));
|
|
83
|
+
};
|
|
84
|
+
const schedule = () => {
|
|
85
|
+
if (stopped)
|
|
86
|
+
return;
|
|
87
|
+
tickHandle = timers.setTimeout(async () => {
|
|
88
|
+
if (stopped)
|
|
89
|
+
return;
|
|
90
|
+
const outcome = await runWithDeadline({
|
|
91
|
+
run: input.run,
|
|
92
|
+
deadlineMs: input.deadlineMs,
|
|
93
|
+
onError: input.onError,
|
|
94
|
+
timers,
|
|
95
|
+
});
|
|
96
|
+
if (outcome === 'abandoned') {
|
|
97
|
+
stalled += 1;
|
|
98
|
+
try {
|
|
99
|
+
input.onStalled?.(input.deadlineMs);
|
|
100
|
+
}
|
|
101
|
+
catch { /* never break the loop */ }
|
|
102
|
+
}
|
|
103
|
+
schedule();
|
|
104
|
+
}, nextDelay());
|
|
105
|
+
};
|
|
106
|
+
schedule();
|
|
107
|
+
return {
|
|
108
|
+
stop() {
|
|
109
|
+
stopped = true;
|
|
110
|
+
timers.clearTimeout(tickHandle);
|
|
111
|
+
},
|
|
112
|
+
stalledCount() {
|
|
113
|
+
return stalled;
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|