fullcourtdefense-cli 1.26.5 → 1.26.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/commands/daemon.d.ts +14 -0
  2. package/dist/commands/daemon.js +181 -28
  3. package/dist/commands/desktopChatGuard.js +5 -4
  4. package/dist/commands/deterministicGuard.d.ts +8 -0
  5. package/dist/commands/deterministicGuard.js +6 -0
  6. package/dist/commands/doctor.d.ts +4 -0
  7. package/dist/commands/doctor.js +99 -0
  8. package/dist/commands/hook.d.ts +1 -0
  9. package/dist/commands/hook.js +25 -2
  10. package/dist/commands/installClaudeHook.d.ts +2 -0
  11. package/dist/commands/installClaudeHook.js +4 -0
  12. package/dist/commands/installCursorHook.d.ts +8 -0
  13. package/dist/commands/installCursorHook.js +5 -0
  14. package/dist/commands/mcpGateway.js +234 -43
  15. package/dist/commands/onboard.d.ts +9 -0
  16. package/dist/commands/onboard.js +7 -0
  17. package/dist/commands/watchdog.d.ts +5 -2
  18. package/dist/commands/watchdog.js +34 -4
  19. package/dist/config.js +6 -1
  20. package/dist/daemonForensics.d.ts +27 -0
  21. package/dist/daemonForensics.js +27 -0
  22. package/dist/distress.d.ts +17 -0
  23. package/dist/distress.js +17 -0
  24. package/dist/hookIo.js +1 -0
  25. package/dist/index.js +6 -0
  26. package/dist/integrity.js +8 -0
  27. package/dist/localSafetySnapshot.d.ts +9 -0
  28. package/dist/localSafetySnapshot.js +58 -2
  29. package/dist/machineIdentity.js +70 -4
  30. package/dist/runtimeConfig.d.ts +12 -0
  31. package/dist/runtimeConfig.js +181 -7
  32. package/dist/selfTest.d.ts +10 -0
  33. package/dist/selfTest.js +21 -8
  34. package/dist/selfUpdate.d.ts +19 -4
  35. package/dist/selfUpdate.js +108 -12
  36. package/dist/telemetry.js +67 -2
  37. package/dist/verdictIpc.js +76 -30
  38. package/dist/verdictIpcClient.d.ts +27 -0
  39. package/dist/verdictIpcClient.js +68 -0
  40. package/dist/version.json +1 -1
  41. package/package.json +5 -1
