fullcourtdefense-cli 1.26.5 → 1.26.7
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 +25 -2
- package/dist/commands/installClaudeHook.d.ts +2 -0
- package/dist/commands/installClaudeHook.js +4 -0
- package/dist/commands/installCursorHook.d.ts +8 -0
- package/dist/commands/installCursorHook.js +5 -0
- 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.js +1 -0
- package/dist/index.js +6 -0
- 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 +12 -0
- package/dist/runtimeConfig.js +181 -7
- package/dist/selfTest.d.ts +10 -0
- package/dist/selfTest.js +21 -8
- package/dist/selfUpdate.d.ts +19 -4
- package/dist/selfUpdate.js +108 -12
- package/dist/telemetry.js +67 -2
- package/dist/verdictIpc.js +76 -30
- package/dist/verdictIpcClient.d.ts +27 -0
- package/dist/verdictIpcClient.js +68 -0
- package/dist/version.json +1 -1
- package/package.json +5 -1
|
@@ -73,5 +73,32 @@ export declare function recordUncleanDeathFromWatchdog(isPidAlive: (pid: number)
|
|
|
73
73
|
export declare function touchDaemonAlive(): void;
|
|
74
74
|
/** Record an intentional shutdown so the next boot knows this was NOT a kill. */
|
|
75
75
|
export declare function recordCleanExit(reason: string): void;
|
|
76
|
+
/**
|
|
77
|
+
* A pid can lie in two ways: the daemon process exists but its event loop is
|
|
78
|
+
* WEDGED (never heartbeats, never serves the pipe, never polls the control
|
|
79
|
+
* plane), or the daemon died long ago and the OS RECYCLED its pid for an
|
|
80
|
+
* unrelated process. Both look "alive" to a pid check; both mean the machine
|
|
81
|
+
* has no working control plane. The alive-marker freshness plus a verdict-pipe
|
|
82
|
+
* probe tells them apart:
|
|
83
|
+
*
|
|
84
|
+
* marker fresh → healthy (heartbeats are running)
|
|
85
|
+
* marker stale + pipe answers → healthy (only the marker write fails)
|
|
86
|
+
* marker stale + pipe exists, silent → hung (kernel frees a named pipe when
|
|
87
|
+
* its owner dies, so a silent endpoint
|
|
88
|
+
* means a live-but-wedged owner)
|
|
89
|
+
* marker stale + no pipe endpoint → pid recycled (daemon is gone; do NOT
|
|
90
|
+
* kill the unrelated process)
|
|
91
|
+
*/
|
|
92
|
+
export type DaemonHealthClass = 'dead' | 'healthy' | 'hung' | 'pid_reused' | 'indeterminate';
|
|
93
|
+
/** Marker older than this is stale — 4 missed 5-minute heartbeats. */
|
|
94
|
+
export declare function daemonHungAfterMs(): number;
|
|
95
|
+
export declare function classifyDaemonHealth(input: {
|
|
96
|
+
pidAlive: boolean;
|
|
97
|
+
pid: number | undefined;
|
|
98
|
+
marker: DaemonAliveMarker | undefined;
|
|
99
|
+
staleAfterMs: number;
|
|
100
|
+
pipeProbe: 'responsive' | 'unresponsive' | 'no-endpoint' | 'not-probed';
|
|
101
|
+
nowMs?: number;
|
|
102
|
+
}): DaemonHealthClass;
|
|
76
103
|
/** Stamp the post-mortem as delivered so heartbeats stop re-sending it. */
|
|
77
104
|
export declare function markPostmortemReported(): void;
|
package/dist/daemonForensics.js
CHANGED
|
@@ -43,6 +43,8 @@ exports.recordDaemonStart = recordDaemonStart;
|
|
|
43
43
|
exports.recordUncleanDeathFromWatchdog = recordUncleanDeathFromWatchdog;
|
|
44
44
|
exports.touchDaemonAlive = touchDaemonAlive;
|
|
45
45
|
exports.recordCleanExit = recordCleanExit;
|
|
46
|
+
exports.daemonHungAfterMs = daemonHungAfterMs;
|
|
47
|
+
exports.classifyDaemonHealth = classifyDaemonHealth;
|
|
46
48
|
exports.markPostmortemReported = markPostmortemReported;
|
|
47
49
|
const fs = __importStar(require("fs"));
|
|
48
50
|
const os = __importStar(require("os"));
|
|
@@ -178,6 +180,31 @@ function recordCleanExit(reason) {
|
|
|
178
180
|
marker.cleanExit = { at: new Date().toISOString(), reason: reason.slice(0, 120) };
|
|
179
181
|
writeJson(aliveMarkerFile(), marker);
|
|
180
182
|
}
|
|
183
|
+
/** Marker older than this is stale — 4 missed 5-minute heartbeats. */
|
|
184
|
+
function daemonHungAfterMs() {
|
|
185
|
+
const env = Number(process.env.FCD_DAEMON_HUNG_AFTER_MS);
|
|
186
|
+
return Number.isFinite(env) && env > 0 ? env : 20 * 60_000;
|
|
187
|
+
}
|
|
188
|
+
function classifyDaemonHealth(input) {
|
|
189
|
+
if (!input.pidAlive || !input.pid)
|
|
190
|
+
return 'dead';
|
|
191
|
+
const marker = input.marker;
|
|
192
|
+
// No marker for this pid (marker deleted, or owned by a different pid):
|
|
193
|
+
// freshness cannot be judged — stay conservative, never kill on a guess.
|
|
194
|
+
if (!marker || marker.pid !== input.pid)
|
|
195
|
+
return 'indeterminate';
|
|
196
|
+
const last = Date.parse(marker.lastAliveAt || marker.startedAt || '');
|
|
197
|
+
const age = (input.nowMs ?? Date.now()) - last;
|
|
198
|
+
if (!Number.isFinite(age) || age <= input.staleAfterMs)
|
|
199
|
+
return 'healthy';
|
|
200
|
+
if (input.pipeProbe === 'responsive')
|
|
201
|
+
return 'healthy';
|
|
202
|
+
if (input.pipeProbe === 'unresponsive')
|
|
203
|
+
return 'hung';
|
|
204
|
+
if (input.pipeProbe === 'no-endpoint')
|
|
205
|
+
return 'pid_reused';
|
|
206
|
+
return 'indeterminate';
|
|
207
|
+
}
|
|
181
208
|
/** Stamp the post-mortem as delivered so heartbeats stop re-sending it. */
|
|
182
209
|
function markPostmortemReported() {
|
|
183
210
|
const pm = readPostmortem();
|
package/dist/distress.d.ts
CHANGED
|
@@ -54,6 +54,23 @@ export declare const DISTRESS: {
|
|
|
54
54
|
* next tick — but while it lasted, remote actions were undeliverable.
|
|
55
55
|
*/
|
|
56
56
|
readonly POLL_STALLED: "poll_stalled";
|
|
57
|
+
/**
|
|
58
|
+
* The watchdog found the daemon process alive but WEDGED — alive-marker
|
|
59
|
+
* stale past the bound and the verdict pipe silent — and recycled it.
|
|
60
|
+
*/
|
|
61
|
+
readonly DAEMON_HUNG: "daemon_hung";
|
|
62
|
+
/**
|
|
63
|
+
* The daemon exited itself after repeated uncaught exceptions in a short
|
|
64
|
+
* window (process state suspect); the supervisor restarts it clean.
|
|
65
|
+
*/
|
|
66
|
+
readonly UNCAUGHT_BURST: "uncaught_burst";
|
|
67
|
+
/**
|
|
68
|
+
* The telemetry spool could not be written (disk full, ACL, AV lock) — the
|
|
69
|
+
* machine is still enforcing, but its decisions are being LOST locally and
|
|
70
|
+
* will never reach the console. Deserves an admin's attention because a
|
|
71
|
+
* quiet machine and a machine that cannot record look identical otherwise.
|
|
72
|
+
*/
|
|
73
|
+
readonly SPOOL_WRITE_FAILED: "spool_write_failed";
|
|
57
74
|
/** Anything nobody predicted — reported via reportUnexpected(). */
|
|
58
75
|
readonly UNEXPECTED: "unexpected_error";
|
|
59
76
|
};
|
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.js
CHANGED
|
@@ -135,6 +135,7 @@ function mapHookFlags(flags) {
|
|
|
135
135
|
shieldKey: flags['shield-key'],
|
|
136
136
|
shadow: flags.shadow,
|
|
137
137
|
enforce: flags.enforce,
|
|
138
|
+
fcdManaged: flags['fcd-managed'],
|
|
138
139
|
failClosed: flags['fail-closed'],
|
|
139
140
|
localOnly: flags['local-only'],
|
|
140
141
|
timeout: flags.timeout,
|
package/dist/index.js
CHANGED
|
@@ -138,6 +138,9 @@ function printHelp() {
|
|
|
138
138
|
doctor First step. Checks outbound HTTPS access to FullCourtDefense.
|
|
139
139
|
--perf measures this machine's real per-event overhead
|
|
140
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.
|
|
141
144
|
login Enrolls this machine with a fleet enrollment token and saves
|
|
142
145
|
per-machine Shield credentials to ~/.fullcourtdefense.yml.
|
|
143
146
|
configure Legacy/manual setup: saves org API key, Organization ID,
|
|
@@ -519,6 +522,8 @@ async function main() {
|
|
|
519
522
|
const args = {
|
|
520
523
|
apiUrl: flags['api-url'],
|
|
521
524
|
perf: flags.perf,
|
|
525
|
+
health: flags.health,
|
|
526
|
+
edr: flags.edr,
|
|
522
527
|
};
|
|
523
528
|
const { doctorCommand } = await Promise.resolve().then(() => __importStar(require('./commands/doctor')));
|
|
524
529
|
await doctorCommand(args, config);
|
|
@@ -761,6 +766,7 @@ async function main() {
|
|
|
761
766
|
json: flags.json,
|
|
762
767
|
noDaemon: flags['no-daemon'],
|
|
763
768
|
acceptDisclosure: flags['accept-disclosure'],
|
|
769
|
+
mode: flags.mode,
|
|
764
770
|
};
|
|
765
771
|
const { onboardCommand } = await Promise.resolve().then(() => __importStar(require('./commands/onboard')));
|
|
766
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,
|
package/dist/machineIdentity.js
CHANGED
|
@@ -40,6 +40,7 @@ const child_process_1 = require("child_process");
|
|
|
40
40
|
const crypto = __importStar(require("crypto"));
|
|
41
41
|
const fs = __importStar(require("fs"));
|
|
42
42
|
const os = __importStar(require("os"));
|
|
43
|
+
const path = __importStar(require("path"));
|
|
43
44
|
/**
|
|
44
45
|
* OS-agnostic stable machine identity.
|
|
45
46
|
*
|
|
@@ -100,6 +101,53 @@ function rawStableId() {
|
|
|
100
101
|
return (safe(() => fs.readFileSync('/etc/machine-id', 'utf8'))
|
|
101
102
|
|| safe(() => fs.readFileSync('/var/lib/dbus/machine-id', 'utf8')));
|
|
102
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Where the FALLBACK machine id is persisted when no OS-native stable id is
|
|
106
|
+
* readable (locked-down registry / missing /etc/machine-id). Two locations:
|
|
107
|
+
*
|
|
108
|
+
* - machine-wide (ProgramData on Windows): shared by EVERY user AND the
|
|
109
|
+
* SYSTEM/S4U daemon tasks. Without it, the composite fallback — which
|
|
110
|
+
* includes the username — gives the daemon (running as SYSTEM) a DIFFERENT
|
|
111
|
+
* machineId than the enrolling user, creating duplicate fleet records with
|
|
112
|
+
* split telemetry for the same laptop.
|
|
113
|
+
* - per-user (~/.fullcourtdefense): fallback when ProgramData is not
|
|
114
|
+
* writable; keeps the id stable across upgrades for that user at least.
|
|
115
|
+
*
|
|
116
|
+
* The persisted value is the final HASHED id (never the raw composite).
|
|
117
|
+
*/
|
|
118
|
+
function persistedIdPaths() {
|
|
119
|
+
const perUser = path.join(os.homedir(), '.fullcourtdefense', 'machine-id');
|
|
120
|
+
if (os.platform() === 'win32') {
|
|
121
|
+
const programData = process.env.ProgramData || 'C:\\ProgramData';
|
|
122
|
+
return { machineWide: path.join(programData, 'FullCourtDefense', 'machine-id'), perUser };
|
|
123
|
+
}
|
|
124
|
+
return { perUser };
|
|
125
|
+
}
|
|
126
|
+
function readPersistedFallbackId() {
|
|
127
|
+
const { machineWide, perUser } = persistedIdPaths();
|
|
128
|
+
for (const file of [machineWide, perUser]) {
|
|
129
|
+
if (!file)
|
|
130
|
+
continue;
|
|
131
|
+
const value = safe(() => fs.readFileSync(file, 'utf8'));
|
|
132
|
+
if (value && /^[0-9a-f]{16,64}$/i.test(value))
|
|
133
|
+
return value.toLowerCase();
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
function persistFallbackId(machineId) {
|
|
138
|
+
const { machineWide, perUser } = persistedIdPaths();
|
|
139
|
+
for (const file of [machineWide, perUser]) {
|
|
140
|
+
if (!file)
|
|
141
|
+
continue;
|
|
142
|
+
try {
|
|
143
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
144
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
145
|
+
fs.writeFileSync(tmp, machineId, 'utf8');
|
|
146
|
+
fs.renameSync(tmp, file);
|
|
147
|
+
}
|
|
148
|
+
catch { /* best-effort — the composite recomputes deterministically for this user */ }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
103
151
|
function friendlyOs() {
|
|
104
152
|
const platform = os.platform();
|
|
105
153
|
const release = os.release();
|
|
@@ -149,10 +197,28 @@ function getMachineIdentity() {
|
|
|
149
197
|
}
|
|
150
198
|
const user = safe(() => os.userInfo().username) || process.env.USER || process.env.USERNAME || 'unknown-user';
|
|
151
199
|
const hostname = normalizeHostname(safe(() => os.hostname()) || 'unknown-host');
|
|
152
|
-
// Seed the hash with the OS-native id when available;
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
200
|
+
// Seed the hash with the OS-native id when available; otherwise reuse the
|
|
201
|
+
// persisted fallback id (so every user + the SYSTEM daemon on the same
|
|
202
|
+
// locked-down machine converge on ONE identity); only then compute the
|
|
203
|
+
// legacy composite — and persist it so future contexts converge on it.
|
|
204
|
+
const nativeRaw = rawStableId();
|
|
205
|
+
let machineId;
|
|
206
|
+
if (nativeRaw) {
|
|
207
|
+
machineId = crypto.createHash('sha256').update(nativeRaw).digest('hex').slice(0, 32);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
const persisted = readPersistedFallbackId();
|
|
211
|
+
if (persisted) {
|
|
212
|
+
machineId = persisted;
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
// Composite keeps the pre-existing shape (incl. username) so machines
|
|
216
|
+
// already enrolled on this path keep their identity across upgrades.
|
|
217
|
+
const composite = `${hostname}|${user}|${os.platform()}|${os.arch()}`;
|
|
218
|
+
machineId = crypto.createHash('sha256').update(composite).digest('hex').slice(0, 32);
|
|
219
|
+
persistFallbackId(machineId);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
156
222
|
cached = {
|
|
157
223
|
machineId,
|
|
158
224
|
user,
|
package/dist/runtimeConfig.d.ts
CHANGED
|
@@ -90,6 +90,15 @@ export interface FetchBundleInput {
|
|
|
90
90
|
ttlMs?: number;
|
|
91
91
|
/** Force a network refresh regardless of cache freshness. */
|
|
92
92
|
force?: boolean;
|
|
93
|
+
/**
|
|
94
|
+
* Fetch timeout override (ms). Default is the tight hot-path budget
|
|
95
|
+
* (REFRESH_TIMEOUT_MS = 1.5s) — a hook must never stall a command on a slow
|
|
96
|
+
* control plane. Diagnostic callers (doctor --health, deep self-test) pass a
|
|
97
|
+
* generous value instead: a bundle answer at 3s is a healthy backend, not a
|
|
98
|
+
* "no live bundle" failure. The 2h workday simulation surfaced this — ~1 in
|
|
99
|
+
* 6 health checkpoints false-alarmed on p95 latency spikes.
|
|
100
|
+
*/
|
|
101
|
+
timeoutMs?: number;
|
|
93
102
|
/**
|
|
94
103
|
* Hot-path caller (hook / MCP gateway): when the cached mode is monitor or
|
|
95
104
|
* shadow, accept a STALE cache without a synchronous refresh — the resident
|
|
@@ -101,6 +110,8 @@ export interface FetchBundleInput {
|
|
|
101
110
|
}
|
|
102
111
|
export interface EffectiveBundle extends RuntimeBundle {
|
|
103
112
|
source: 'server' | 'cache' | 'default';
|
|
113
|
+
/** Epoch ms of the last server 200/304 that confirmed this bundle (absent on the 'default' fallback). */
|
|
114
|
+
serverValidatedAt?: number;
|
|
104
115
|
}
|
|
105
116
|
/**
|
|
106
117
|
* Resolve the effective runtime bundle for a Shield, using the local cache when
|
|
@@ -128,6 +139,7 @@ export declare function validateShieldCredentials(input: {
|
|
|
128
139
|
apiUrl: string;
|
|
129
140
|
shieldId: string;
|
|
130
141
|
shieldKey?: string;
|
|
142
|
+
timeoutMs?: number;
|
|
131
143
|
}): Promise<CredentialValidationResult>;
|
|
132
144
|
/**
|
|
133
145
|
* Drop every cached runtime bundle at enrollment time.
|