fullcourtdefense-cli 1.26.4 → 1.26.6
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.d.ts +14 -0
- package/dist/commands/daemon.js +181 -28
- package/dist/commands/desktopChatGuard.js +5 -4
- package/dist/commands/deterministicGuard.d.ts +8 -0
- package/dist/commands/deterministicGuard.js +6 -0
- package/dist/commands/doctor.d.ts +4 -0
- package/dist/commands/doctor.js +99 -0
- package/dist/commands/hook.d.ts +1 -0
- package/dist/commands/hook.js +61 -104
- package/dist/commands/installClaudeHook.d.ts +2 -0
- package/dist/commands/installClaudeHook.js +21 -3
- package/dist/commands/installCursorHook.d.ts +8 -0
- package/dist/commands/installCursorHook.js +29 -4
- package/dist/commands/mcpGateway.js +234 -43
- package/dist/commands/onboard.d.ts +9 -0
- package/dist/commands/onboard.js +7 -0
- package/dist/commands/watchdog.d.ts +5 -2
- package/dist/commands/watchdog.js +34 -4
- package/dist/config.js +6 -1
- package/dist/daemonForensics.d.ts +27 -0
- package/dist/daemonForensics.js +27 -0
- package/dist/distress.d.ts +17 -0
- package/dist/distress.js +17 -0
- package/dist/hookIo.d.ts +43 -0
- package/dist/hookIo.js +189 -0
- package/dist/hookSlim.d.ts +32 -0
- package/dist/hookSlim.js +142 -0
- package/dist/index.js +10 -14
- package/dist/integrity.js +8 -0
- package/dist/localSafetySnapshot.d.ts +9 -0
- package/dist/localSafetySnapshot.js +58 -2
- package/dist/machineIdentity.js +70 -4
- package/dist/runtimeConfig.d.ts +2 -0
- package/dist/runtimeConfig.js +177 -5
- package/dist/selfTest.d.ts +10 -0
- package/dist/selfTest.js +19 -8
- package/dist/selfUpdate.d.ts +19 -4
- package/dist/selfUpdate.js +108 -12
- package/dist/telemetry.js +67 -2
- package/dist/verdictIpc.d.ts +12 -75
- package/dist/verdictIpc.js +104 -188
- package/dist/verdictIpcClient.d.ts +103 -0
- package/dist/verdictIpcClient.js +251 -0
- package/dist/version.json +1 -1
- package/package.json +6 -1
package/dist/distress.js
CHANGED
|
@@ -99,6 +99,23 @@ exports.DISTRESS = {
|
|
|
99
99
|
* next tick — but while it lasted, remote actions were undeliverable.
|
|
100
100
|
*/
|
|
101
101
|
POLL_STALLED: 'poll_stalled',
|
|
102
|
+
/**
|
|
103
|
+
* The watchdog found the daemon process alive but WEDGED — alive-marker
|
|
104
|
+
* stale past the bound and the verdict pipe silent — and recycled it.
|
|
105
|
+
*/
|
|
106
|
+
DAEMON_HUNG: 'daemon_hung',
|
|
107
|
+
/**
|
|
108
|
+
* The daemon exited itself after repeated uncaught exceptions in a short
|
|
109
|
+
* window (process state suspect); the supervisor restarts it clean.
|
|
110
|
+
*/
|
|
111
|
+
UNCAUGHT_BURST: 'uncaught_burst',
|
|
112
|
+
/**
|
|
113
|
+
* The telemetry spool could not be written (disk full, ACL, AV lock) — the
|
|
114
|
+
* machine is still enforcing, but its decisions are being LOST locally and
|
|
115
|
+
* will never reach the console. Deserves an admin's attention because a
|
|
116
|
+
* quiet machine and a machine that cannot record look identical otherwise.
|
|
117
|
+
*/
|
|
118
|
+
SPOOL_WRITE_FAILED: 'spool_write_failed',
|
|
102
119
|
/** Anything nobody predicted — reported via reportUnexpected(). */
|
|
103
120
|
UNEXPECTED: 'unexpected_error',
|
|
104
121
|
};
|
package/dist/hookIo.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook I/O leaf — the pieces of the hook runtime that BOTH entries share:
|
|
3
|
+
*
|
|
4
|
+
* - `dist/hookSlim.js` (the slim hook entry installed into IDE hook configs)
|
|
5
|
+
* - `dist/index.js hook` (the full CLI's hook command, and the daemon's
|
|
6
|
+
* in-process evaluator)
|
|
7
|
+
*
|
|
8
|
+
* COLD-START CONTRACT: this module is on the slim entry's module graph, which
|
|
9
|
+
* is loaded on EVERY IDE hook event. It may import Node built-ins ONLY —
|
|
10
|
+
* every additional module here is bytes the customer's EDR re-scans per
|
|
11
|
+
* docker command. `scripts/test-slim-hook.js` enforces the graph budget.
|
|
12
|
+
*/
|
|
13
|
+
export declare const HOOK_DEBUG_LOG: string;
|
|
14
|
+
/**
|
|
15
|
+
* Verbose diagnostics (FCD_HOOK_DEBUG=1) additionally log content previews
|
|
16
|
+
* (raw payload + prompt text snippets). Default logging keeps operational
|
|
17
|
+
* metadata only — user prompt content must never sit in a plaintext file on
|
|
18
|
+
* a customer laptop unless support explicitly opts in.
|
|
19
|
+
*/
|
|
20
|
+
export declare const HOOK_DEBUG_VERBOSE: boolean;
|
|
21
|
+
/** Append a diagnostic line so we can see exactly what the IDE invoked + the verdict. */
|
|
22
|
+
export declare function dbg(obj: Record<string, unknown>): void;
|
|
23
|
+
export declare function readStdin(): Promise<string>;
|
|
24
|
+
/**
|
|
25
|
+
* kebab-case CLI flags -> the camelCase HookArgs shape. ONE mapping shared by
|
|
26
|
+
* the full CLI entry (`index.ts` case 'hook') and the slim entry, so the
|
|
27
|
+
* daemon's IPC evaluator always receives identical argument shapes no matter
|
|
28
|
+
* which entry the IDE invoked.
|
|
29
|
+
*/
|
|
30
|
+
export declare function mapHookFlags(flags: Record<string, string>): Record<string, string | undefined>;
|
|
31
|
+
/**
|
|
32
|
+
* Minimal `--flag value` / `--flag=value` parser with the same semantics as
|
|
33
|
+
* the full CLI's parseArgs, restricted to what a hook command line carries.
|
|
34
|
+
* Positionals (the literal `hook` subcommand kept in installed commands for
|
|
35
|
+
* the managed-entry repair marker) are ignored.
|
|
36
|
+
*/
|
|
37
|
+
export declare function parseHookFlags(argv: string[]): Record<string, string>;
|
|
38
|
+
/**
|
|
39
|
+
* Absolute path of the slim hook entry that sits next to a given CLI script
|
|
40
|
+
* (`dist/index.js` -> `dist/hookSlim.js`). Installers prefer the slim entry
|
|
41
|
+
* when it exists; older installs (pre-slim dist) keep pointing at index.js.
|
|
42
|
+
*/
|
|
43
|
+
export declare function slimHookEntryPath(cliScriptPath: string): string | undefined;
|
package/dist/hookIo.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
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
|
+
fcdManaged: flags['fcd-managed'],
|
|
139
|
+
failClosed: flags['fail-closed'],
|
|
140
|
+
localOnly: flags['local-only'],
|
|
141
|
+
timeout: flags.timeout,
|
|
142
|
+
approvalMode: flags['approval-mode'],
|
|
143
|
+
approvalTimeoutMs: flags['approval-timeout-ms'],
|
|
144
|
+
approvalPollMs: flags['approval-poll-ms'],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Minimal `--flag value` / `--flag=value` parser with the same semantics as
|
|
149
|
+
* the full CLI's parseArgs, restricted to what a hook command line carries.
|
|
150
|
+
* Positionals (the literal `hook` subcommand kept in installed commands for
|
|
151
|
+
* the managed-entry repair marker) are ignored.
|
|
152
|
+
*/
|
|
153
|
+
function parseHookFlags(argv) {
|
|
154
|
+
const flags = {};
|
|
155
|
+
for (let i = 0; i < argv.length; i++) {
|
|
156
|
+
const arg = argv[i];
|
|
157
|
+
if (!arg.startsWith('-'))
|
|
158
|
+
continue;
|
|
159
|
+
const key = arg.startsWith('--') ? arg.slice(2) : arg.slice(1);
|
|
160
|
+
const eqIdx = key.indexOf('=');
|
|
161
|
+
if (eqIdx !== -1) {
|
|
162
|
+
flags[key.slice(0, eqIdx)] = key.slice(eqIdx + 1);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const next = argv[i + 1];
|
|
166
|
+
if (next && !next.startsWith('-')) {
|
|
167
|
+
flags[key] = next;
|
|
168
|
+
i++;
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
flags[key] = 'true';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return flags;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Absolute path of the slim hook entry that sits next to a given CLI script
|
|
178
|
+
* (`dist/index.js` -> `dist/hookSlim.js`). Installers prefer the slim entry
|
|
179
|
+
* when it exists; older installs (pre-slim dist) keep pointing at index.js.
|
|
180
|
+
*/
|
|
181
|
+
function slimHookEntryPath(cliScriptPath) {
|
|
182
|
+
try {
|
|
183
|
+
const candidate = path.join(path.dirname(cliScriptPath), 'hookSlim.js');
|
|
184
|
+
return fs.existsSync(candidate) ? candidate : undefined;
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -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() {
|
|
@@ -137,6 +138,9 @@ function printHelp() {
|
|
|
137
138
|
doctor First step. Checks outbound HTTPS access to FullCourtDefense.
|
|
138
139
|
--perf measures this machine's real per-event overhead
|
|
139
140
|
(hook latency, spawn floor, cmd-guard cost, disk footprint).
|
|
141
|
+
--health prints an mdatp-health-style per-subsystem status
|
|
142
|
+
table (enrollment, credentials, hooks, updater, tasks).
|
|
143
|
+
--edr prints the narrowest AV/EDR allowlist guidance for IT.
|
|
140
144
|
login Enrolls this machine with a fleet enrollment token and saves
|
|
141
145
|
per-machine Shield credentials to ~/.fullcourtdefense.yml.
|
|
142
146
|
configure Legacy/manual setup: saves org API key, Organization ID,
|
|
@@ -518,6 +522,8 @@ async function main() {
|
|
|
518
522
|
const args = {
|
|
519
523
|
apiUrl: flags['api-url'],
|
|
520
524
|
perf: flags.perf,
|
|
525
|
+
health: flags.health,
|
|
526
|
+
edr: flags.edr,
|
|
521
527
|
};
|
|
522
528
|
const { doctorCommand } = await Promise.resolve().then(() => __importStar(require('./commands/doctor')));
|
|
523
529
|
await doctorCommand(args, config);
|
|
@@ -679,20 +685,9 @@ async function main() {
|
|
|
679
685
|
break;
|
|
680
686
|
}
|
|
681
687
|
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
|
-
};
|
|
688
|
+
// ONE flag mapping shared with the slim entry (hookSlim.ts) — the daemon
|
|
689
|
+
// IPC evaluator must see identical argument shapes from both entries.
|
|
690
|
+
const args = (0, hookIo_1.mapHookFlags)(flags);
|
|
696
691
|
await (0, hook_1.hookCommand)(args, config);
|
|
697
692
|
break;
|
|
698
693
|
}
|
|
@@ -771,6 +766,7 @@ async function main() {
|
|
|
771
766
|
json: flags.json,
|
|
772
767
|
noDaemon: flags['no-daemon'],
|
|
773
768
|
acceptDisclosure: flags['accept-disclosure'],
|
|
769
|
+
mode: flags.mode,
|
|
774
770
|
};
|
|
775
771
|
const { onboardCommand } = await Promise.resolve().then(() => __importStar(require('./commands/onboard')));
|
|
776
772
|
await onboardCommand(args, config);
|
package/dist/integrity.js
CHANGED
|
@@ -105,6 +105,12 @@ function getLocalIntegrityReport(options = {}) {
|
|
|
105
105
|
}
|
|
106
106
|
else if (cursorHooks.staleEntries > 0)
|
|
107
107
|
reasons.push('cursor_hook_stale_path');
|
|
108
|
+
// A managed entry mutated to weaken enforcement (hand-added `--shadow true`):
|
|
109
|
+
// the hook ignores the flag on managed invocations, and the tamper is
|
|
110
|
+
// reported here so the fleet console sees the edit instead of it being
|
|
111
|
+
// silently neutralized.
|
|
112
|
+
if (cursorHooks.existed && (cursorHooks.weakenedEntries || 0) > 0)
|
|
113
|
+
reasons.push('cursor_hook_changed');
|
|
108
114
|
const claudeHooks = (0, installClaudeHook_1.inspectClaudeManagedHooks)();
|
|
109
115
|
if (!claudeHooks.existed || claudeHooks.managedEntries === 0) {
|
|
110
116
|
if ((0, appDetection_1.isClaudeFamilyAppInstalled)())
|
|
@@ -112,6 +118,8 @@ function getLocalIntegrityReport(options = {}) {
|
|
|
112
118
|
}
|
|
113
119
|
else if (claudeHooks.staleEntries > 0)
|
|
114
120
|
reasons.push('claude_hook_stale_path');
|
|
121
|
+
if (claudeHooks.existed && (claudeHooks.weakenedEntries || 0) > 0)
|
|
122
|
+
reasons.push('claude_hook_changed');
|
|
115
123
|
const daemonRunning = isDaemonRunning();
|
|
116
124
|
if (options.requireDaemon && !daemonRunning)
|
|
117
125
|
reasons.push('daemon_not_running');
|
|
@@ -36,5 +36,14 @@ export declare function hookSnapshotIdentity(): {
|
|
|
36
36
|
machineName: string;
|
|
37
37
|
};
|
|
38
38
|
export declare function clearLocalSafetySnapshotCache(input: Pick<LocalSafetySnapshotInput, 'apiUrl' | 'shieldId' | 'developerName' | 'machineName'>): void;
|
|
39
|
+
/**
|
|
40
|
+
* Remove EVERY Local Safety snapshot cache file, regardless of the identity
|
|
41
|
+
* that keyed it. Used on (re-)enrollment and explicit policy refresh: cache
|
|
42
|
+
* files are keyed by sha1(apiUrl|shieldId|developer|machine), so after a
|
|
43
|
+
* shield/identity change a targeted unlink can miss files written under an
|
|
44
|
+
* old key — leaving a stale (possibly enforcing) snapshot for whichever
|
|
45
|
+
* surface still resolves that old identity.
|
|
46
|
+
*/
|
|
47
|
+
export declare function clearAllLocalSafetySnapshotCaches(): void;
|
|
39
48
|
export declare function loadLocalSafetySnapshot(input: LocalSafetySnapshotInput): Promise<LocalSafetySnapshot | undefined>;
|
|
40
49
|
export declare function snapshotToScanOptions(snapshot: LocalSafetySnapshot | undefined, extra?: Pick<LocalSafetyScanOptions, 'cwd' | 'inspectScripts'>): LocalSafetyScanOptions;
|
|
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.hookSnapshotIdentity = hookSnapshotIdentity;
|
|
37
37
|
exports.clearLocalSafetySnapshotCache = clearLocalSafetySnapshotCache;
|
|
38
|
+
exports.clearAllLocalSafetySnapshotCaches = clearAllLocalSafetySnapshotCaches;
|
|
38
39
|
exports.loadLocalSafetySnapshot = loadLocalSafetySnapshot;
|
|
39
40
|
exports.snapshotToScanOptions = snapshotToScanOptions;
|
|
40
41
|
const crypto = __importStar(require("crypto"));
|
|
@@ -64,10 +65,40 @@ function readCache(file) {
|
|
|
64
65
|
function writeCache(file, snapshot) {
|
|
65
66
|
try {
|
|
66
67
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
67
|
-
|
|
68
|
+
// Atomic write-temp-rename: hooks read this file on their hot path — a
|
|
69
|
+
// torn read must never make a policy snapshot unparseable mid-refresh.
|
|
70
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
71
|
+
fs.writeFileSync(tmp, JSON.stringify({ loadedAt: Date.now(), snapshot }, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
72
|
+
fs.renameSync(tmp, file);
|
|
68
73
|
}
|
|
69
74
|
catch { /* best-effort */ }
|
|
70
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Fallback when the derived cache key has no file: the freshest snapshot on
|
|
78
|
+
* disk under ANY key. Cache keys hash the caller's identity, and that
|
|
79
|
+
* identity has drifted across surfaces/versions (gateway --developer-name vs
|
|
80
|
+
* hook username@hostname vs normalized machine identity) — after an upgrade
|
|
81
|
+
* the old surface's cache would otherwise be invisible and an OFFLINE block
|
|
82
|
+
* machine would silently downgrade its catalog rules to warn. All snapshot
|
|
83
|
+
* files in this per-user dir belong to this machine's enrollment (enrollment
|
|
84
|
+
* clears them all), so the freshest one is always safe to serve.
|
|
85
|
+
*/
|
|
86
|
+
function readFreshestAnyCache() {
|
|
87
|
+
try {
|
|
88
|
+
let best;
|
|
89
|
+
for (const name of fs.readdirSync(CACHE_DIR)) {
|
|
90
|
+
if (!/^local-safety-[0-9a-f]{40}\.json$/i.test(name))
|
|
91
|
+
continue;
|
|
92
|
+
const parsed = readCache(path.join(CACHE_DIR, name));
|
|
93
|
+
if (parsed && (!best || parsed.loadedAt > best.loadedAt))
|
|
94
|
+
best = parsed;
|
|
95
|
+
}
|
|
96
|
+
return best;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
71
102
|
function toRuleAction(value) {
|
|
72
103
|
// Explicit 'block' is preserved (not treated as absent): warn-by-default
|
|
73
104
|
// items (e.g. env_workspace) need an org's explicit block override to
|
|
@@ -135,9 +166,30 @@ function clearLocalSafetySnapshotCache(input) {
|
|
|
135
166
|
}
|
|
136
167
|
catch { /* cache may not exist */ }
|
|
137
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Remove EVERY Local Safety snapshot cache file, regardless of the identity
|
|
171
|
+
* that keyed it. Used on (re-)enrollment and explicit policy refresh: cache
|
|
172
|
+
* files are keyed by sha1(apiUrl|shieldId|developer|machine), so after a
|
|
173
|
+
* shield/identity change a targeted unlink can miss files written under an
|
|
174
|
+
* old key — leaving a stale (possibly enforcing) snapshot for whichever
|
|
175
|
+
* surface still resolves that old identity.
|
|
176
|
+
*/
|
|
177
|
+
function clearAllLocalSafetySnapshotCaches() {
|
|
178
|
+
try {
|
|
179
|
+
for (const name of fs.readdirSync(CACHE_DIR)) {
|
|
180
|
+
if (/^local-safety-[0-9a-f]{40}\.json$/i.test(name)) {
|
|
181
|
+
try {
|
|
182
|
+
fs.unlinkSync(path.join(CACHE_DIR, name));
|
|
183
|
+
}
|
|
184
|
+
catch { /* best-effort */ }
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
catch { /* cache dir may not exist */ }
|
|
189
|
+
}
|
|
138
190
|
async function loadLocalSafetySnapshot(input) {
|
|
139
191
|
const file = cachePath(input);
|
|
140
|
-
const cached = readCache(file);
|
|
192
|
+
const cached = readCache(file) || readFreshestAnyCache();
|
|
141
193
|
const hashMatches = !input.expectedPolicyHash || cached?.snapshot.policyHash === input.expectedPolicyHash;
|
|
142
194
|
if (cached && hashMatches && Date.now() - cached.loadedAt < CACHE_TTL_MS)
|
|
143
195
|
return cached.snapshot;
|
|
@@ -175,6 +227,10 @@ function snapshotToScanOptions(snapshot, extra = {}) {
|
|
|
175
227
|
return {
|
|
176
228
|
disabledBuiltInItemIds: snapshot?.disabledBuiltInItemIds,
|
|
177
229
|
itemActions: snapshot?.itemActions,
|
|
230
|
+
// Signals to the guard whether a real snapshot was resolved. Without one,
|
|
231
|
+
// the guard defaults to warn (never catalog hard-block) for unarmed/offline
|
|
232
|
+
// machines instead of over-blocking.
|
|
233
|
+
snapshotPresent: snapshot !== undefined,
|
|
178
234
|
customBlocks: snapshot?.customBlocks,
|
|
179
235
|
trustedScriptPaths: snapshot?.trustedScriptPaths,
|
|
180
236
|
policyHash: snapshot?.policyHash,
|