fullcourtdefense-cli 1.22.4 → 1.22.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.js +17 -2
- package/dist/commands/doctor.js +37 -0
- package/dist/commands/hook.js +27 -6
- package/dist/commands/mcpGateway.js +18 -3
- package/dist/commands/onboard.js +25 -2
- package/dist/commands/windowsAudit.js +23 -2
- package/dist/config.d.ts +1 -1
- package/dist/config.js +43 -0
- package/dist/distress.d.ts +4 -0
- package/dist/distress.js +4 -0
- package/dist/machineKeyFile.d.ts +25 -0
- package/dist/machineKeyFile.js +226 -0
- package/dist/runtimeConfig.d.ts +21 -0
- package/dist/runtimeConfig.js +39 -0
- package/dist/selfTest.js +7 -0
- package/dist/version.json +1 -1
- package/package.json +3 -1
package/dist/commands/daemon.js
CHANGED
|
@@ -399,18 +399,33 @@ async function runDaemon(args, config) {
|
|
|
399
399
|
let autoUpdatePolicy;
|
|
400
400
|
/** Heartbeat ticks spent without full credentials (drives recovery cadence). */
|
|
401
401
|
let credRecoveryTicks = 0;
|
|
402
|
+
/** Enrollment-file mtime at the last recovery attempt — a change means the
|
|
403
|
+
* user just ran login/onboard and fresh credentials are waiting. */
|
|
404
|
+
let lastEnrollmentMtimeMs = 0;
|
|
402
405
|
/**
|
|
403
406
|
* A daemon that starts without a usable shield key (DPAPI decrypt blocked,
|
|
404
407
|
* config written after start by `login`) must NOT stay credential-less
|
|
405
408
|
* forever — that silences telemetry AND auto-update, stranding the machine.
|
|
406
409
|
* Retry quickly right after start, then hourly (each retry may spawn
|
|
407
|
-
* PowerShell for DPAPI, which we keep rare on machines that block it)
|
|
410
|
+
* PowerShell for DPAPI, which we keep rare on machines that block it) —
|
|
411
|
+
* EXCEPT when ~/.fullcourtdefense.yml changed, which means a re-enrollment
|
|
412
|
+
* just happened: retry on the very next tick, so a repaired machine comes
|
|
413
|
+
* back within one heartbeat instead of up to an hour later (the 8/12
|
|
414
|
+
* incident: re-enroll fixed the hooks instantly while the daemon kept
|
|
415
|
+
* 401-ing for 16 more minutes).
|
|
408
416
|
*/
|
|
409
417
|
const recoverCredentialsIfMissing = () => {
|
|
410
418
|
if (creds.shieldId && creds.shieldKey)
|
|
411
419
|
return;
|
|
420
|
+
let enrollmentChanged = false;
|
|
421
|
+
try {
|
|
422
|
+
const mtime = fs.statSync((0, config_1.getHomeConfigPath)()).mtimeMs;
|
|
423
|
+
enrollmentChanged = mtime !== lastEnrollmentMtimeMs;
|
|
424
|
+
lastEnrollmentMtimeMs = mtime;
|
|
425
|
+
}
|
|
426
|
+
catch { /* no config file — the tick cadence below applies */ }
|
|
412
427
|
credRecoveryTicks += 1;
|
|
413
|
-
if (credRecoveryTicks > 3 && credRecoveryTicks % 12 !== 0)
|
|
428
|
+
if (!enrollmentChanged && credRecoveryTicks > 3 && credRecoveryTicks % 12 !== 0)
|
|
414
429
|
return;
|
|
415
430
|
try {
|
|
416
431
|
const fresh = (0, config_1.resolveCliCredentials)((0, config_1.loadConfig)(args.config), {
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.doctorCommand = doctorCommand;
|
|
4
|
+
const config_1 = require("../config");
|
|
5
|
+
const runtimeConfig_1 = require("../runtimeConfig");
|
|
4
6
|
const DEFAULT_API_URL = 'https://api.fullcourtdefense.ai';
|
|
5
7
|
function withTimeout(ms) {
|
|
6
8
|
return AbortSignal.timeout(ms);
|
|
@@ -8,6 +10,35 @@ function withTimeout(ms) {
|
|
|
8
10
|
function normalizeApiUrl(url) {
|
|
9
11
|
return (url || DEFAULT_API_URL).replace(/\/$/, '');
|
|
10
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Credential health, printed after connectivity passes. Connectivity alone is
|
|
15
|
+
* a false comfort: the 8/12 incident machine had a green ping while the
|
|
16
|
+
* backend rejected its shield key on every call (broken local key store), so
|
|
17
|
+
* every protected action failed closed. Doctor must surface that state.
|
|
18
|
+
*/
|
|
19
|
+
async function checkCredentials(apiUrl, config) {
|
|
20
|
+
const creds = (0, config_1.resolveCliCredentials)(config, { apiUrl });
|
|
21
|
+
if (!creds.shieldId || !creds.shieldKey) {
|
|
22
|
+
console.log('INFO not enrolled on this machine (no saved shield credentials)');
|
|
23
|
+
console.log(' Enroll with: fullcourtdefense onboard --token <fleet-enrollment-token>');
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
const started = Date.now();
|
|
27
|
+
const validation = await (0, runtimeConfig_1.validateShieldCredentials)({ apiUrl, shieldId: creds.shieldId, shieldKey: creds.shieldKey });
|
|
28
|
+
const elapsed = Date.now() - started;
|
|
29
|
+
if (validation.ok) {
|
|
30
|
+
console.log(`PASS machine credentials accepted by the backend (shield ${creds.shieldId}, ${elapsed}ms)`);
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
if (validation.authRejected) {
|
|
34
|
+
console.log(`FAIL backend REJECTS this machine's saved credentials (${validation.detail})`);
|
|
35
|
+
console.log(' Protected actions fail-closed until the machine re-enrolls.');
|
|
36
|
+
console.log(' Fix: fullcourtdefense onboard --token <fleet-enrollment-token>');
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
console.log(`WARN could not verify credentials right now (${validation.detail}); cached enforcement applies`);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
11
42
|
async function doctorCommand(args, config) {
|
|
12
43
|
const apiUrl = normalizeApiUrl(args.apiUrl || config.apiUrl);
|
|
13
44
|
const pingUrl = `${apiUrl}/api/health/ping`;
|
|
@@ -27,6 +58,9 @@ async function doctorCommand(args, config) {
|
|
|
27
58
|
if (resp.ok) {
|
|
28
59
|
console.log(`PASS outbound HTTPS open (${resp.status}, ${elapsed}ms)`);
|
|
29
60
|
console.log(`Checked: ${pingUrl}`);
|
|
61
|
+
if (!(await checkCredentials(apiUrl, config))) {
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
30
64
|
return;
|
|
31
65
|
}
|
|
32
66
|
console.log(`WARN health ping reachable but returned HTTP ${resp.status} (${elapsed}ms)`);
|
|
@@ -46,6 +80,9 @@ async function doctorCommand(args, config) {
|
|
|
46
80
|
if (resp.ok) {
|
|
47
81
|
console.log(`PASS root API reachable (${resp.status}, ${elapsed}ms)`);
|
|
48
82
|
console.log(`Checked: ${rootUrl}`);
|
|
83
|
+
if (!(await checkCredentials(apiUrl, config))) {
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
49
86
|
return;
|
|
50
87
|
}
|
|
51
88
|
console.log(`FAIL root API returned HTTP ${resp.status} (${elapsed}ms)`);
|
package/dist/commands/hook.js
CHANGED
|
@@ -373,21 +373,31 @@ async function fetchWithOneRetry(url, init, timeoutMs, onFailure) {
|
|
|
373
373
|
* spooled (`offlineEnforced`) and replayed to the backend when connectivity
|
|
374
374
|
* returns, and distress signals make the episode visible to org admins.
|
|
375
375
|
*/
|
|
376
|
-
function respondDegraded(ctx, detail, toolName) {
|
|
376
|
+
function respondDegraded(ctx, detail, toolName, authRejected = false) {
|
|
377
|
+
// "Unreachable" and "credentials rejected" need OPPOSITE fixes (wait vs
|
|
378
|
+
// re-enroll) and different alerts. The 8/12 incident machine showed users
|
|
379
|
+
// "restore connectivity" advice for hours while the real fix was a 30-second
|
|
380
|
+
// re-enrollment — never conflate the two again.
|
|
381
|
+
const cause = authRejected
|
|
382
|
+
? 'the policy service rejected this machine\'s credentials'
|
|
383
|
+
: 'policy service unreachable';
|
|
384
|
+
const advice = authRejected
|
|
385
|
+
? 'Re-enroll this machine: fullcourtdefense onboard --token <fleet-enrollment-token>.'
|
|
386
|
+
: 'Do not retry until the connection is restored.';
|
|
377
387
|
if (ctx.failClosed && !ctx.shadow) {
|
|
378
388
|
const health = (0, policyGateHealth_1.recordGateFailure)();
|
|
379
389
|
if ((0, policyGateHealth_1.shouldFailClosed)(health)) {
|
|
380
390
|
// Persistent outage on an enforcing machine: block, and raise a CRITICAL
|
|
381
391
|
// distress code — the daemon heartbeat ships it and org admins get the
|
|
382
392
|
// proactive alert email (fleet-alerts pipeline).
|
|
383
|
-
(0, distress_1.reportDistress)('hook', distress_1.DISTRESS.HOOK_FAIL_CLOSED, `Blocking ${ctx.event} actions:
|
|
393
|
+
(0, distress_1.reportDistress)('hook', authRejected ? distress_1.DISTRESS.AUTH_BROKEN : distress_1.DISTRESS.HOOK_FAIL_CLOSED, `Blocking ${ctx.event} actions: ${cause} ${health.consecutiveFailures} times in a row since ${health.firstFailureAt || 'now'} (${detail})`);
|
|
384
394
|
(0, telemetry_1.spoolEvent)({ decision: 'block', toolName, reason: `fail-closed: ${detail}`, offlineEnforced: true });
|
|
385
395
|
(0, telemetry_1.triggerFlush)(true);
|
|
386
|
-
ctx.respond(true, `Blocked by FullCourtDefense —
|
|
396
|
+
ctx.respond(true, `Blocked by FullCourtDefense — ${cause} (${health.consecutiveFailures}x in a row) and this machine is set to fail-closed. ${detail} ${advice}`, `FullCourtDefense could not verify this ${ctx.event} against your org policies (${detail}) and the failure is persistent, so fail-closed mode is blocking. ${advice}`);
|
|
387
397
|
}
|
|
388
398
|
// Grace window: transient failure on a fail-closed machine — allow this
|
|
389
399
|
// action, but record distress so the episode is visible in the console.
|
|
390
|
-
(0, distress_1.reportDistress)('hook', distress_1.DISTRESS.NETWORK_DOWN, `Policy gate failure ${health.consecutiveFailures}/3 (grace window, still allowing): ${detail}`);
|
|
400
|
+
(0, distress_1.reportDistress)('hook', authRejected ? distress_1.DISTRESS.AUTH_BROKEN : distress_1.DISTRESS.NETWORK_DOWN, `Policy gate failure ${health.consecutiveFailures}/3 (grace window, still allowing): ${detail}`);
|
|
391
401
|
dbg({ phase: 'policy_grace_allow', event: ctx.event, consecutiveFailures: health.consecutiveFailures, detail });
|
|
392
402
|
(0, telemetry_1.spoolEvent)({ decision: 'allow', toolName, reason: `degraded (grace ${health.consecutiveFailures}/3): ${detail}`, offlineEnforced: true });
|
|
393
403
|
(0, telemetry_1.triggerFlush)(false);
|
|
@@ -883,8 +893,19 @@ async function enforceActionPolicy(ctx) {
|
|
|
883
893
|
}, timeoutMs, (attempt, err) => dbg({ phase: 'policy_retry', event, attempt, error: err }));
|
|
884
894
|
if (!resp.ok) {
|
|
885
895
|
dbg({ phase: 'policy_http_error', event, status: resp.status, failClosed: ctx.failClosed });
|
|
886
|
-
|
|
887
|
-
|
|
896
|
+
const authRejected = resp.status === 401 || resp.status === 403;
|
|
897
|
+
const detail = authRejected
|
|
898
|
+
? `The policy service rejected this machine's credentials (HTTP ${resp.status}) — the shield key saved here is broken or revoked, not a network problem.`
|
|
899
|
+
: `Backend returned HTTP ${resp.status}.`;
|
|
900
|
+
if (authRejected) {
|
|
901
|
+
// Rejected key = every future call fails identically. Cached policies
|
|
902
|
+
// (below) remain the best enforcement stance, but the distress code
|
|
903
|
+
// must say auth_broken so the org-admin alert email names the real
|
|
904
|
+
// fix (re-enroll) instead of "check connectivity".
|
|
905
|
+
(0, distress_1.reportDistress)('hook', distress_1.DISTRESS.AUTH_BROKEN, `check-tool-call HTTP ${resp.status}${shieldKey ? '' : ' (no shield key resolved)'}`);
|
|
906
|
+
}
|
|
907
|
+
if (!tryLocalPolicyEnforcement(ctx, call, detail)) {
|
|
908
|
+
respondDegraded(ctx, detail, call.toolName, authRejected);
|
|
888
909
|
}
|
|
889
910
|
return;
|
|
890
911
|
}
|
|
@@ -587,6 +587,18 @@ class HttpMcpClient {
|
|
|
587
587
|
}
|
|
588
588
|
}
|
|
589
589
|
}
|
|
590
|
+
/**
|
|
591
|
+
* The backend ANSWERED and rejected the machine's shield key (401/403).
|
|
592
|
+
* Deliberately a distinct type: the offline-fallback catch must report
|
|
593
|
+
* auth_broken (fix: re-enroll) instead of network_down (fix: wait) — the
|
|
594
|
+
* 8/12 incident hid a broken key store behind "unreachable" advice.
|
|
595
|
+
*/
|
|
596
|
+
class CredentialsRejectedError extends Error {
|
|
597
|
+
constructor(status) {
|
|
598
|
+
super(`the policy service rejected this machine's credentials (HTTP ${status}) — re-enroll with: fullcourtdefense onboard --token <fleet-enrollment-token>`);
|
|
599
|
+
this.name = 'CredentialsRejectedError';
|
|
600
|
+
}
|
|
601
|
+
}
|
|
590
602
|
class AgentGuardApi {
|
|
591
603
|
config;
|
|
592
604
|
constructor(config) {
|
|
@@ -608,6 +620,8 @@ class AgentGuardApi {
|
|
|
608
620
|
source: 'runtime_sdk',
|
|
609
621
|
});
|
|
610
622
|
if (!result.success || !result.data) {
|
|
623
|
+
if (result.status === 401 || result.status === 403)
|
|
624
|
+
throw new CredentialsRejectedError(result.status);
|
|
611
625
|
throw new Error(result.error || 'Tool-call policy check failed.');
|
|
612
626
|
}
|
|
613
627
|
return result.data;
|
|
@@ -706,7 +720,7 @@ class AgentGuardApi {
|
|
|
706
720
|
});
|
|
707
721
|
const data = await resp.json().catch(() => ({}));
|
|
708
722
|
if (!resp.ok)
|
|
709
|
-
return { success: false, error: data.error || `AgentGuard API error (${resp.status})` };
|
|
723
|
+
return { success: false, status: resp.status, error: data.error || `AgentGuard API error (${resp.status})` };
|
|
710
724
|
return data;
|
|
711
725
|
}
|
|
712
726
|
async get(pathValue, timeoutMs = 10_000) {
|
|
@@ -717,7 +731,7 @@ class AgentGuardApi {
|
|
|
717
731
|
});
|
|
718
732
|
const data = await resp.json().catch(() => ({}));
|
|
719
733
|
if (!resp.ok)
|
|
720
|
-
return { success: false, error: data.error || `AgentGuard API error (${resp.status})` };
|
|
734
|
+
return { success: false, status: resp.status, error: data.error || `AgentGuard API error (${resp.status})` };
|
|
721
735
|
return data;
|
|
722
736
|
}
|
|
723
737
|
headers() {
|
|
@@ -908,7 +922,8 @@ class McpGatewayServer {
|
|
|
908
922
|
// same engine the server runs — the machine keeps working (and keeps
|
|
909
923
|
// enforcing) through the outage instead of blanket fail-open/closed.
|
|
910
924
|
const detail = err instanceof Error ? err.message : String(err);
|
|
911
|
-
|
|
925
|
+
const authRejected = err instanceof CredentialsRejectedError;
|
|
926
|
+
(0, distress_1.reportDistress)('gateway', authRejected ? distress_1.DISTRESS.AUTH_BROKEN : distress_1.DISTRESS.NETWORK_DOWN, `check-tool-call ${authRejected ? 'credentials rejected' : 'unreachable'}: ${detail}`);
|
|
912
927
|
if (cachedPolicies && cachedPolicies.length > 0) {
|
|
913
928
|
const inferred = (0, actionPolicyEngine_1.inferToolContext)(toolName, toolArgs);
|
|
914
929
|
const localOperation = operation || inferred.operation;
|
package/dist/commands/onboard.js
CHANGED
|
@@ -54,6 +54,7 @@ const appDetection_1 = require("../appDetection");
|
|
|
54
54
|
const integrity_1 = require("../integrity");
|
|
55
55
|
const telemetry_1 = require("../telemetry");
|
|
56
56
|
const envDiagnostics_1 = require("../envDiagnostics");
|
|
57
|
+
const runtimeConfig_1 = require("../runtimeConfig");
|
|
57
58
|
const daemonForensics_1 = require("../daemonForensics");
|
|
58
59
|
const onboardingJournal_1 = require("./onboardingJournal");
|
|
59
60
|
const GREEN = '\x1b[32m';
|
|
@@ -293,8 +294,30 @@ async function onboardCommand(args, config) {
|
|
|
293
294
|
mark('enrollment', 'completed', 'machine enrolled');
|
|
294
295
|
}
|
|
295
296
|
else if (alreadyEnrolled) {
|
|
296
|
-
|
|
297
|
-
|
|
297
|
+
// A locally-resolved key is NOT proof of enrollment: the 8/12 incident
|
|
298
|
+
// machine printed "Already enrolled ✓" here while the backend 401'd every
|
|
299
|
+
// hook call (broken key store). Prove the key works before saying so —
|
|
300
|
+
// and keep the two failure kinds apart: rejected => re-enroll NOW;
|
|
301
|
+
// unreachable => fine, cached stance applies, don't block onboarding.
|
|
302
|
+
const validation = await (0, runtimeConfig_1.validateShieldCredentials)({ apiUrl, shieldId: creds.shieldId, shieldKey: creds.shieldKey });
|
|
303
|
+
if (validation.ok) {
|
|
304
|
+
console.log(` ${GREEN}✓${RESET} Already enrolled ${DIM}(shield ${creds.shieldId}, key verified against the backend)${RESET} — pass --token to re-enroll.`);
|
|
305
|
+
mark('enrollment', 'completed', `reused shield ${creds.shieldId} (key verified)`);
|
|
306
|
+
}
|
|
307
|
+
else if (validation.authRejected) {
|
|
308
|
+
console.log(` ${RED}✗${RESET} Saved credentials exist but the backend ${BOLD}rejects them${RESET} ${DIM}(${validation.detail})${RESET}.`);
|
|
309
|
+
console.log(` Protected actions on this machine would fail-closed until it re-enrolls.`);
|
|
310
|
+
console.log(` Get a fleet enrollment token (AI Fleet -> Settings -> Fleet enrollment token), then run:`);
|
|
311
|
+
console.log(` ${BOLD}fullcourtdefense onboard --token <fleet-enrollment-token>${RESET}`);
|
|
312
|
+
mark('enrollment', 'failed', undefined, `backend rejected saved credentials: ${validation.detail}`);
|
|
313
|
+
report('Re-enroll with a fresh fleet enrollment token: fullcourtdefense onboard --token <token>.');
|
|
314
|
+
process.exitCode = 1;
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
console.log(` ${YELLOW}!${RESET} Already enrolled ${DIM}(shield ${creds.shieldId})${RESET} — key not verified right now (${validation.detail}); cached enforcement applies.`);
|
|
319
|
+
mark('enrollment', 'completed', `reused shield ${creds.shieldId} (verification deferred: backend unreachable)`);
|
|
320
|
+
}
|
|
298
321
|
}
|
|
299
322
|
else {
|
|
300
323
|
console.log(`\n${RED}No fleet enrollment token.${RESET} Ask an org admin for one (AI Fleet -> Settings -> Fleet enrollment token), then run:`);
|
|
@@ -145,12 +145,33 @@ function auditPreInstallSnapshotPath() {
|
|
|
145
145
|
function captureAuditPreInstallSnapshot() {
|
|
146
146
|
try {
|
|
147
147
|
const file = auditPreInstallSnapshotPath();
|
|
148
|
-
|
|
148
|
+
const trDir = regString(TRANSCRIPTION_KEY, 'OutputDirectory');
|
|
149
|
+
// Upgrade guard: when transcription already points at OUR folder, the
|
|
150
|
+
// current keys are FCD's own configuration written by a pre-snapshot
|
|
151
|
+
// version (<=1.22.3) — NOT a pre-install state. Recording it would make
|
|
152
|
+
// uninstall "restore" FCD's keys instead of removing them. Skip the
|
|
153
|
+
// snapshot; uninstall's conservative heuristic handles FCD-provenance
|
|
154
|
+
// keys correctly.
|
|
155
|
+
const fcdProvenance = typeof trDir === 'string' && /FullCourtDefense/i.test(trDir);
|
|
156
|
+
if (fs.existsSync(file)) {
|
|
157
|
+
// Self-heal snapshots already written by 1.22.4.0 on upgraded machines:
|
|
158
|
+
// a "pre-install" transcription directory inside an FCD folder is
|
|
159
|
+
// provably bogus (FCD did not exist before FCD was installed).
|
|
160
|
+
try {
|
|
161
|
+
const existing = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
162
|
+
const dir = existing?.transcription?.outputDirectory;
|
|
163
|
+
if (typeof dir === 'string' && /FullCourtDefense/i.test(dir))
|
|
164
|
+
fs.unlinkSync(file);
|
|
165
|
+
}
|
|
166
|
+
catch { /* unreadable snapshot: leave it — the uninstall branch parses defensively */ }
|
|
167
|
+
if (fs.existsSync(file))
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (fcdProvenance)
|
|
149
171
|
return;
|
|
150
172
|
const sblValue = regDword(SBL_KEY, 'EnableScriptBlockLogging');
|
|
151
173
|
const trValue = regDword(TRANSCRIPTION_KEY, 'EnableTranscripting');
|
|
152
174
|
const trHeader = regDword(TRANSCRIPTION_KEY, 'EnableInvocationHeader');
|
|
153
|
-
const trDir = regString(TRANSCRIPTION_KEY, 'OutputDirectory');
|
|
154
175
|
const snapshot = {
|
|
155
176
|
capturedAt: new Date().toISOString(),
|
|
156
177
|
scriptBlockLogging: { present: sblValue !== undefined, enableValue: sblValue },
|
package/dist/config.d.ts
CHANGED
|
@@ -100,7 +100,7 @@ export declare function daemonHandoffShieldKey(env?: NodeJS.ProcessEnv): string
|
|
|
100
100
|
* itself). `none_dpapi_broken` is the credential-broken state: a DPAPI blob
|
|
101
101
|
* exists but would not decrypt and no fallback source was available.
|
|
102
102
|
*/
|
|
103
|
-
export type ShieldKeySource = 'override' | 'daemon_handoff' | 'native_store' | 'dpapi' | 'plaintext_config' | 'env' | 'none' | 'none_dpapi_broken';
|
|
103
|
+
export type ShieldKeySource = 'override' | 'daemon_handoff' | 'native_store' | 'machine_file' | 'dpapi' | 'plaintext_config' | 'env' | 'none' | 'none_dpapi_broken';
|
|
104
104
|
export interface CredentialResolutionTrace {
|
|
105
105
|
shieldKeySource: ShieldKeySource;
|
|
106
106
|
dpapiAttempted: boolean;
|
package/dist/config.js
CHANGED
|
@@ -55,6 +55,7 @@ const path = __importStar(require("path"));
|
|
|
55
55
|
const child_process_1 = require("child_process");
|
|
56
56
|
const distress_1 = require("./distress");
|
|
57
57
|
const credentialStore_1 = require("./credentialStore");
|
|
58
|
+
const machineKeyFile_1 = require("./machineKeyFile");
|
|
58
59
|
const CONFIG_FILENAMES = [
|
|
59
60
|
'.fullcourtdefense.yml',
|
|
60
61
|
'.fullcourtdefense.yaml',
|
|
@@ -358,6 +359,26 @@ function maybeMigrateShieldKeyToNativeStore(shieldId, shieldKey) {
|
|
|
358
359
|
}
|
|
359
360
|
catch { /* migration is opportunistic — legacy sources keep working */ }
|
|
360
361
|
}
|
|
362
|
+
// Once per process, same rationale as the native migration above.
|
|
363
|
+
let machineFileMirrorAttempted = false;
|
|
364
|
+
/**
|
|
365
|
+
* Self-heal mirror for the S4U daemon (the 1.22.2 regression): whenever an
|
|
366
|
+
* interactive-session process resolves the key from a store the S4U daemon
|
|
367
|
+
* cannot read (Credential Manager, DPAPI, plaintext config), copy it into the
|
|
368
|
+
* ACL-protected machine key file. The very next hook invocation after this
|
|
369
|
+
* code ships repairs every already-broken machine — no re-enrollment needed.
|
|
370
|
+
*/
|
|
371
|
+
function maybeMirrorShieldKeyToMachineFile(shieldId, shieldKey) {
|
|
372
|
+
if (machineFileMirrorAttempted || process.platform !== 'win32')
|
|
373
|
+
return;
|
|
374
|
+
machineFileMirrorAttempted = true;
|
|
375
|
+
try {
|
|
376
|
+
if ((0, machineKeyFile_1.readShieldKeyFromMachineFile)(shieldId) === shieldKey)
|
|
377
|
+
return; // mirror current
|
|
378
|
+
(0, machineKeyFile_1.saveShieldKeyToMachineFile)(shieldId, shieldKey); // read-back verified inside
|
|
379
|
+
}
|
|
380
|
+
catch { /* mirroring is opportunistic — primary stores keep working */ }
|
|
381
|
+
}
|
|
361
382
|
/** Merge saved ~/.fullcourtdefense.yml + env + optional CLI flag overrides. */
|
|
362
383
|
function resolveCliCredentials(config, overrides = {}, options = {}) {
|
|
363
384
|
// Shield key: same precedence as the old || chain, evaluated stepwise so
|
|
@@ -388,6 +409,16 @@ function resolveCliCredentials(config, overrides = {}, options = {}) {
|
|
|
388
409
|
if (shieldKey)
|
|
389
410
|
shieldKeySource = 'native_store';
|
|
390
411
|
}
|
|
412
|
+
if (!shieldKey && resolvedShieldId) {
|
|
413
|
+
// S4U/task contexts (daemon, watchdog): Credential Manager and user-scope
|
|
414
|
+
// DPAPI are both sealed by the user's password-derived master key, which
|
|
415
|
+
// S4U logon sessions do not have — the read above returns nothing there.
|
|
416
|
+
// The machine key file is protected by NTFS ACL (SID-based, which S4U
|
|
417
|
+
// does carry) and is mirrored by interactive-session processes below.
|
|
418
|
+
shieldKey = (0, machineKeyFile_1.readShieldKeyFromMachineFile)(resolvedShieldId);
|
|
419
|
+
if (shieldKey)
|
|
420
|
+
shieldKeySource = 'machine_file';
|
|
421
|
+
}
|
|
391
422
|
if (!shieldKey && !options.skipDpapi && config.shieldKeyDpapi) {
|
|
392
423
|
dpapiAttempted = true;
|
|
393
424
|
shieldKey = unprotectShieldKeyForCurrentWindowsUser(config.shieldKeyDpapi);
|
|
@@ -417,6 +448,14 @@ function resolveCliCredentials(config, overrides = {}, options = {}) {
|
|
|
417
448
|
&& (shieldKeySource === 'dpapi' || shieldKeySource === 'plaintext_config')) {
|
|
418
449
|
maybeMigrateShieldKeyToNativeStore(resolvedShieldId, shieldKey);
|
|
419
450
|
}
|
|
451
|
+
// S4U mirror self-heal: any resolution through a store the S4U daemon
|
|
452
|
+
// cannot read keeps the machine key file current for it. Skipped on the
|
|
453
|
+
// shell-free path (skipDpapi): a first-time mirror write spawns
|
|
454
|
+
// whoami/icacls for the ACL, which that path must never do.
|
|
455
|
+
if (shieldKey && resolvedShieldId && !options.skipDpapi
|
|
456
|
+
&& (shieldKeySource === 'native_store' || shieldKeySource === 'dpapi' || shieldKeySource === 'plaintext_config')) {
|
|
457
|
+
maybeMirrorShieldKeyToMachineFile(resolvedShieldId, shieldKey);
|
|
458
|
+
}
|
|
420
459
|
return {
|
|
421
460
|
apiKey: overrides.apiKey
|
|
422
461
|
|| config.apiKey
|
|
@@ -499,6 +538,10 @@ function writeSetupConfig(target, input) {
|
|
|
499
538
|
// where the native binding is blocked keep their PowerShell path.
|
|
500
539
|
if (input.shieldKey && input.shieldId) {
|
|
501
540
|
(0, credentialStore_1.saveShieldKeyNative)(input.shieldId, input.shieldKey);
|
|
541
|
+
// S4U mirror: the daemon/watchdog tasks cannot read Credential Manager or
|
|
542
|
+
// user-scope DPAPI (S4U logon has no password-derived master key) — give
|
|
543
|
+
// them the ACL-protected file at enrollment so they are never key-less.
|
|
544
|
+
(0, machineKeyFile_1.saveShieldKeyToMachineFile)(input.shieldId, input.shieldKey);
|
|
502
545
|
}
|
|
503
546
|
const protectedShieldKey = input.shieldKey && process.platform === 'win32'
|
|
504
547
|
? protectShieldKeyForCurrentWindowsUser(input.shieldKey)
|
package/dist/distress.d.ts
CHANGED
|
@@ -42,6 +42,10 @@ export declare const DISTRESS: {
|
|
|
42
42
|
readonly NATIVE_STORE_UNAVAILABLE: "native_store_unavailable";
|
|
43
43
|
/** Native Credential Manager write/verify failed. */
|
|
44
44
|
readonly NATIVE_STORE_FAILED: "native_store_failed";
|
|
45
|
+
/** S4U-safe machine key file write/verify failed (daemon may stay credential-less). */
|
|
46
|
+
readonly MACHINE_KEYFILE_FAILED: "machine_keyfile_failed";
|
|
47
|
+
/** Machine key file kept, but the owner-only ACL could not be applied. */
|
|
48
|
+
readonly MACHINE_KEYFILE_ACL_FAILED: "machine_keyfile_acl_failed";
|
|
45
49
|
/** Fail-closed engaged: hook is BLOCKING user actions because the policy gate is persistently unreachable. */
|
|
46
50
|
readonly HOOK_FAIL_CLOSED: "hook_fail_closed";
|
|
47
51
|
/** Anything nobody predicted — reported via reportUnexpected(). */
|
package/dist/distress.js
CHANGED
|
@@ -86,6 +86,10 @@ exports.DISTRESS = {
|
|
|
86
86
|
NATIVE_STORE_UNAVAILABLE: 'native_store_unavailable',
|
|
87
87
|
/** Native Credential Manager write/verify failed. */
|
|
88
88
|
NATIVE_STORE_FAILED: 'native_store_failed',
|
|
89
|
+
/** S4U-safe machine key file write/verify failed (daemon may stay credential-less). */
|
|
90
|
+
MACHINE_KEYFILE_FAILED: 'machine_keyfile_failed',
|
|
91
|
+
/** Machine key file kept, but the owner-only ACL could not be applied. */
|
|
92
|
+
MACHINE_KEYFILE_ACL_FAILED: 'machine_keyfile_acl_failed',
|
|
89
93
|
/** Fail-closed engaged: hook is BLOCKING user actions because the policy gate is persistently unreachable. */
|
|
90
94
|
HOOK_FAIL_CLOSED: 'hook_fail_closed',
|
|
91
95
|
/** Anything nobody predicted — reported via reportUnexpected(). */
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export declare function machineKeyFilePath(): string;
|
|
2
|
+
/**
|
|
3
|
+
* Read the shield key mirrored for S4U/task contexts. Plain in-process file
|
|
4
|
+
* read — safe for the shell-free resolution path. Undefined when absent,
|
|
5
|
+
* unreadable, malformed, or recorded for a different shield.
|
|
6
|
+
*/
|
|
7
|
+
export declare function readShieldKeyFromMachineFile(shieldId: string): string | undefined;
|
|
8
|
+
/**
|
|
9
|
+
* Mirror the shield key for S4U/task contexts, read-back verified. Called
|
|
10
|
+
* from contexts that already hold the decrypted key (enrollment, and the
|
|
11
|
+
* resolution self-heal in config.ts). Returns false on any failure — the
|
|
12
|
+
* caller's primary stores are untouched either way.
|
|
13
|
+
*/
|
|
14
|
+
export declare function saveShieldKeyToMachineFile(shieldId: string, shieldKey: string): boolean;
|
|
15
|
+
/** Remove the mirror (unenroll / re-enroll cleanup). Best-effort. */
|
|
16
|
+
export declare function deleteMachineKeyFile(): void;
|
|
17
|
+
/**
|
|
18
|
+
* Deep self-test probe: write + read-back + ACL inspection with the exact
|
|
19
|
+
* code paths used for the shield key. Proves the S4U fallback store works
|
|
20
|
+
* end-to-end on THIS machine.
|
|
21
|
+
*/
|
|
22
|
+
export declare function machineKeyFileProbe(): {
|
|
23
|
+
ok: boolean;
|
|
24
|
+
detail: string;
|
|
25
|
+
};
|
|
@@ -0,0 +1,226 @@
|
|
|
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.machineKeyFilePath = machineKeyFilePath;
|
|
37
|
+
exports.readShieldKeyFromMachineFile = readShieldKeyFromMachineFile;
|
|
38
|
+
exports.saveShieldKeyToMachineFile = saveShieldKeyToMachineFile;
|
|
39
|
+
exports.deleteMachineKeyFile = deleteMachineKeyFile;
|
|
40
|
+
exports.machineKeyFileProbe = machineKeyFileProbe;
|
|
41
|
+
const child_process_1 = require("child_process");
|
|
42
|
+
const fs = __importStar(require("fs"));
|
|
43
|
+
const os = __importStar(require("os"));
|
|
44
|
+
const path = __importStar(require("path"));
|
|
45
|
+
const distress_1 = require("./distress");
|
|
46
|
+
/**
|
|
47
|
+
* Machine key file — the S4U-safe credential fallback.
|
|
48
|
+
*
|
|
49
|
+
* WHY (the 1.22.2 S4U regression): the daemon/watchdog scheduled tasks run
|
|
50
|
+
* with the S4U logon type — the only supported windowless way to run a
|
|
51
|
+
* console app from Task Scheduler (see daemon.ts). But an S4U logon session
|
|
52
|
+
* carries the user's SID WITHOUT the user's password-derived secrets, so both
|
|
53
|
+
* credential stores fail inside it: user-scope DPAPI cannot derive its master
|
|
54
|
+
* key, and Credential Manager entries (themselves DPAPI-sealed) cannot be
|
|
55
|
+
* opened. The daemon therefore resolved no shield key and every bundle poll
|
|
56
|
+
* 401'd — "auth_broken: bundle fetch HTTP 401 (no shield key available)" —
|
|
57
|
+
* on every Windows machine running 1.22.2+, silently (the same failure
|
|
58
|
+
* prevented the distress ledger from being uploaded).
|
|
59
|
+
*
|
|
60
|
+
* File ACLs, unlike DPAPI, are enforced against the token's SID — which the
|
|
61
|
+
* S4U session DOES carry. So processes in the user's interactive session
|
|
62
|
+
* (enrollment, hooks), which can read the real stores, mirror the key into a
|
|
63
|
+
* file locked to the user + SYSTEM, and the daemon reads that file where
|
|
64
|
+
* DPAPI is unavailable.
|
|
65
|
+
*
|
|
66
|
+
* SECURITY MODEL: at-rest protection is the NTFS ACL (inheritance stripped;
|
|
67
|
+
* only the owning user's SID and SYSTEM are granted). Against a same-user
|
|
68
|
+
* attacker this is equivalent to Credential Manager — CredRead is open to any
|
|
69
|
+
* process running as the user anyway — and it matches the protection level of
|
|
70
|
+
* the long-standing `shieldKey` plaintext-config fallback in
|
|
71
|
+
* ~/.fullcourtdefense.yml. Offline-disk protection comes from BitLocker, as
|
|
72
|
+
* for every other file. The key is stored base64-wrapped only to keep it out
|
|
73
|
+
* of casual text greps; that is labeling, not encryption.
|
|
74
|
+
*
|
|
75
|
+
* Windows-only: the S4U problem this solves is Windows-specific, and keeping
|
|
76
|
+
* other platforms untouched keeps the change surgical (same policy as
|
|
77
|
+
* credentialStore.ts).
|
|
78
|
+
*/
|
|
79
|
+
const FILE_VERSION = 1;
|
|
80
|
+
function machineKeyFilePath() {
|
|
81
|
+
return path.join(os.homedir(), '.fullcourtdefense', 'machine-key.json');
|
|
82
|
+
}
|
|
83
|
+
/** Cached `whoami /user` SID — one child process per process lifetime, max. */
|
|
84
|
+
let cachedUserSid;
|
|
85
|
+
function currentUserSid() {
|
|
86
|
+
if (cachedUserSid !== undefined)
|
|
87
|
+
return cachedUserSid;
|
|
88
|
+
try {
|
|
89
|
+
// CSV row: "user","DOMAIN\name","S-1-5-21-..." — the SID is the last field.
|
|
90
|
+
const csv = (0, child_process_1.execFileSync)('whoami', ['/user', '/fo', 'csv'], {
|
|
91
|
+
encoding: 'utf8', windowsHide: true, timeout: 10_000,
|
|
92
|
+
});
|
|
93
|
+
const match = csv.match(/"(S-1-5-[\d-]+)"/);
|
|
94
|
+
cachedUserSid = match ? match[1] : null;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
cachedUserSid = null;
|
|
98
|
+
}
|
|
99
|
+
return cachedUserSid;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Lock the file to the owning user + SYSTEM via icacls (a plain Windows
|
|
103
|
+
* executable — no PowerShell, nothing for EDR/CLM to constrain). Returns
|
|
104
|
+
* false when the ACL could not be applied; the caller decides whether the
|
|
105
|
+
* default user-profile ACL (other users already denied) is acceptable.
|
|
106
|
+
*/
|
|
107
|
+
function applyOwnerOnlyAcl(file) {
|
|
108
|
+
const sid = currentUserSid();
|
|
109
|
+
if (!sid)
|
|
110
|
+
return false;
|
|
111
|
+
try {
|
|
112
|
+
(0, child_process_1.execFileSync)('icacls', [file, '/inheritance:r', '/grant:r', `*${sid}:F`, '*S-1-5-18:F'], {
|
|
113
|
+
stdio: 'ignore', windowsHide: true, timeout: 10_000,
|
|
114
|
+
});
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Read the shield key mirrored for S4U/task contexts. Plain in-process file
|
|
123
|
+
* read — safe for the shell-free resolution path. Undefined when absent,
|
|
124
|
+
* unreadable, malformed, or recorded for a different shield.
|
|
125
|
+
*/
|
|
126
|
+
function readShieldKeyFromMachineFile(shieldId) {
|
|
127
|
+
if (process.platform !== 'win32' || !shieldId)
|
|
128
|
+
return undefined;
|
|
129
|
+
try {
|
|
130
|
+
const parsed = JSON.parse(fs.readFileSync(machineKeyFilePath(), 'utf8'));
|
|
131
|
+
if (!parsed || parsed.v !== FILE_VERSION || parsed.shieldId !== shieldId || !parsed.key)
|
|
132
|
+
return undefined;
|
|
133
|
+
const key = Buffer.from(parsed.key, 'base64').toString('utf8');
|
|
134
|
+
return key || undefined;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Mirror the shield key for S4U/task contexts, read-back verified. Called
|
|
142
|
+
* from contexts that already hold the decrypted key (enrollment, and the
|
|
143
|
+
* resolution self-heal in config.ts). Returns false on any failure — the
|
|
144
|
+
* caller's primary stores are untouched either way.
|
|
145
|
+
*/
|
|
146
|
+
function saveShieldKeyToMachineFile(shieldId, shieldKey) {
|
|
147
|
+
if (process.platform !== 'win32' || !shieldId || !shieldKey)
|
|
148
|
+
return false;
|
|
149
|
+
const file = machineKeyFilePath();
|
|
150
|
+
try {
|
|
151
|
+
const contents = {
|
|
152
|
+
v: FILE_VERSION,
|
|
153
|
+
shieldId,
|
|
154
|
+
key: Buffer.from(shieldKey, 'utf8').toString('base64'),
|
|
155
|
+
savedAt: new Date().toISOString(),
|
|
156
|
+
};
|
|
157
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
158
|
+
fs.writeFileSync(file, JSON.stringify(contents, null, 2), 'utf8');
|
|
159
|
+
if (!applyOwnerOnlyAcl(file)) {
|
|
160
|
+
// The file lives inside the user profile, where other non-admin users
|
|
161
|
+
// are already denied — keep the mirror (a credential-less daemon is the
|
|
162
|
+
// worse failure) but surface the weaker-than-intended ACL to the fleet.
|
|
163
|
+
(0, distress_1.reportDistress)('credentials', distress_1.DISTRESS.MACHINE_KEYFILE_ACL_FAILED, 'icacls owner-only ACL could not be applied; file kept with profile-default ACL');
|
|
164
|
+
}
|
|
165
|
+
const verified = readShieldKeyFromMachineFile(shieldId) === shieldKey;
|
|
166
|
+
if (!verified) {
|
|
167
|
+
(0, distress_1.reportDistress)('credentials', distress_1.DISTRESS.MACHINE_KEYFILE_FAILED, 'write succeeded but read-back returned a different value');
|
|
168
|
+
try {
|
|
169
|
+
fs.unlinkSync(file);
|
|
170
|
+
}
|
|
171
|
+
catch { /* best effort */ }
|
|
172
|
+
}
|
|
173
|
+
return verified;
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
(0, distress_1.reportDistress)('credentials', distress_1.DISTRESS.MACHINE_KEYFILE_FAILED, `write failed: ${error instanceof Error ? error.message.slice(0, 120) : String(error)}`);
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** Remove the mirror (unenroll / re-enroll cleanup). Best-effort. */
|
|
181
|
+
function deleteMachineKeyFile() {
|
|
182
|
+
try {
|
|
183
|
+
fs.unlinkSync(machineKeyFilePath());
|
|
184
|
+
}
|
|
185
|
+
catch { /* absent — fine */ }
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Deep self-test probe: write + read-back + ACL inspection with the exact
|
|
189
|
+
* code paths used for the shield key. Proves the S4U fallback store works
|
|
190
|
+
* end-to-end on THIS machine.
|
|
191
|
+
*/
|
|
192
|
+
function machineKeyFileProbe() {
|
|
193
|
+
if (process.platform !== 'win32')
|
|
194
|
+
return { ok: true, detail: 'not applicable (non-Windows)' };
|
|
195
|
+
const existing = fs.existsSync(machineKeyFilePath());
|
|
196
|
+
const probeShieldId = `selftest-${Date.now()}`;
|
|
197
|
+
const probeKey = `fcd-machinefile-probe-${Date.now()}`;
|
|
198
|
+
// Never disturb a real mirror: probe against a sibling path by swapping the
|
|
199
|
+
// real read/write helpers' target through a temp copy of the logic.
|
|
200
|
+
const probeFile = path.join(path.dirname(machineKeyFilePath()), `machine-key.selftest-${process.pid}.json`);
|
|
201
|
+
try {
|
|
202
|
+
fs.mkdirSync(path.dirname(probeFile), { recursive: true });
|
|
203
|
+
fs.writeFileSync(probeFile, JSON.stringify({
|
|
204
|
+
v: FILE_VERSION, shieldId: probeShieldId,
|
|
205
|
+
key: Buffer.from(probeKey, 'utf8').toString('base64'),
|
|
206
|
+
savedAt: new Date().toISOString(),
|
|
207
|
+
}, null, 2), 'utf8');
|
|
208
|
+
const aclOk = applyOwnerOnlyAcl(probeFile);
|
|
209
|
+
const parsed = JSON.parse(fs.readFileSync(probeFile, 'utf8'));
|
|
210
|
+
const readOk = Buffer.from(parsed.key, 'base64').toString('utf8') === probeKey;
|
|
211
|
+
if (!readOk)
|
|
212
|
+
return { ok: false, detail: 'roundtrip read returned a different value' };
|
|
213
|
+
return aclOk
|
|
214
|
+
? { ok: true, detail: `write + owner-only ACL + read roundtrip OK${existing ? ' (live mirror present)' : ''}` }
|
|
215
|
+
: { ok: true, detail: 'roundtrip OK; owner-only ACL not applied (profile-default ACL in effect)' };
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
return { ok: false, detail: `probe failed: ${error instanceof Error ? error.message.slice(0, 160) : String(error)}` };
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
try {
|
|
222
|
+
fs.unlinkSync(probeFile);
|
|
223
|
+
}
|
|
224
|
+
catch { /* best effort */ }
|
|
225
|
+
}
|
|
226
|
+
}
|
package/dist/runtimeConfig.d.ts
CHANGED
|
@@ -86,6 +86,27 @@ export interface EffectiveBundle extends RuntimeBundle {
|
|
|
86
86
|
* or a 'default' marker so the caller can apply its local fallback.
|
|
87
87
|
*/
|
|
88
88
|
export declare function getRuntimeBundle(input: FetchBundleInput): Promise<EffectiveBundle>;
|
|
89
|
+
export interface CredentialValidationResult {
|
|
90
|
+
ok: boolean;
|
|
91
|
+
/** true when the backend answered 401/403 — the key is WRONG, not offline. */
|
|
92
|
+
authRejected: boolean;
|
|
93
|
+
detail: string;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Prove the machine's credentials actually WORK against the backend — a
|
|
97
|
+
* direct, cache-bypassing bundle fetch that exposes the HTTP outcome.
|
|
98
|
+
* getRuntimeBundle() deliberately never fails (cache/default fallback keeps
|
|
99
|
+
* enforcement alive offline), which is exactly wrong for onboarding/doctor:
|
|
100
|
+
* "resolved a key locally" is not "the backend accepts it". The 8/12 incident
|
|
101
|
+
* machine printed "Already enrolled ✓" while every hook call 401'd —
|
|
102
|
+
* validation must separate rejected (re-enroll now) from unreachable (fine,
|
|
103
|
+
* cached stance applies).
|
|
104
|
+
*/
|
|
105
|
+
export declare function validateShieldCredentials(input: {
|
|
106
|
+
apiUrl: string;
|
|
107
|
+
shieldId: string;
|
|
108
|
+
shieldKey?: string;
|
|
109
|
+
}): Promise<CredentialValidationResult>;
|
|
89
110
|
/**
|
|
90
111
|
* Admin scan-folder choices from the most recent cached bundle (any shield):
|
|
91
112
|
* extra folders to add and default folders the admin removed. Read-only and
|
package/dist/runtimeConfig.js
CHANGED
|
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.getRuntimeBundle = getRuntimeBundle;
|
|
37
|
+
exports.validateShieldCredentials = validateShieldCredentials;
|
|
37
38
|
exports.getCachedScanRootOverrides = getCachedScanRootOverrides;
|
|
38
39
|
exports.getCachedExtraScanRoots = getCachedExtraScanRoots;
|
|
39
40
|
const fs = __importStar(require("fs"));
|
|
@@ -157,6 +158,44 @@ async function getRuntimeBundle(input) {
|
|
|
157
158
|
}
|
|
158
159
|
return { mode: 'block', version: '', source: 'default' };
|
|
159
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Prove the machine's credentials actually WORK against the backend — a
|
|
163
|
+
* direct, cache-bypassing bundle fetch that exposes the HTTP outcome.
|
|
164
|
+
* getRuntimeBundle() deliberately never fails (cache/default fallback keeps
|
|
165
|
+
* enforcement alive offline), which is exactly wrong for onboarding/doctor:
|
|
166
|
+
* "resolved a key locally" is not "the backend accepts it". The 8/12 incident
|
|
167
|
+
* machine printed "Already enrolled ✓" while every hook call 401'd —
|
|
168
|
+
* validation must separate rejected (re-enroll now) from unreachable (fine,
|
|
169
|
+
* cached stance applies).
|
|
170
|
+
*/
|
|
171
|
+
async function validateShieldCredentials(input) {
|
|
172
|
+
if (!input.shieldKey) {
|
|
173
|
+
return { ok: false, authRejected: false, detail: 'no shield key resolved on this machine' };
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
const params = new URLSearchParams({ shieldId: input.shieldId });
|
|
177
|
+
const resp = await fetch(`${input.apiUrl}/api/cli/bundle?${params.toString()}`, {
|
|
178
|
+
method: 'GET',
|
|
179
|
+
headers: { 'Content-Type': 'application/json', 'x-shield-key': input.shieldKey },
|
|
180
|
+
signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS),
|
|
181
|
+
});
|
|
182
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
183
|
+
(0, distress_1.reportDistress)('bundle', distress_1.DISTRESS.AUTH_BROKEN, `credential validation HTTP ${resp.status}`);
|
|
184
|
+
return { ok: false, authRejected: true, detail: `backend rejected the shield key (HTTP ${resp.status})` };
|
|
185
|
+
}
|
|
186
|
+
if (!resp.ok) {
|
|
187
|
+
return { ok: false, authRejected: false, detail: `backend answered HTTP ${resp.status}` };
|
|
188
|
+
}
|
|
189
|
+
return { ok: true, authRejected: false, detail: 'backend accepted the shield key' };
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
return {
|
|
193
|
+
ok: false,
|
|
194
|
+
authRejected: false,
|
|
195
|
+
detail: `control plane unreachable: ${error instanceof Error ? error.message : String(error)}`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
160
199
|
/**
|
|
161
200
|
* Admin scan-folder choices from the most recent cached bundle (any shield):
|
|
162
201
|
* extra folders to add and default folders the admin removed. Read-only and
|
package/dist/selfTest.js
CHANGED
|
@@ -5,6 +5,7 @@ exports.summarizeSelfTest = summarizeSelfTest;
|
|
|
5
5
|
const child_process_1 = require("child_process");
|
|
6
6
|
const config_1 = require("./config");
|
|
7
7
|
const credentialStore_1 = require("./credentialStore");
|
|
8
|
+
const machineKeyFile_1 = require("./machineKeyFile");
|
|
8
9
|
const runtimeConfig_1 = require("./runtimeConfig");
|
|
9
10
|
const integrity_1 = require("./integrity");
|
|
10
11
|
const selfUpdate_1 = require("./selfUpdate");
|
|
@@ -52,6 +53,12 @@ async function runDeepSelfTest(input) {
|
|
|
52
53
|
// binding and the rollback path to ≤1.21.33.
|
|
53
54
|
const probe = (0, config_1.dpapiRoundtripProbe)();
|
|
54
55
|
add({ id: 'dpapi_roundtrip', label: 'Credential store (DPAPI) roundtrip', ok: probe.ok, detail: probe.detail });
|
|
56
|
+
// 4. Machine key file — the S4U-safe mirror the daemon/watchdog tasks
|
|
57
|
+
// read (Credential Manager and DPAPI are both sealed to S4U sessions).
|
|
58
|
+
// Without it, a daemon under the windowless S4U principal is key-less
|
|
59
|
+
// and every bundle poll 401s.
|
|
60
|
+
const keyFile = (0, machineKeyFile_1.machineKeyFileProbe)();
|
|
61
|
+
add({ id: 'machine_key_file', label: 'Machine key file (S4U mirror) roundtrip', ok: keyFile.ok, detail: keyFile.detail });
|
|
55
62
|
}
|
|
56
63
|
// 3. Credentials resolved on THIS machine right now.
|
|
57
64
|
const trace = (() => {
|
package/dist/version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullcourtdefense-cli",
|
|
3
|
-
"version": "1.22.
|
|
3
|
+
"version": "1.22.6",
|
|
4
4
|
"description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -54,6 +54,8 @@
|
|
|
54
54
|
"test:update-loop": "npm run build && node scripts/test-update-loop-detection.js",
|
|
55
55
|
"test:bricked-rescue": "npm run build && node scripts/test-bricked-machine-rescue.js",
|
|
56
56
|
"test:native-credstore": "npm run build && node scripts/test-native-credential-store.js",
|
|
57
|
+
"test:machine-key-file": "npm run build && node scripts/test-machine-key-file.js",
|
|
58
|
+
"test:auth-rejection": "npm run build && node scripts/test-auth-rejection.js",
|
|
57
59
|
"test:real-life": "npm run build && node scripts/test-real-life-scenarios.js",
|
|
58
60
|
"test:node-updater": "npm run build && node scripts/test-node-updater.js",
|
|
59
61
|
"test:desktop-chat-guard": "npm run build && node scripts/test-desktop-chat-guard.js",
|