@@ -73,5 +73,32 @@ export declare function recordUncleanDeathFromWatchdog(isPidAlive: (pid: number)
73
73
  export declare function touchDaemonAlive(): void;
74
74
  /** Record an intentional shutdown so the next boot knows this was NOT a kill. */
75
75
  export declare function recordCleanExit(reason: string): void;
76
+ /**
77
+ * A pid can lie in two ways: the daemon process exists but its event loop is
78
+ * WEDGED (never heartbeats, never serves the pipe, never polls the control
79
+ * plane), or the daemon died long ago and the OS RECYCLED its pid for an
80
+ * unrelated process. Both look "alive" to a pid check; both mean the machine
81
+ * has no working control plane. The alive-marker freshness plus a verdict-pipe
82
+ * probe tells them apart:
83
+ *
84
+ * marker fresh → healthy (heartbeats are running)
85
+ * marker stale + pipe answers → healthy (only the marker write fails)
86
+ * marker stale + pipe exists, silent → hung (kernel frees a named pipe when
87
+ * its owner dies, so a silent endpoint
88
+ * means a live-but-wedged owner)
89
+ * marker stale + no pipe endpoint → pid recycled (daemon is gone; do NOT
90
+ * kill the unrelated process)
91
+ */
92
+ export type DaemonHealthClass = 'dead' | 'healthy' | 'hung' | 'pid_reused' | 'indeterminate';
93
+ /** Marker older than this is stale — 4 missed 5-minute heartbeats. */
94
+ export declare function daemonHungAfterMs(): number;
95
+ export declare function classifyDaemonHealth(input: {
96
+ pidAlive: boolean;
97
+ pid: number | undefined;
98
+ marker: DaemonAliveMarker | undefined;
99
+ staleAfterMs: number;
100
+ pipeProbe: 'responsive' | 'unresponsive' | 'no-endpoint' | 'not-probed';
101
+ nowMs?: number;
102
+ }): DaemonHealthClass;
76
103
  /** Stamp the post-mortem as delivered so heartbeats stop re-sending it. */
77
104
  export declare function markPostmortemReported(): void;
@@ -43,6 +43,8 @@ exports.recordDaemonStart = recordDaemonStart;
43
43
  exports.recordUncleanDeathFromWatchdog = recordUncleanDeathFromWatchdog;
44
44
  exports.touchDaemonAlive = touchDaemonAlive;
45
45
  exports.recordCleanExit = recordCleanExit;
46
+ exports.daemonHungAfterMs = daemonHungAfterMs;
47
+ exports.classifyDaemonHealth = classifyDaemonHealth;
46
48
  exports.markPostmortemReported = markPostmortemReported;
47
49
  const fs = __importStar(require("fs"));
48
50
  const os = __importStar(require("os"));
@@ -178,6 +180,31 @@ function recordCleanExit(reason) {
178
180
  marker.cleanExit = { at: new Date().toISOString(), reason: reason.slice(0, 120) };
179
181
  writeJson(aliveMarkerFile(), marker);
180
182
  }
183
+ /** Marker older than this is stale — 4 missed 5-minute heartbeats. */
184
+ function daemonHungAfterMs() {
185
+ const env = Number(process.env.FCD_DAEMON_HUNG_AFTER_MS);
186
+ return Number.isFinite(env) && env > 0 ? env : 20 * 60_000;
187
+ }
188
+ function classifyDaemonHealth(input) {
189
+ if (!input.pidAlive || !input.pid)
190
+ return 'dead';
191
+ const marker = input.marker;
192
+ // No marker for this pid (marker deleted, or owned by a different pid):
193
+ // freshness cannot be judged — stay conservative, never kill on a guess.
194
+ if (!marker || marker.pid !== input.pid)
195
+ return 'indeterminate';
196
+ const last = Date.parse(marker.lastAliveAt || marker.startedAt || '');
197
+ const age = (input.nowMs ?? Date.now()) - last;
198
+ if (!Number.isFinite(age) || age <= input.staleAfterMs)
199
+ return 'healthy';
200
+ if (input.pipeProbe === 'responsive')
201
+ return 'healthy';
202
+ if (input.pipeProbe === 'unresponsive')
203
+ return 'hung';
204
+ if (input.pipeProbe === 'no-endpoint')
205
+ return 'pid_reused';
206
+ return 'indeterminate';
207
+ }
181
208
  /** Stamp the post-mortem as delivered so heartbeats stop re-sending it. */
182
209
  function markPostmortemReported() {
183
210
  const pm = readPostmortem();
@@ -54,6 +54,23 @@ export declare const DISTRESS: {
54
54
  * next tick — but while it lasted, remote actions were undeliverable.
55
55
  */
56
56
  readonly POLL_STALLED: "poll_stalled";
57
+ /**
58
+ * The watchdog found the daemon process alive but WEDGED — alive-marker
59
+ * stale past the bound and the verdict pipe silent — and recycled it.
60
+ */
61
+ readonly DAEMON_HUNG: "daemon_hung";
62
+ /**
63
+ * The daemon exited itself after repeated uncaught exceptions in a short
64
+ * window (process state suspect); the supervisor restarts it clean.
65
+ */
66
+ readonly UNCAUGHT_BURST: "uncaught_burst";
67
+ /**
68
+ * The telemetry spool could not be written (disk full, ACL, AV lock) — the
69
+ * machine is still enforcing, but its decisions are being LOST locally and
70
+ * will never reach the console. Deserves an admin's attention because a
71
+ * quiet machine and a machine that cannot record look identical otherwise.
72
+ */
73
+ readonly SPOOL_WRITE_FAILED: "spool_write_failed";
57
74
  /** Anything nobody predicted — reported via reportUnexpected(). */
58
75
  readonly UNEXPECTED: "unexpected_error";
59
76
  };
package/dist/distress.js CHANGED
@@ -99,6 +99,23 @@ exports.DISTRESS = {
99
99
  * next tick — but while it lasted, remote actions were undeliverable.
100
100
  */
101
101
  POLL_STALLED: 'poll_stalled',
102
+ /**
103
+ * The watchdog found the daemon process alive but WEDGED — alive-marker
104
+ * stale past the bound and the verdict pipe silent — and recycled it.
105
+ */
106
+ DAEMON_HUNG: 'daemon_hung',
107
+ /**
108
+ * The daemon exited itself after repeated uncaught exceptions in a short
109
+ * window (process state suspect); the supervisor restarts it clean.
110
+ */
111
+ UNCAUGHT_BURST: 'uncaught_burst',
112
+ /**
113
+ * The telemetry spool could not be written (disk full, ACL, AV lock) — the
114
+ * machine is still enforcing, but its decisions are being LOST locally and
115
+ * will never reach the console. Deserves an admin's attention because a
116
+ * quiet machine and a machine that cannot record look identical otherwise.
117
+ */
118
+ SPOOL_WRITE_FAILED: 'spool_write_failed',
102
119
  /** Anything nobody predicted — reported via reportUnexpected(). */
103
120
  UNEXPECTED: 'unexpected_error',
104
121
  };
package/dist/hookIo.js CHANGED
@@ -135,6 +135,7 @@ function mapHookFlags(flags) {
135
135
  shieldKey: flags['shield-key'],
136
136
  shadow: flags.shadow,
137
137
  enforce: flags.enforce,
138
+ fcdManaged: flags['fcd-managed'],
138
139
  failClosed: flags['fail-closed'],
139
140
  localOnly: flags['local-only'],
140
141
  timeout: flags.timeout,
package/dist/index.js CHANGED
@@ -138,6 +138,9 @@ function printHelp() {
138
138
  doctor First step. Checks outbound HTTPS access to FullCourtDefense.
139
139
  --perf measures this machine's real per-event overhead
140
140
  (hook latency, spawn floor, cmd-guard cost, disk footprint).
141
+ --health prints an mdatp-health-style per-subsystem status
142
+ table (enrollment, credentials, hooks, updater, tasks).
143
+ --edr prints the narrowest AV/EDR allowlist guidance for IT.
141
144
  login Enrolls this machine with a fleet enrollment token and saves
142
145
  per-machine Shield credentials to ~/.fullcourtdefense.yml.
143
146
  configure Legacy/manual setup: saves org API key, Organization ID,
@@ -519,6 +522,8 @@ async function main() {
519
522
  const args = {
520
523
  apiUrl: flags['api-url'],
521
524
  perf: flags.perf,
525
+ health: flags.health,
526
+ edr: flags.edr,
522
527
  };
523
528
  const { doctorCommand } = await Promise.resolve().then(() => __importStar(require('./commands/doctor')));
524
529
  await doctorCommand(args, config);
@@ -761,6 +766,7 @@ async function main() {
761
766
  json: flags.json,
762
767
  noDaemon: flags['no-daemon'],
763
768
  acceptDisclosure: flags['accept-disclosure'],
769
+ mode: flags.mode,
764
770
  };
765
771
  const { onboardCommand } = await Promise.resolve().then(() => __importStar(require('./commands/onboard')));
766
772
  await onboardCommand(args, config);
package/dist/integrity.js CHANGED
@@ -105,6 +105,12 @@ function getLocalIntegrityReport(options = {}) {
105
105
  }
106
106
  else if (cursorHooks.staleEntries > 0)
107
107
  reasons.push('cursor_hook_stale_path');
108
+ // A managed entry mutated to weaken enforcement (hand-added `--shadow true`):
109
+ // the hook ignores the flag on managed invocations, and the tamper is
110
+ // reported here so the fleet console sees the edit instead of it being
111
+ // silently neutralized.
112
+ if (cursorHooks.existed && (cursorHooks.weakenedEntries || 0) > 0)
113
+ reasons.push('cursor_hook_changed');
108
114
  const claudeHooks = (0, installClaudeHook_1.inspectClaudeManagedHooks)();
109
115
  if (!claudeHooks.existed || claudeHooks.managedEntries === 0) {
110
116
  if ((0, appDetection_1.isClaudeFamilyAppInstalled)())
@@ -112,6 +118,8 @@ function getLocalIntegrityReport(options = {}) {
112
118
  }
113
119
  else if (claudeHooks.staleEntries > 0)
114
120
  reasons.push('claude_hook_stale_path');
121
+ if (claudeHooks.existed && (claudeHooks.weakenedEntries || 0) > 0)
122
+ reasons.push('claude_hook_changed');
115
123
  const daemonRunning = isDaemonRunning();
116
124
  if (options.requireDaemon && !daemonRunning)
117
125
  reasons.push('daemon_not_running');
@@ -36,5 +36,14 @@ export declare function hookSnapshotIdentity(): {
36
36
  machineName: string;
37
37
  };
38
38
  export declare function clearLocalSafetySnapshotCache(input: Pick<LocalSafetySnapshotInput, 'apiUrl' | 'shieldId' | 'developerName' | 'machineName'>): void;
39
+ /**
40
+ * Remove EVERY Local Safety snapshot cache file, regardless of the identity
41
+ * that keyed it. Used on (re-)enrollment and explicit policy refresh: cache
42
+ * files are keyed by sha1(apiUrl|shieldId|developer|machine), so after a
43
+ * shield/identity change a targeted unlink can miss files written under an
44
+ * old key — leaving a stale (possibly enforcing) snapshot for whichever
45
+ * surface still resolves that old identity.
46
+ */
47
+ export declare function clearAllLocalSafetySnapshotCaches(): void;
39
48
  export declare function loadLocalSafetySnapshot(input: LocalSafetySnapshotInput): Promise<LocalSafetySnapshot | undefined>;
40
49
  export declare function snapshotToScanOptions(snapshot: LocalSafetySnapshot | undefined, extra?: Pick<LocalSafetyScanOptions, 'cwd' | 'inspectScripts'>): LocalSafetyScanOptions;
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.hookSnapshotIdentity = hookSnapshotIdentity;
37
37
  exports.clearLocalSafetySnapshotCache = clearLocalSafetySnapshotCache;
38
+ exports.clearAllLocalSafetySnapshotCaches = clearAllLocalSafetySnapshotCaches;
38
39
  exports.loadLocalSafetySnapshot = loadLocalSafetySnapshot;
39
40
  exports.snapshotToScanOptions = snapshotToScanOptions;
40
41
  const crypto = __importStar(require("crypto"));
@@ -64,10 +65,40 @@ function readCache(file) {
64
65
  function writeCache(file, snapshot) {
65
66
  try {
66
67
  fs.mkdirSync(path.dirname(file), { recursive: true });
67
- fs.writeFileSync(file, JSON.stringify({ loadedAt: Date.now(), snapshot }, null, 2), { encoding: 'utf8', mode: 0o600 });
68
+ // Atomic write-temp-rename: hooks read this file on their hot path — a
69
+ // torn read must never make a policy snapshot unparseable mid-refresh.
70
+ const tmp = `${file}.tmp-${process.pid}`;
71
+ fs.writeFileSync(tmp, JSON.stringify({ loadedAt: Date.now(), snapshot }, null, 2), { encoding: 'utf8', mode: 0o600 });
72
+ fs.renameSync(tmp, file);
68
73
  }
69
74
  catch { /* best-effort */ }
70
75
  }
76
+ /**
77
+ * Fallback when the derived cache key has no file: the freshest snapshot on
78
+ * disk under ANY key. Cache keys hash the caller's identity, and that
79
+ * identity has drifted across surfaces/versions (gateway --developer-name vs
80
+ * hook username@hostname vs normalized machine identity) — after an upgrade
81
+ * the old surface's cache would otherwise be invisible and an OFFLINE block
82
+ * machine would silently downgrade its catalog rules to warn. All snapshot
83
+ * files in this per-user dir belong to this machine's enrollment (enrollment
84
+ * clears them all), so the freshest one is always safe to serve.
85
+ */
86
+ function readFreshestAnyCache() {
87
+ try {
88
+ let best;
89
+ for (const name of fs.readdirSync(CACHE_DIR)) {
90
+ if (!/^local-safety-[0-9a-f]{40}\.json$/i.test(name))
91
+ continue;
92
+ const parsed = readCache(path.join(CACHE_DIR, name));
93
+ if (parsed && (!best || parsed.loadedAt > best.loadedAt))
94
+ best = parsed;
95
+ }
96
+ return best;
97
+ }
98
+ catch {
99
+ return undefined;
100
+ }
101
+ }
71
102
  function toRuleAction(value) {
72
103
  // Explicit 'block' is preserved (not treated as absent): warn-by-default
73
104
  // items (e.g. env_workspace) need an org's explicit block override to
@@ -135,9 +166,30 @@ function clearLocalSafetySnapshotCache(input) {
135
166
  }
136
167
  catch { /* cache may not exist */ }
137
168
  }
169
+ /**
170
+ * Remove EVERY Local Safety snapshot cache file, regardless of the identity
171
+ * that keyed it. Used on (re-)enrollment and explicit policy refresh: cache
172
+ * files are keyed by sha1(apiUrl|shieldId|developer|machine), so after a
173
+ * shield/identity change a targeted unlink can miss files written under an
174
+ * old key — leaving a stale (possibly enforcing) snapshot for whichever
175
+ * surface still resolves that old identity.
176
+ */
177
+ function clearAllLocalSafetySnapshotCaches() {
178
+ try {
179
+ for (const name of fs.readdirSync(CACHE_DIR)) {
180
+ if (/^local-safety-[0-9a-f]{40}\.json$/i.test(name)) {
181
+ try {
182
+ fs.unlinkSync(path.join(CACHE_DIR, name));
183
+ }
184
+ catch { /* best-effort */ }
185
+ }
186
+ }
187
+ }
188
+ catch { /* cache dir may not exist */ }
189
+ }
138
190
  async function loadLocalSafetySnapshot(input) {
139
191
  const file = cachePath(input);
140
- const cached = readCache(file);
192
+ const cached = readCache(file) || readFreshestAnyCache();
141
193
  const hashMatches = !input.expectedPolicyHash || cached?.snapshot.policyHash === input.expectedPolicyHash;
142
194
  if (cached && hashMatches && Date.now() - cached.loadedAt < CACHE_TTL_MS)
143
195
  return cached.snapshot;
@@ -175,6 +227,10 @@ function snapshotToScanOptions(snapshot, extra = {}) {
175
227
  return {
176
228
  disabledBuiltInItemIds: snapshot?.disabledBuiltInItemIds,
177
229
  itemActions: snapshot?.itemActions,
230
+ // Signals to the guard whether a real snapshot was resolved. Without one,
231
+ // the guard defaults to warn (never catalog hard-block) for unarmed/offline
232
+ // machines instead of over-blocking.
233
+ snapshotPresent: snapshot !== undefined,
178
234
  customBlocks: snapshot?.customBlocks,
179
235
  trustedScriptPaths: snapshot?.trustedScriptPaths,
180
236
  policyHash: snapshot?.policyHash,
@@ -40,6 +40,7 @@ const child_process_1 = require("child_process");
40
40
  const crypto = __importStar(require("crypto"));
41
41
  const fs = __importStar(require("fs"));
42
42
  const os = __importStar(require("os"));
43
+ const path = __importStar(require("path"));
43
44
  /**
44
45
  * OS-agnostic stable machine identity.
45
46
  *
@@ -100,6 +101,53 @@ function rawStableId() {
100
101
  return (safe(() => fs.readFileSync('/etc/machine-id', 'utf8'))
101
102
  || safe(() => fs.readFileSync('/var/lib/dbus/machine-id', 'utf8')));
102
103
  }
104
+ /**
105
+ * Where the FALLBACK machine id is persisted when no OS-native stable id is
106
+ * readable (locked-down registry / missing /etc/machine-id). Two locations:
107
+ *
108
+ * - machine-wide (ProgramData on Windows): shared by EVERY user AND the
109
+ * SYSTEM/S4U daemon tasks. Without it, the composite fallback — which
110
+ * includes the username — gives the daemon (running as SYSTEM) a DIFFERENT
111
+ * machineId than the enrolling user, creating duplicate fleet records with
112
+ * split telemetry for the same laptop.
113
+ * - per-user (~/.fullcourtdefense): fallback when ProgramData is not
114
+ * writable; keeps the id stable across upgrades for that user at least.
115
+ *
116
+ * The persisted value is the final HASHED id (never the raw composite).
117
+ */
118
+ function persistedIdPaths() {
119
+ const perUser = path.join(os.homedir(), '.fullcourtdefense', 'machine-id');
120
+ if (os.platform() === 'win32') {
121
+ const programData = process.env.ProgramData || 'C:\\ProgramData';
122
+ return { machineWide: path.join(programData, 'FullCourtDefense', 'machine-id'), perUser };
123
+ }
124
+ return { perUser };
125
+ }
126
+ function readPersistedFallbackId() {
127
+ const { machineWide, perUser } = persistedIdPaths();
128
+ for (const file of [machineWide, perUser]) {
129
+ if (!file)
130
+ continue;
131
+ const value = safe(() => fs.readFileSync(file, 'utf8'));
132
+ if (value && /^[0-9a-f]{16,64}$/i.test(value))
133
+ return value.toLowerCase();
134
+ }
135
+ return undefined;
136
+ }
137
+ function persistFallbackId(machineId) {
138
+ const { machineWide, perUser } = persistedIdPaths();
139
+ for (const file of [machineWide, perUser]) {
140
+ if (!file)
141
+ continue;
142
+ try {
143
+ fs.mkdirSync(path.dirname(file), { recursive: true });
144
+ const tmp = `${file}.tmp-${process.pid}`;
145
+ fs.writeFileSync(tmp, machineId, 'utf8');
146
+ fs.renameSync(tmp, file);
147
+ }
148
+ catch { /* best-effort — the composite recomputes deterministically for this user */ }
149
+ }
150
+ }
103
151
  function friendlyOs() {
104
152
  const platform = os.platform();
105
153
  const release = os.release();
@@ -149,10 +197,28 @@ function getMachineIdentity() {
149
197
  }
150
198
  const user = safe(() => os.userInfo().username) || process.env.USER || process.env.USERNAME || 'unknown-user';
151
199
  const hostname = normalizeHostname(safe(() => os.hostname()) || 'unknown-host');
152
- // Seed the hash with the OS-native id when available; fall back to a stable
153
- // composite so we still produce a deterministic id on locked-down machines.
154
- const raw = rawStableId() || `${hostname}|${user}|${os.platform()}|${os.arch()}`;
155
- const machineId = crypto.createHash('sha256').update(raw).digest('hex').slice(0, 32);
200
+ // Seed the hash with the OS-native id when available; otherwise reuse the
201
+ // persisted fallback id (so every user + the SYSTEM daemon on the same
202
+ // locked-down machine converge on ONE identity); only then compute the
203
+ // legacy composite — and persist it so future contexts converge on it.
204
+ const nativeRaw = rawStableId();
205
+ let machineId;
206
+ if (nativeRaw) {
207
+ machineId = crypto.createHash('sha256').update(nativeRaw).digest('hex').slice(0, 32);
208
+ }
209
+ else {
210
+ const persisted = readPersistedFallbackId();
211
+ if (persisted) {
212
+ machineId = persisted;
213
+ }
214
+ else {
215
+ // Composite keeps the pre-existing shape (incl. username) so machines
216
+ // already enrolled on this path keep their identity across upgrades.
217
+ const composite = `${hostname}|${user}|${os.platform()}|${os.arch()}`;
218
+ machineId = crypto.createHash('sha256').update(composite).digest('hex').slice(0, 32);
219
+ persistFallbackId(machineId);
220
+ }
221
+ }
156
222
  cached = {
157
223
  machineId,
158
224
  user,
@@ -90,6 +90,15 @@ export interface FetchBundleInput {
90
90
  ttlMs?: number;
91
91
  /** Force a network refresh regardless of cache freshness. */
92
92
  force?: boolean;
93
+ /**
94
+ * Fetch timeout override (ms). Default is the tight hot-path budget
95
+ * (REFRESH_TIMEOUT_MS = 1.5s) — a hook must never stall a command on a slow
96
+ * control plane. Diagnostic callers (doctor --health, deep self-test) pass a
97
+ * generous value instead: a bundle answer at 3s is a healthy backend, not a
98
+ * "no live bundle" failure. The 2h workday simulation surfaced this — ~1 in
99
+ * 6 health checkpoints false-alarmed on p95 latency spikes.
100
+ */
101
+ timeoutMs?: number;
93
102
  /**
94
103
  * Hot-path caller (hook / MCP gateway): when the cached mode is monitor or
95
104
  * shadow, accept a STALE cache without a synchronous refresh — the resident
@@ -101,6 +110,8 @@ export interface FetchBundleInput {
101
110
  }
102
111
  export interface EffectiveBundle extends RuntimeBundle {
103
112
  source: 'server' | 'cache' | 'default';
113
+ /** Epoch ms of the last server 200/304 that confirmed this bundle (absent on the 'default' fallback). */
114
+ serverValidatedAt?: number;
104
115
  }
105
116
  /**
106
117
  * Resolve the effective runtime bundle for a Shield, using the local cache when
@@ -128,6 +139,7 @@ export declare function validateShieldCredentials(input: {
128
139
  apiUrl: string;
129
140
  shieldId: string;
130
141
  shieldKey?: string;
142
+ timeoutMs?: number;
131
143
  }): Promise<CredentialValidationResult>;
132
144
  /**
133
145
  * Drop every cached runtime bundle at enrollment time.