fullcourtdefense-cli 1.22.5 → 1.22.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.js +62 -9
- 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/config.d.ts +1 -1
- package/dist/config.js +43 -0
- package/dist/distress.d.ts +12 -0
- package/dist/distress.js +30 -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/selfUpdate.d.ts +10 -0
- package/dist/selfUpdate.js +42 -1
- package/dist/version.json +1 -1
- package/package.json +4 -1
package/dist/commands/daemon.js
CHANGED
|
@@ -385,6 +385,10 @@ async function runDaemon(args, config) {
|
|
|
385
385
|
if (postmortem) {
|
|
386
386
|
log(`Previous daemon (v${postmortem.version || '?'}) died UNCLEANLY — last alive ${postmortem.lastAliveAt || 'unknown'}, no shutdown recorded. Likely killed externally (EDR/AV, OOM, or forced termination). Post-mortem recorded; reporting upstream.`);
|
|
387
387
|
}
|
|
388
|
+
// A fresh daemon runs right after every successful MSI update: if a
|
|
389
|
+
// previously-reported update loop has been resolved by this version, clear
|
|
390
|
+
// it so heartbeats stop shipping it as live distress.
|
|
391
|
+
(0, selfUpdate_1.clearResolvedUpdateLoop)(cliVersion(), log);
|
|
388
392
|
// --- state ---------------------------------------------------------------
|
|
389
393
|
const watchers = new Map(); // directory -> watcher
|
|
390
394
|
const rootWatchers = new Map(); // admin protection roots -> recursive watcher
|
|
@@ -399,18 +403,33 @@ async function runDaemon(args, config) {
|
|
|
399
403
|
let autoUpdatePolicy;
|
|
400
404
|
/** Heartbeat ticks spent without full credentials (drives recovery cadence). */
|
|
401
405
|
let credRecoveryTicks = 0;
|
|
406
|
+
/** Enrollment-file mtime at the last recovery attempt — a change means the
|
|
407
|
+
* user just ran login/onboard and fresh credentials are waiting. */
|
|
408
|
+
let lastEnrollmentMtimeMs = 0;
|
|
402
409
|
/**
|
|
403
410
|
* A daemon that starts without a usable shield key (DPAPI decrypt blocked,
|
|
404
411
|
* config written after start by `login`) must NOT stay credential-less
|
|
405
412
|
* forever — that silences telemetry AND auto-update, stranding the machine.
|
|
406
413
|
* Retry quickly right after start, then hourly (each retry may spawn
|
|
407
|
-
* PowerShell for DPAPI, which we keep rare on machines that block it)
|
|
414
|
+
* PowerShell for DPAPI, which we keep rare on machines that block it) —
|
|
415
|
+
* EXCEPT when ~/.fullcourtdefense.yml changed, which means a re-enrollment
|
|
416
|
+
* just happened: retry on the very next tick, so a repaired machine comes
|
|
417
|
+
* back within one heartbeat instead of up to an hour later (the 8/12
|
|
418
|
+
* incident: re-enroll fixed the hooks instantly while the daemon kept
|
|
419
|
+
* 401-ing for 16 more minutes).
|
|
408
420
|
*/
|
|
409
421
|
const recoverCredentialsIfMissing = () => {
|
|
410
422
|
if (creds.shieldId && creds.shieldKey)
|
|
411
423
|
return;
|
|
424
|
+
let enrollmentChanged = false;
|
|
425
|
+
try {
|
|
426
|
+
const mtime = fs.statSync((0, config_1.getHomeConfigPath)()).mtimeMs;
|
|
427
|
+
enrollmentChanged = mtime !== lastEnrollmentMtimeMs;
|
|
428
|
+
lastEnrollmentMtimeMs = mtime;
|
|
429
|
+
}
|
|
430
|
+
catch { /* no config file — the tick cadence below applies */ }
|
|
412
431
|
credRecoveryTicks += 1;
|
|
413
|
-
if (credRecoveryTicks > 3 && credRecoveryTicks % 12 !== 0)
|
|
432
|
+
if (!enrollmentChanged && credRecoveryTicks > 3 && credRecoveryTicks % 12 !== 0)
|
|
414
433
|
return;
|
|
415
434
|
try {
|
|
416
435
|
const fresh = (0, config_1.resolveCliCredentials)((0, config_1.loadConfig)(args.config), {
|
|
@@ -1407,6 +1426,7 @@ function windowsTaskRunsInteractive(taskName) {
|
|
|
1407
1426
|
* Upgrade an EXISTING (working) task to the windowless S4U principal. Tries
|
|
1408
1427
|
* ONLY the S4U XML — on failure (unelevated daemon) the current task is left
|
|
1409
1428
|
* untouched so autostart never regresses from "flashes" to "broken".
|
|
1429
|
+
* Returns true when the task now runs S4U.
|
|
1410
1430
|
*/
|
|
1411
1431
|
function upgradeTaskWindowless(taskName, s4uXml, logFn) {
|
|
1412
1432
|
const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
@@ -1423,12 +1443,40 @@ function upgradeTaskWindowless(taskName, s4uXml, logFn) {
|
|
|
1423
1443
|
});
|
|
1424
1444
|
if (created.status === 0) {
|
|
1425
1445
|
logFn(`Self-heal: "${taskName}" migrated to the windowless S4U principal (no more console-window flash).`);
|
|
1446
|
+
return true;
|
|
1426
1447
|
}
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1448
|
+
logFn(`Self-heal: could not migrate "${taskName}" to S4U (needs elevation) — asking the elevated updater task to do it.`);
|
|
1449
|
+
return false;
|
|
1450
|
+
}
|
|
1451
|
+
catch {
|
|
1452
|
+
return false; /* keep the existing working task */
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* The unelevated daemon cannot rewrite task principals — but the SYSTEM
|
|
1457
|
+
* "FullCourtDefense Updater" task can, and its stage-1 repair pass migrates
|
|
1458
|
+
* InteractiveToken tasks BEFORE any network work (so it succeeds even behind
|
|
1459
|
+
* a blocked egress). Kick it ONCE per daemon lifetime when self-heal could
|
|
1460
|
+
* not flip a task itself: same-boot windowless fix instead of waiting for
|
|
1461
|
+
* the 03:07 daily run (lptx1110 flashed CMD windows for a day because the
|
|
1462
|
+
* MSI's PowerShell migration was silently blocked and self-heal had no
|
|
1463
|
+
* elevation). The task grants Authenticated Users read+execute, so an
|
|
1464
|
+
* unelevated /Run works by design (same path as org-pushed upgrades).
|
|
1465
|
+
*/
|
|
1466
|
+
let updaterMigrationKicked = false;
|
|
1467
|
+
function kickUpdaterTaskForMigration(logFn) {
|
|
1468
|
+
if (updaterMigrationKicked)
|
|
1469
|
+
return;
|
|
1470
|
+
updaterMigrationKicked = true;
|
|
1471
|
+
try {
|
|
1472
|
+
const run = (0, child_process_1.spawnSync)('schtasks', ['/Run', '/TN', selfUpdate_1.MSI_UPDATER_TASK_NAME], {
|
|
1473
|
+
stdio: 'ignore', windowsHide: true, timeout: 20_000,
|
|
1474
|
+
});
|
|
1475
|
+
logFn(run.status === 0
|
|
1476
|
+
? `Self-heal: elevated updater task triggered — it migrates the tasks to S4U now (windowless within a minute).`
|
|
1477
|
+
: `Self-heal: updater task unavailable for the S4U migration — its next daily run (03:07) will migrate instead.`);
|
|
1430
1478
|
}
|
|
1431
|
-
catch { /*
|
|
1479
|
+
catch { /* best-effort — the daily schedule remains the backstop */ }
|
|
1432
1480
|
}
|
|
1433
1481
|
/** True when the per-user Run-key fallback still names wscript/a .vbs. */
|
|
1434
1482
|
function windowsRunKeyReferencesScriptHost() {
|
|
@@ -1561,13 +1609,18 @@ function ensureWindowsAutostartHealthy(logFn) {
|
|
|
1561
1609
|
}
|
|
1562
1610
|
// Migration (1.22.1 -> 1.22.2): InteractiveToken tasks flash a console
|
|
1563
1611
|
// window on every trigger. Upgrade healthy existing tasks to S4U; S4U-only
|
|
1564
|
-
// attempt so a denied re-registration never breaks a working task.
|
|
1612
|
+
// attempt so a denied re-registration never breaks a working task. When
|
|
1613
|
+
// unelevated self-heal cannot flip them, kick the SYSTEM updater task
|
|
1614
|
+
// once — its repair pass migrates with real elevation (1.22.7).
|
|
1615
|
+
let migrationDenied = false;
|
|
1565
1616
|
if (daemonTask.status === 0 && !legacyDaemon && windowsTaskRunsInteractive(TASK_NAME)) {
|
|
1566
|
-
upgradeTaskWindowless(TASK_NAME, buildDaemonTaskXml()[0], logFn);
|
|
1617
|
+
migrationDenied = !upgradeTaskWindowless(TASK_NAME, buildDaemonTaskXml()[0], logFn) || migrationDenied;
|
|
1567
1618
|
}
|
|
1568
1619
|
if (watchdogQuery.status === 0 && !legacyWatchdog && windowsTaskRunsInteractive(WATCHDOG_TASK_NAME)) {
|
|
1569
|
-
upgradeTaskWindowless(WATCHDOG_TASK_NAME, buildWatchdogTaskXml()[0], logFn);
|
|
1620
|
+
migrationDenied = !upgradeTaskWindowless(WATCHDOG_TASK_NAME, buildWatchdogTaskXml()[0], logFn) || migrationDenied;
|
|
1570
1621
|
}
|
|
1622
|
+
if (migrationDenied)
|
|
1623
|
+
kickUpdaterTaskForMigration(logFn);
|
|
1571
1624
|
// A stale Run-key fallback from an older install may still name wscript.
|
|
1572
1625
|
if (windowsRunKeyReferencesScriptHost()) {
|
|
1573
1626
|
(0, child_process_1.spawnSync)('reg', [
|
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:`);
|
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(). */
|
|
@@ -70,6 +74,14 @@ export declare function distressFile(): string;
|
|
|
70
74
|
export declare function reportDistress(component: string, code: string, detail?: string): void;
|
|
71
75
|
/** Structured capture for failures nobody predicted. */
|
|
72
76
|
export declare function reportUnexpected(component: string, error: unknown): void;
|
|
77
|
+
/**
|
|
78
|
+
* Remove a code from the ledger once its condition is PROVEN resolved (e.g.
|
|
79
|
+
* update_loop after the installed version reaches the looping target). Keeps
|
|
80
|
+
* the shipped ledger truthful: resolved incidents must stop riding along in
|
|
81
|
+
* heartbeats, where they read as live problems in the console and the fleet
|
|
82
|
+
* alerter. Returns true when something was actually cleared.
|
|
83
|
+
*/
|
|
84
|
+
export declare function clearDistress(code: string, component?: string): boolean;
|
|
73
85
|
/**
|
|
74
86
|
* Recent distress for the heartbeat: entries seen within `windowMs`
|
|
75
87
|
* (default 24h), newest last, capped for transport.
|
package/dist/distress.js
CHANGED
|
@@ -37,6 +37,7 @@ exports.DISTRESS = void 0;
|
|
|
37
37
|
exports.distressFile = distressFile;
|
|
38
38
|
exports.reportDistress = reportDistress;
|
|
39
39
|
exports.reportUnexpected = reportUnexpected;
|
|
40
|
+
exports.clearDistress = clearDistress;
|
|
40
41
|
exports.readDistressSnapshot = readDistressSnapshot;
|
|
41
42
|
exports.readDistressLedger = readDistressLedger;
|
|
42
43
|
const fs = __importStar(require("fs"));
|
|
@@ -86,6 +87,10 @@ exports.DISTRESS = {
|
|
|
86
87
|
NATIVE_STORE_UNAVAILABLE: 'native_store_unavailable',
|
|
87
88
|
/** Native Credential Manager write/verify failed. */
|
|
88
89
|
NATIVE_STORE_FAILED: 'native_store_failed',
|
|
90
|
+
/** S4U-safe machine key file write/verify failed (daemon may stay credential-less). */
|
|
91
|
+
MACHINE_KEYFILE_FAILED: 'machine_keyfile_failed',
|
|
92
|
+
/** Machine key file kept, but the owner-only ACL could not be applied. */
|
|
93
|
+
MACHINE_KEYFILE_ACL_FAILED: 'machine_keyfile_acl_failed',
|
|
89
94
|
/** Fail-closed engaged: hook is BLOCKING user actions because the policy gate is persistently unreachable. */
|
|
90
95
|
HOOK_FAIL_CLOSED: 'hook_fail_closed',
|
|
91
96
|
/** Anything nobody predicted — reported via reportUnexpected(). */
|
|
@@ -168,6 +173,31 @@ function reportUnexpected(component, error) {
|
|
|
168
173
|
const message = error instanceof Error ? error.message : String(error);
|
|
169
174
|
reportDistress(component, exports.DISTRESS.UNEXPECTED, message);
|
|
170
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* Remove a code from the ledger once its condition is PROVEN resolved (e.g.
|
|
178
|
+
* update_loop after the installed version reaches the looping target). Keeps
|
|
179
|
+
* the shipped ledger truthful: resolved incidents must stop riding along in
|
|
180
|
+
* heartbeats, where they read as live problems in the console and the fleet
|
|
181
|
+
* alerter. Returns true when something was actually cleared.
|
|
182
|
+
*/
|
|
183
|
+
function clearDistress(code, component) {
|
|
184
|
+
try {
|
|
185
|
+
const entries = readLedger();
|
|
186
|
+
const remaining = entries.filter(e => !(e.code === code && (!component || e.component === component)));
|
|
187
|
+
if (remaining.length === entries.length)
|
|
188
|
+
return false;
|
|
189
|
+
writeLedger(remaining);
|
|
190
|
+
try {
|
|
191
|
+
const line = `[${new Date().toISOString()}] DISTRESS ${code} resolved — cleared from ledger\n`;
|
|
192
|
+
fs.appendFileSync(path.join(stateDir(), 'daemon.log'), line, 'utf8');
|
|
193
|
+
}
|
|
194
|
+
catch { /* best-effort */ }
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
171
201
|
/**
|
|
172
202
|
* Recent distress for the heartbeat: entries seen within `windowMs`
|
|
173
203
|
* (default 24h), newest last, capped for transport.
|
|
@@ -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/selfUpdate.d.ts
CHANGED
|
@@ -56,6 +56,16 @@ export declare function modernizeUpdaterTask(log: (message: string) => void): vo
|
|
|
56
56
|
* fleet-visible evidence of WHY).
|
|
57
57
|
*/
|
|
58
58
|
export declare function readUpdaterLogTail(maxLines?: number): string[];
|
|
59
|
+
/**
|
|
60
|
+
* Clear update_loop distress once the machine PROVABLY moved past the loop:
|
|
61
|
+
* the installed version reached (or passed) the looping target, or the
|
|
62
|
+
* installed version changed at all since the attempts record was written.
|
|
63
|
+
* Called at daemon boot (a fresh daemon starts right after every MSI update)
|
|
64
|
+
* and when the bundle target is already satisfied. Without this, a resolved
|
|
65
|
+
* loop keeps shipping in the 24h ledger snapshot and reads as a live problem
|
|
66
|
+
* in the console. Cheap no-op when there is no attempts record.
|
|
67
|
+
*/
|
|
68
|
+
export declare function clearResolvedUpdateLoop(currentVersion: string | undefined, log?: (message: string) => void): void;
|
|
59
69
|
/**
|
|
60
70
|
* Upgrade this machine to targetVersion if it is newer than currentVersion.
|
|
61
71
|
* Cheap no-op when already current, when a kick is still pending, or when the
|
package/dist/selfUpdate.js
CHANGED
|
@@ -41,6 +41,7 @@ exports.buildUpdaterTaskCommand = buildUpdaterTaskCommand;
|
|
|
41
41
|
exports.updaterTaskNeedsModernization = updaterTaskNeedsModernization;
|
|
42
42
|
exports.modernizeUpdaterTask = modernizeUpdaterTask;
|
|
43
43
|
exports.readUpdaterLogTail = readUpdaterLogTail;
|
|
44
|
+
exports.clearResolvedUpdateLoop = clearResolvedUpdateLoop;
|
|
44
45
|
exports.maybeSelfUpdate = maybeSelfUpdate;
|
|
45
46
|
exports.installedMsiVersion = installedMsiVersion;
|
|
46
47
|
const fs = __importStar(require("fs"));
|
|
@@ -291,6 +292,42 @@ function recordUpdateAttempt(target, fromVersion) {
|
|
|
291
292
|
return 1;
|
|
292
293
|
}
|
|
293
294
|
}
|
|
295
|
+
/**
|
|
296
|
+
* Clear update_loop distress once the machine PROVABLY moved past the loop:
|
|
297
|
+
* the installed version reached (or passed) the looping target, or the
|
|
298
|
+
* installed version changed at all since the attempts record was written.
|
|
299
|
+
* Called at daemon boot (a fresh daemon starts right after every MSI update)
|
|
300
|
+
* and when the bundle target is already satisfied. Without this, a resolved
|
|
301
|
+
* loop keeps shipping in the 24h ledger snapshot and reads as a live problem
|
|
302
|
+
* in the console. Cheap no-op when there is no attempts record.
|
|
303
|
+
*/
|
|
304
|
+
function clearResolvedUpdateLoop(currentVersion, log) {
|
|
305
|
+
if (!currentVersion)
|
|
306
|
+
return;
|
|
307
|
+
try {
|
|
308
|
+
let record;
|
|
309
|
+
try {
|
|
310
|
+
record = JSON.parse(fs.readFileSync(updateAttemptsFile(), 'utf8'));
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
return; // nothing ever looped
|
|
314
|
+
}
|
|
315
|
+
if (!record || typeof record.target !== 'string')
|
|
316
|
+
return;
|
|
317
|
+
const reachedTarget = compareCliVersions(record.target, currentVersion) <= 0;
|
|
318
|
+
const versionMoved = typeof record.fromVersion === 'string' && record.fromVersion !== currentVersion;
|
|
319
|
+
if (!reachedTarget && !versionMoved)
|
|
320
|
+
return;
|
|
321
|
+
try {
|
|
322
|
+
fs.unlinkSync(updateAttemptsFile());
|
|
323
|
+
}
|
|
324
|
+
catch { /* best-effort */ }
|
|
325
|
+
if ((0, distress_1.clearDistress)(distress_1.DISTRESS.UPDATE_LOOP) && log) {
|
|
326
|
+
log(`Self-update: update loop resolved (installed ${currentVersion}, was looping toward ${record.target}) — distress cleared.`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
catch { /* resolution bookkeeping must never disturb the caller */ }
|
|
330
|
+
}
|
|
294
331
|
/** How many times an update to the same target was kicked without the version moving. */
|
|
295
332
|
const UPDATE_LOOP_THRESHOLD = 3;
|
|
296
333
|
let updateInFlightSince = 0;
|
|
@@ -310,8 +347,12 @@ function maybeSelfUpdate(input) {
|
|
|
310
347
|
return undefined;
|
|
311
348
|
if (input.busy)
|
|
312
349
|
return undefined;
|
|
313
|
-
if (compareCliVersions(input.targetVersion, input.currentVersion) <= 0)
|
|
350
|
+
if (compareCliVersions(input.targetVersion, input.currentVersion) <= 0) {
|
|
351
|
+
// Already current — if a loop was previously reported toward this (or an
|
|
352
|
+
// older) target, it is resolved now: stop shipping it as live distress.
|
|
353
|
+
clearResolvedUpdateLoop(input.currentVersion, log);
|
|
314
354
|
return undefined;
|
|
355
|
+
}
|
|
315
356
|
// A NEW target always gets an immediate attempt. The old global cooldown
|
|
316
357
|
// incorrectly suppressed 1.18.10 after a failed 1.18.9 task.
|
|
317
358
|
if (!input.force
|
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.7",
|
|
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",
|
|
@@ -63,6 +65,7 @@
|
|
|
63
65
|
"test:blocking-approval": "npm run build && node scripts/test-blocking-approval-drill.js",
|
|
64
66
|
"test:clipboard-scan": "npm run build && node scripts/test-clipboard-scan.js",
|
|
65
67
|
"test:no-script-host": "npm run build && node scripts/test-no-script-host.js",
|
|
68
|
+
"test:s4u-migration": "npm run build && node scripts/test-s4u-task-migration.js",
|
|
66
69
|
"build:msi": "powershell -NoProfile -ExecutionPolicy Bypass -File installer/windows/Build-Msi.ps1",
|
|
67
70
|
"prepublishOnly": "npm run build"
|
|
68
71
|
},
|