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.
Files changed (45) hide show
  1. package/dist/commands/daemon.d.ts +14 -0
  2. package/dist/commands/daemon.js +181 -28
  3. package/dist/commands/desktopChatGuard.js +5 -4
  4. package/dist/commands/deterministicGuard.d.ts +8 -0
  5. package/dist/commands/deterministicGuard.js +6 -0
  6. package/dist/commands/doctor.d.ts +4 -0
  7. package/dist/commands/doctor.js +99 -0
  8. package/dist/commands/hook.d.ts +1 -0
  9. package/dist/commands/hook.js +61 -104
  10. package/dist/commands/installClaudeHook.d.ts +2 -0
  11. package/dist/commands/installClaudeHook.js +21 -3
  12. package/dist/commands/installCursorHook.d.ts +8 -0
  13. package/dist/commands/installCursorHook.js +29 -4
  14. package/dist/commands/mcpGateway.js +234 -43
  15. package/dist/commands/onboard.d.ts +9 -0
  16. package/dist/commands/onboard.js +7 -0
  17. package/dist/commands/watchdog.d.ts +5 -2
  18. package/dist/commands/watchdog.js +34 -4
  19. package/dist/config.js +6 -1
  20. package/dist/daemonForensics.d.ts +27 -0
  21. package/dist/daemonForensics.js +27 -0
  22. package/dist/distress.d.ts +17 -0
  23. package/dist/distress.js +17 -0
  24. package/dist/hookIo.d.ts +43 -0
  25. package/dist/hookIo.js +189 -0
  26. package/dist/hookSlim.d.ts +32 -0
  27. package/dist/hookSlim.js +142 -0
  28. package/dist/index.js +10 -14
  29. package/dist/integrity.js +8 -0
  30. package/dist/localSafetySnapshot.d.ts +9 -0
  31. package/dist/localSafetySnapshot.js +58 -2
  32. package/dist/machineIdentity.js +70 -4
  33. package/dist/runtimeConfig.d.ts +2 -0
  34. package/dist/runtimeConfig.js +177 -5
  35. package/dist/selfTest.d.ts +10 -0
  36. package/dist/selfTest.js +19 -8
  37. package/dist/selfUpdate.d.ts +19 -4
  38. package/dist/selfUpdate.js +108 -12
  39. package/dist/telemetry.js +67 -2
  40. package/dist/verdictIpc.d.ts +12 -75
  41. package/dist/verdictIpc.js +104 -188
  42. package/dist/verdictIpcClient.d.ts +103 -0
  43. package/dist/verdictIpcClient.js +251 -0
  44. package/dist/version.json +1 -1
  45. 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
  };
@@ -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 {};
@@ -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
- const args = {
683
- event: flags.event,
684
- apiUrl: flags['api-url'],
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
- fs.writeFileSync(file, JSON.stringify({ loadedAt: Date.now(), snapshot }, null, 2), { encoding: 'utf8', mode: 0o600 });
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,