fullcourtdefense-cli 1.26.14 → 1.26.16

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.
@@ -17,15 +17,47 @@
17
17
  * they diverge. Change this file? Copy it to the other location verbatim.
18
18
  */
19
19
  export type ActionPolicyVerdict = 'allow' | 'block' | 'require_approval' | 'log';
20
+ /**
21
+ * Constraint operators. `not_matches` is the glob complement of `matches`: it is what lets an
22
+ * allow-list be expressed under "most restrictive verdict wins" — `to matches *@company.com ->
23
+ * allow` needs a `to not_matches *@company.com -> block` partner, because an unconstrained block
24
+ * rule would match every call and swallow the allow.
25
+ */
26
+ export type ActionPolicyConstraintOperator = 'contains' | 'not_contains' | 'equals' | 'not_equals' | 'gt' | 'lt' | 'matches' | 'not_matches';
20
27
  export interface ActionPolicyConstraint {
21
28
  field: string;
22
- operator: 'contains' | 'not_contains' | 'equals' | 'not_equals' | 'gt' | 'lt' | 'matches';
29
+ operator: ActionPolicyConstraintOperator;
23
30
  value: string;
24
31
  }
32
+ /**
33
+ * Who resolves a `require_approval` verdict.
34
+ * - `org` — an admin in the console / Slack (default; the only scope with no human at the keyboard).
35
+ * - `developer` — the developer running the agent confirms in their IDE's own permission prompt
36
+ * (Cursor `permission: ask`, Claude `permissionDecision: ask`) or a terminal y/N.
37
+ * Only meaningful where a human is present — see `effectiveApprovalScope`.
38
+ */
39
+ export type ActionPolicyApprovalScope = 'developer' | 'org';
40
+ /** How long a developer's confirmation is remembered for the same tool + operation. */
41
+ export type ActionPolicyApprovalRemember = 'none' | 'session' | '1h';
42
+ /** What happens when nobody answers within the approval window. */
43
+ export type ActionPolicyApprovalOnTimeout = 'block' | 'org';
44
+ export interface ActionPolicyApproval {
45
+ scope?: ActionPolicyApprovalScope;
46
+ remember?: ActionPolicyApprovalRemember;
47
+ onTimeout?: ActionPolicyApprovalOnTimeout;
48
+ }
49
+ /** Fully-resolved approval options (every field present) attached to a `require_approval` result. */
50
+ export interface ResolvedActionPolicyApproval {
51
+ scope: ActionPolicyApprovalScope;
52
+ remember: ActionPolicyApprovalRemember;
53
+ onTimeout: ActionPolicyApprovalOnTimeout;
54
+ }
25
55
  export interface ActionPolicyRule {
26
56
  operations: string[];
27
57
  verdict: ActionPolicyVerdict;
28
58
  constraints?: ActionPolicyConstraint[];
59
+ /** Approval routing for `require_approval` rules. Absent = org admin approval, no memory, block on timeout. */
60
+ approval?: ActionPolicyApproval;
29
61
  }
30
62
  export interface ActionPolicyTargeting {
31
63
  developerNames?: string[];
@@ -59,11 +91,36 @@ export interface EngineCheckResult {
59
91
  resourceType?: string;
60
92
  matchedRule?: string;
61
93
  reason?: string;
94
+ /**
95
+ * Resolved approval routing — present only when `verdict === 'require_approval'`. When several
96
+ * approval rules match, the most restrictive option wins per field (org > developer, none >
97
+ * session > 1h, block > org) so one permissive rule can never loosen another.
98
+ */
99
+ approval?: ResolvedActionPolicyApproval;
62
100
  /** Monitor-stage policies that matched (would-block / would-approve) without enforcing. */
63
101
  monitorMatches?: ActionPolicyMonitorMatch[];
64
102
  /** Org-scoped policies skipped because the call carried no developerName (for caller-side logging). */
65
103
  skippedOrgPolicies?: string[];
66
104
  }
105
+ export declare const DEFAULT_ACTION_POLICY_APPROVAL: ResolvedActionPolicyApproval;
106
+ /**
107
+ * Normalize a rule's approval options: unknown / missing values fall back to the DEFAULT
108
+ * (org, none, block) — the strictest — so a typo in a policy document can never widen who
109
+ * may approve.
110
+ */
111
+ export declare function resolveApprovalOptions(approval?: ActionPolicyApproval): ResolvedActionPolicyApproval;
112
+ /**
113
+ * Where an approval actually goes given who is present at the enforcement point.
114
+ *
115
+ * `developer` scope means "the human running the agent confirms in their IDE". That human only
116
+ * exists on an end machine. Ephemeral machines (CI runners, containers, cloud coding agents) and
117
+ * SDK-embedded online services have no keyboard, so a developer-scoped rule there degrades to the
118
+ * org admin queue — never to a silent self-approval, and never to allow. Pure and shared so the
119
+ * backend, the CLI hook and the SDK agree on this fallback without a network round-trip.
120
+ */
121
+ export declare function effectiveApprovalScope(approval: ActionPolicyApproval | ResolvedActionPolicyApproval | undefined, presence: {
122
+ humanPresent: boolean;
123
+ }): ActionPolicyApprovalScope;
67
124
  /**
68
125
  * Derive the set of canonical operations a tool represents from its NAME and declared inventory
69
126
  * actions. This is the "declared operations" model: the tool's identity/declaration defines what
@@ -18,11 +18,60 @@
18
18
  * they diverge. Change this file? Copy it to the other location verbatim.
19
19
  */
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.DEFAULT_ACTION_POLICY_APPROVAL = void 0;
22
+ exports.resolveApprovalOptions = resolveApprovalOptions;
23
+ exports.effectiveApprovalScope = effectiveApprovalScope;
21
24
  exports.deriveToolOperations = deriveToolOperations;
22
25
  exports.toolCapabilities = toolCapabilities;
23
26
  exports.evaluateActionPolicies = evaluateActionPolicies;
24
27
  exports.inferToolContext = inferToolContext;
25
28
  exports.inventoryToolMatchesActionPolicy = inventoryToolMatchesActionPolicy;
29
+ exports.DEFAULT_ACTION_POLICY_APPROVAL = {
30
+ scope: 'org',
31
+ remember: 'none',
32
+ onTimeout: 'block',
33
+ };
34
+ const APPROVAL_SCOPE_RANK = { developer: 0, org: 1 };
35
+ const APPROVAL_REMEMBER_RANK = { '1h': 0, session: 1, none: 2 };
36
+ const APPROVAL_ON_TIMEOUT_RANK = { org: 0, block: 1 };
37
+ /**
38
+ * Normalize a rule's approval options: unknown / missing values fall back to the DEFAULT
39
+ * (org, none, block) — the strictest — so a typo in a policy document can never widen who
40
+ * may approve.
41
+ */
42
+ function resolveApprovalOptions(approval) {
43
+ const scope = approval?.scope;
44
+ const remember = approval?.remember;
45
+ const onTimeout = approval?.onTimeout;
46
+ return {
47
+ scope: scope === 'developer' || scope === 'org' ? scope : exports.DEFAULT_ACTION_POLICY_APPROVAL.scope,
48
+ remember: remember === 'session' || remember === '1h' || remember === 'none' ? remember : exports.DEFAULT_ACTION_POLICY_APPROVAL.remember,
49
+ onTimeout: onTimeout === 'org' || onTimeout === 'block' ? onTimeout : exports.DEFAULT_ACTION_POLICY_APPROVAL.onTimeout,
50
+ };
51
+ }
52
+ /** Field-wise most-restrictive merge of two resolved approval options. */
53
+ function mergeApprovalOptions(a, b) {
54
+ return {
55
+ scope: APPROVAL_SCOPE_RANK[b.scope] > APPROVAL_SCOPE_RANK[a.scope] ? b.scope : a.scope,
56
+ remember: APPROVAL_REMEMBER_RANK[b.remember] > APPROVAL_REMEMBER_RANK[a.remember] ? b.remember : a.remember,
57
+ onTimeout: APPROVAL_ON_TIMEOUT_RANK[b.onTimeout] > APPROVAL_ON_TIMEOUT_RANK[a.onTimeout] ? b.onTimeout : a.onTimeout,
58
+ };
59
+ }
60
+ /**
61
+ * Where an approval actually goes given who is present at the enforcement point.
62
+ *
63
+ * `developer` scope means "the human running the agent confirms in their IDE". That human only
64
+ * exists on an end machine. Ephemeral machines (CI runners, containers, cloud coding agents) and
65
+ * SDK-embedded online services have no keyboard, so a developer-scoped rule there degrades to the
66
+ * org admin queue — never to a silent self-approval, and never to allow. Pure and shared so the
67
+ * backend, the CLI hook and the SDK agree on this fallback without a network round-trip.
68
+ */
69
+ function effectiveApprovalScope(approval, presence) {
70
+ const resolved = resolveApprovalOptions(approval);
71
+ if (resolved.scope === 'developer' && presence.humanPresent)
72
+ return 'developer';
73
+ return 'org';
74
+ }
26
75
  function matchesGlob(value, pattern) {
27
76
  const escaped = pattern
28
77
  .replace(/[.+^${}()|[\]\\]/g, '\\$&')
@@ -174,6 +223,8 @@ function evaluateConstraint(constraint, context) {
174
223
  return !actualNorm.includes(expectedNorm);
175
224
  case 'matches':
176
225
  return matchesGlob(actualNorm, expectedNorm);
226
+ case 'not_matches':
227
+ return !matchesGlob(actualNorm, expectedNorm);
177
228
  default:
178
229
  return false;
179
230
  }
@@ -190,6 +241,8 @@ function evaluateConstraint(constraint, context) {
190
241
  return !actual.toLowerCase().includes(expected.toLowerCase());
191
242
  case 'matches':
192
243
  return matchesGlob(actual, expected);
244
+ case 'not_matches':
245
+ return !matchesGlob(actual, expected);
193
246
  default:
194
247
  return false;
195
248
  }
@@ -207,6 +260,7 @@ function evaluateConstraint(constraint, context) {
207
260
  return capabilityMatch;
208
261
  case 'not_equals':
209
262
  case 'not_contains':
263
+ case 'not_matches':
210
264
  return !capabilityMatch;
211
265
  default:
212
266
  return false;
@@ -227,6 +281,8 @@ function evaluateConstraint(constraint, context) {
227
281
  return parseFloat(actual) < parseFloat(expected);
228
282
  case 'matches':
229
283
  return matchesGlob(actual, expected);
284
+ case 'not_matches':
285
+ return !matchesGlob(actual, expected);
230
286
  default:
231
287
  return false;
232
288
  }
@@ -528,6 +584,16 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
528
584
  resourceType: policy.resourceType,
529
585
  matchedRule,
530
586
  reason: `Action policy "${policy.name}": ${rule.operations.join('/')} ${rule.verdict === 'block' ? 'blocked' : rule.verdict === 'require_approval' ? 'requires approval' : rule.verdict}`,
587
+ ...(rule.verdict === 'require_approval' ? { approval: resolveApprovalOptions(rule.approval) } : {}),
588
+ };
589
+ }
590
+ else if (rule.verdict === 'require_approval' && worstResult.verdict === 'require_approval') {
591
+ // A second approval rule matched: keep the first rule's attribution but tighten the
592
+ // approval routing to the most restrictive of both (a developer-scoped rule can never
593
+ // relax an org-scoped one that also fires).
594
+ worstResult = {
595
+ ...worstResult,
596
+ approval: mergeApprovalOptions(worstResult.approval || exports.DEFAULT_ACTION_POLICY_APPROVAL, resolveApprovalOptions(rule.approval)),
531
597
  };
532
598
  }
533
599
  }
@@ -28,6 +28,8 @@ export interface CiProtectArgs {
28
28
  hooks?: string;
29
29
  /** 'false' => skip wrapping MCP client configs with the gateway. */
30
30
  gateway?: string;
31
+ /** 'true' => allow replacing an existing developer-machine enrollment (see assertNotEnrolledLaptop). */
32
+ force?: string;
31
33
  }
32
34
  interface CiContext {
33
35
  provider: string;
@@ -1,44 +1,9 @@
1
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
2
  Object.defineProperty(exports, "__esModule", { value: true });
36
3
  exports.detectCiContext = detectCiContext;
37
4
  exports.ciProtectCommand = ciProtectCommand;
38
- const fs = __importStar(require("fs"));
39
5
  const config_1 = require("../config");
40
- const installClaudeHook_1 = require("./installClaudeHook");
41
- const mcpGateway_1 = require("./mcpGateway");
6
+ const ephemeralStack_1 = require("./ephemeralStack");
42
7
  /** Detect the pipeline from standard CI env vars (GitHub Actions, GitLab CI). */
43
8
  function detectCiContext(env = process.env) {
44
9
  if (env.GITHUB_ACTIONS === 'true') {
@@ -89,6 +54,9 @@ async function ciProtectCommand(args, config) {
89
54
  throw new Error('An organization API key is required. Add FCD_API_KEY to the repo/pipeline secrets '
90
55
  + '(create one in the dashboard: Workspace → API keys) or pass --api-key.');
91
56
  }
57
+ // Before any network call or file write: never overwrite a developer's enrollment
58
+ // (self-hosted runner that is also someone's workstation).
59
+ (0, ephemeralStack_1.assertNotEnrolledLaptop)('pipeline', { force: args.force === 'true' });
92
60
  const detected = detectCiContext();
93
61
  const provider = (args.provider || detected?.provider || 'generic-ci').trim();
94
62
  const repo = (args.repo || detected?.repo || '').trim();
@@ -129,62 +97,26 @@ async function ciProtectCommand(args, config) {
129
97
  shieldKey: result.shieldKey,
130
98
  apiUrl,
131
99
  });
100
+ (0, ephemeralStack_1.writeEphemeralIdentityMarker)('pipeline', result.machineId);
132
101
  // Attribute every subsequent hook/gateway event in this job to the PIPELINE
133
102
  // machine record: current process + GITHUB_ENV for the steps that follow
134
103
  // (where the AI agent actually runs).
135
- const identityEnv = {
104
+ (0, ephemeralStack_1.exportIdentityEnv)({
136
105
  FCD_MACHINE_ID: result.machineId,
137
106
  FCD_DEVELOPER_NAME: `ci@${repo.toLowerCase()}`,
138
107
  FCD_MACHINE_HOSTNAME: result.pipeline.key,
139
- };
140
- for (const [key, value] of Object.entries(identityEnv))
141
- process.env[key] = value;
142
- if (process.env.GITHUB_ENV) {
143
- try {
144
- fs.appendFileSync(process.env.GITHUB_ENV, Object.entries(identityEnv).map(([k, v]) => `${k}=${v}\n`).join(''));
145
- }
146
- catch { /* non-GitHub runner with a stray env var — identity still set for this process */ }
147
- }
108
+ }, { githubEnvPath: process.env.GITHUB_ENV });
148
109
  console.log(`\x1b[32m✓ ${result.reused ? 'Pipeline re-enrolled' : 'Pipeline enrolled'}\x1b[0m ${result.shieldName}`);
149
110
  console.log(` Mode: ${result.enforcementMode} · Config: ${savedPath}`);
150
- // Same protection stack as laptops. The Claude-format hook covers Claude
151
- // Code, VS Code agent mode, and Copilot CLI — the common CI agents.
152
- if (args.hooks !== 'false') {
153
- console.log('\n\x1b[1mInstalling runtime hook (Claude Code / VS Code / Copilot CLI)…\x1b[0m');
154
- const hookArgs = {
155
- shieldId: result.shieldId,
156
- shieldKey: result.shieldKey,
157
- apiUrl,
158
- events: 'tools,prompt',
159
- };
160
- try {
161
- await (0, installClaudeHook_1.installClaudeHookCommand)(hookArgs, config);
162
- await sendLog([{ level: 'success', message: 'Runtime hook installed (Claude Code / VS Code / Copilot CLI)' }]);
163
- }
164
- catch (error) {
165
- const reason = error instanceof Error ? error.message : String(error);
166
- console.log(`Hook install skipped: ${reason}`);
167
- await sendLog([{ level: 'warn', message: `Runtime hook install skipped: ${reason}` }]);
168
- }
169
- }
170
- if (args.gateway !== 'false') {
171
- console.log('\n\x1b[1mWrapping MCP client configs with the gateway…\x1b[0m');
172
- const gatewayArgs = {
173
- shieldId: result.shieldId,
174
- shieldKey: result.shieldKey,
175
- apiUrl,
176
- clients: 'all',
177
- };
178
- try {
179
- await (0, mcpGateway_1.protectAllCommand)(gatewayArgs, config);
180
- await sendLog([{ level: 'success', message: 'MCP client configs wrapped with the FullCourtDefense gateway' }]);
181
- }
182
- catch (error) {
183
- const reason = error instanceof Error ? error.message : String(error);
184
- console.log(`MCP gateway wrap skipped: ${reason}`);
185
- await sendLog([{ level: 'warn', message: `MCP gateway wrap skipped: ${reason}` }]);
186
- }
187
- }
111
+ // Same protection stack as laptops (shared with workload-protect).
112
+ await (0, ephemeralStack_1.installEphemeralProtectionStack)({
113
+ shieldId: result.shieldId,
114
+ shieldKey: result.shieldKey,
115
+ apiUrl,
116
+ hooks: args.hooks,
117
+ gateway: args.gateway,
118
+ sendLog,
119
+ }, config);
188
120
  console.log('\n\x1b[32mDone.\x1b[0m This job\'s AI tool calls are now policy-checked and streamed to the fleet console.');
189
121
  console.log(`Pipeline appears in AI Fleet → Machines → CI pipelines as \x1b[1m${repo} · ${workflow}\x1b[0m.`);
190
122
  await sendLog([{ level: 'success', message: `Protection active (${result.enforcementMode} mode) — this job's AI tool calls are policy-checked and recorded` }], 'succeeded');
@@ -145,6 +145,14 @@ const HEARTBEAT_DEADLINE_MS = envMs('FCD_DAEMON_HEARTBEAT_DEADLINE_MS', 120_000)
145
145
  */
146
146
  const RESUME_BEAT_MS = envMs('FCD_DAEMON_RESUME_BEAT_MS', 30_000);
147
147
  const RESUME_JUMP_MS = envMs('FCD_DAEMON_RESUME_JUMP_MS', 120_000);
148
+ /**
149
+ * Report-in attempts after a resume, and the base delay between them (linear
150
+ * backoff: 10s, 20s, 30s, 40s). Sized to outlast a normal Wi-Fi reassociation
151
+ * plus DHCP/DNS, so waking on a slow network costs seconds of staleness rather
152
+ * than a whole reporting window.
153
+ */
154
+ const RESUME_REPORT_ATTEMPTS = envMs('FCD_DAEMON_RESUME_ATTEMPTS', 5);
155
+ const RESUME_RETRY_BASE_MS = envMs('FCD_DAEMON_RESUME_RETRY_MS', 10_000);
148
156
  /** Delay before the one-time initial discovery sweep on a fresh machine. */
149
157
  const INITIAL_DISCOVER_DELAY_MS = envMs('FCD_DAEMON_INITIAL_DISCOVER_MS', 2 * 60_000);
150
158
  /** A discovery upload older than this is stale — the daemon catches up itself. */
@@ -582,30 +590,49 @@ async function runDaemon(args, config) {
582
590
  * back within one heartbeat instead of up to an hour later (the 8/12
583
591
  * incident: re-enroll fixed the hooks instantly while the daemon kept
584
592
  * 401-ing for 16 more minutes).
593
+ *
594
+ * The same applies when the daemon already HOLDS credentials: a re-enroll
595
+ * rotates the shield key, so the key in memory is dead the moment
596
+ * ~/.fullcourtdefense.yml is rewritten. Until 9/3 this function returned
597
+ * early whenever creds were present, and a re-enrolled laptop's daemon kept
598
+ * 401-ing (bundle, heartbeat, telemetry) until someone killed the process.
599
+ * Now a changed enrollment file reloads credentials whether or not the
600
+ * daemon had some — one heartbeat later it is on the new key.
585
601
  */
586
602
  const recoverCredentialsIfMissing = () => {
587
- if (creds.shieldId && creds.shieldKey)
588
- return;
589
603
  let enrollmentChanged = false;
590
604
  try {
591
605
  const mtime = fs.statSync((0, config_1.getHomeConfigPath)()).mtimeMs;
592
- enrollmentChanged = mtime !== lastEnrollmentMtimeMs;
606
+ // First observation only records the baseline; a later different mtime is a re-enroll.
607
+ enrollmentChanged = lastEnrollmentMtimeMs !== 0 && mtime !== lastEnrollmentMtimeMs;
593
608
  lastEnrollmentMtimeMs = mtime;
594
609
  }
595
610
  catch { /* no config file — the tick cadence below applies */ }
596
- credRecoveryTicks += 1;
597
- if (!enrollmentChanged && credRecoveryTicks > 3 && credRecoveryTicks % 12 !== 0)
611
+ const missing = !(creds.shieldId && creds.shieldKey);
612
+ if (!missing && !enrollmentChanged)
598
613
  return;
614
+ if (missing) {
615
+ credRecoveryTicks += 1;
616
+ if (!enrollmentChanged && credRecoveryTicks > 3 && credRecoveryTicks % 12 !== 0)
617
+ return;
618
+ }
599
619
  try {
600
620
  const fresh = (0, config_1.resolveCliCredentials)((0, config_1.loadConfig)(args.config), {
601
621
  shieldId: args.shieldId,
602
622
  shieldKey: args.shieldKey,
603
623
  apiUrl: args.apiUrl,
604
624
  });
605
- if (fresh.shieldId && fresh.shieldKey) {
625
+ if (!fresh.shieldId || !fresh.shieldKey)
626
+ return;
627
+ const rotated = fresh.shieldId !== creds.shieldId || fresh.shieldKey !== creds.shieldKey || fresh.apiUrl !== creds.apiUrl;
628
+ if (missing) {
606
629
  Object.assign(creds, fresh);
607
630
  log('Credentials recovered — telemetry and control-plane sync restored.');
608
631
  }
632
+ else if (rotated) {
633
+ Object.assign(creds, fresh);
634
+ log(`Enrollment changed on disk — reloaded credentials (shield ${fresh.shieldId}); telemetry and control-plane sync continue on the new key.`);
635
+ }
609
636
  }
610
637
  catch { /* next tick */ }
611
638
  };
@@ -1298,6 +1325,14 @@ async function runDaemon(args, config) {
1298
1325
  recoverCredentialsIfMissing();
1299
1326
  if (!creds.shieldId)
1300
1327
  return;
1328
+ // Did THIS beat actually record daemon liveness upstream? `flushSpool`
1329
+ // reports every failure by returning null and never throws, so without
1330
+ // this flag a heartbeat that never left the machine was indistinguishable
1331
+ // from a delivered one — and the loop happily waited a full cadence before
1332
+ // trying again. `null` also covers the case where a hook flusher held the
1333
+ // flush lock, which drops the daemon marker specifically (hook flushes do
1334
+ // not set `daemon: true`), so retrying soon is right in that case too.
1335
+ let reported = false;
1301
1336
  try {
1302
1337
  let integrity = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
1303
1338
  // A failing verdict used to be REPORTED and nothing more, so a machine
@@ -1348,6 +1383,7 @@ async function runDaemon(args, config) {
1348
1383
  }
1349
1384
  : undefined,
1350
1385
  });
1386
+ reported = result !== null;
1351
1387
  if (result && unreportedCrash)
1352
1388
  (0, daemonForensics_1.markPostmortemReported)();
1353
1389
  if (result && result.accepted > 0)
@@ -1358,8 +1394,13 @@ async function runDaemon(args, config) {
1358
1394
  }
1359
1395
  catch { /* spool stays on disk for the next tick */ }
1360
1396
  // Settle any in-flight upgrade_cli action (success once the new build is
1361
- // live, failure when the target never arrived).
1397
+ // live, failure when the target never arrived). Runs BEFORE the failure
1398
+ // signal below so a lost heartbeat can never strand a pending upgrade.
1362
1399
  await verifyPendingUpgrade();
1400
+ // Surface the miss to the caller (poll loop / resume path) so it retries in
1401
+ // seconds. Nothing else can tell them: liveness has exactly one writer.
1402
+ if (!reported)
1403
+ throw new Error('heartbeat did not reach the backend — daemon liveness was not recorded');
1363
1404
  };
1364
1405
  // --- boot ---------------------------------------------------------------
1365
1406
  // Self-heal autostart + watchdog tasks: machines installed by older versions
@@ -1385,7 +1426,19 @@ async function runDaemon(args, config) {
1385
1426
  // would report a false "damaged" state (and fire admin integrity alerts)
1386
1427
  // for a condition the very next line repairs.
1387
1428
  await reprotect(['startup pass']);
1388
- await heartbeat();
1429
+ // The FIRST beat must not be able to abort the rest of this function.
1430
+ // `heartbeat` throws when the report did not reach the backend (that is how
1431
+ // the poll loop knows to retry in seconds), and an exception here propagates
1432
+ // out of `runDaemon` and skips every loop registered below it — the heartbeat
1433
+ // loop, the resume timer and the bundle poll. The process itself survives on
1434
+ // the `unhandledRejection` handler, which is worse than dying: it holds the
1435
+ // pid lock, so the scheduled task declines to start a replacement, and it
1436
+ // never polls the bundle, so no remote action can reach it. Booting before
1437
+ // the network is up (captive portal, VPN, resumed in a lift) is routine, and
1438
+ // the heartbeat loop below retries within seconds.
1439
+ await heartbeat().catch(error => {
1440
+ log(`First heartbeat did not reach the backend (${error?.message || String(error)}) — the heartbeat loop will retry shortly.`);
1441
+ });
1389
1442
  // Claude Desktop chat guard (Windows, advisory): Claude Desktop's regular
1390
1443
  // chat has no hook and never hits an MCP server, so it is the one machine
1391
1444
  // surface neither hooks nor the gateway can see. Supervise the advisory guard
@@ -1535,6 +1588,39 @@ async function runDaemon(args, config) {
1535
1588
  // closes the reporting gap and removes the evidence of a hang that never
1536
1589
  // happened. Both calls go through runWithDeadline, so a resume that lands on
1537
1590
  // sockets which died during sleep cannot leak an unhandled rejection.
1591
+ // The resume beat is RETRIED, because it fires the instant the wall-clock
1592
+ // jump is noticed — which on a laptop is reliably before Wi-Fi has
1593
+ // reassociated. As a single shot, the one beat whose whole job is to prove
1594
+ // the daemon survived the sleep was also the likeliest to be lost, and modern
1595
+ // standby routinely re-suspends the machine before the next cadence tick, so
1596
+ // liveness could stay stale indefinitely while IDE-hook flushes kept the
1597
+ // machine "online" — a live daemon displayed as down.
1598
+ let resumeReportInFlight = false;
1599
+ const reportInAfterResume = async () => {
1600
+ if (resumeReportInFlight)
1601
+ return; // a previous resume is still catching up
1602
+ resumeReportInFlight = true;
1603
+ try {
1604
+ for (let attempt = 1; attempt <= RESUME_REPORT_ATTEMPTS; attempt++) {
1605
+ let failed = false;
1606
+ const outcome = await (0, pollLoop_1.runWithDeadline)({
1607
+ run: heartbeat,
1608
+ deadlineMs: HEARTBEAT_DEADLINE_MS,
1609
+ onError: () => { failed = true; },
1610
+ });
1611
+ if (outcome === 'settled' && !failed)
1612
+ return; // liveness recorded
1613
+ if (attempt === RESUME_REPORT_ATTEMPTS) {
1614
+ log(`Resume report-in did not land after ${attempt} attempt(s) — the scheduled heartbeat keeps retrying.`);
1615
+ return;
1616
+ }
1617
+ await new Promise(resolve => setTimeout(resolve, RESUME_RETRY_BASE_MS * attempt));
1618
+ }
1619
+ }
1620
+ finally {
1621
+ resumeReportInFlight = false;
1622
+ }
1623
+ };
1538
1624
  let lastResumeBeatMs = Date.now();
1539
1625
  const resumeTimer = setInterval(() => {
1540
1626
  const now = Date.now();
@@ -1544,7 +1630,7 @@ async function runDaemon(args, config) {
1544
1630
  return;
1545
1631
  log(`Resume detected: ${Math.round(drift / 1000)}s of wall time elapsed while suspended — refreshing liveness and reporting in now.`);
1546
1632
  (0, daemonForensics_1.touchDaemonAlive)();
1547
- void (0, pollLoop_1.runWithDeadline)({ run: heartbeat, deadlineMs: HEARTBEAT_DEADLINE_MS });
1633
+ void reportInAfterResume();
1548
1634
  void (0, pollLoop_1.runWithDeadline)({ run: pollBundle, deadlineMs: BUNDLE_POLL_DEADLINE_MS });
1549
1635
  }, RESUME_BEAT_MS);
1550
1636
  // PowerShell transcript retention (Windows): the Transcription policy FCD
@@ -0,0 +1,117 @@
1
+ import type { BotGuardConfig } from '../config';
2
+ import { type LocalSafetySnapshot } from '../localSafetySnapshot';
3
+ export interface DemoActionsArgs {
4
+ live?: string;
5
+ json?: string;
6
+ demoPolicies?: string;
7
+ timeout?: string;
8
+ }
9
+ export type DemoEvent = 'shell' | 'mcp' | 'read' | 'file';
10
+ export interface DemoScenario {
11
+ id: string;
12
+ /** One line a security lead understands. */
13
+ title: string;
14
+ event: DemoEvent;
15
+ /** Payload in the exact shape the IDE hook receives. */
16
+ payload: Record<string, unknown>;
17
+ /** What the scenario is meant to show. */
18
+ why: string;
19
+ }
20
+ export interface DemoScenarioResult {
21
+ id: string;
22
+ title: string;
23
+ event: DemoEvent;
24
+ action: string;
25
+ permission: 'allow' | 'deny' | 'ask' | 'unknown';
26
+ reason?: string;
27
+ /**
28
+ * Catalog rules that matched at WARN level (recorded as findings, action allowed). Shown so
29
+ * an allowed credential read is not mistaken for "not noticed": the org can flip these
30
+ * rules to block in the console. Offline replay only.
31
+ */
32
+ flagged?: string[];
33
+ /** Monitor/shadow machines allow and report what WOULD have been blocked. */
34
+ wouldBlock?: boolean;
35
+ latencyMs: number;
36
+ raw?: Record<string, unknown>;
37
+ }
38
+ export interface DemoActionsReport {
39
+ mode: 'offline' | 'live';
40
+ policySource: 'machine-cache' | 'demo-policies';
41
+ machineMode: 'block' | 'monitor' | 'shadow' | 'unknown';
42
+ policyCount: number;
43
+ shieldId: string;
44
+ scenarios: DemoScenarioResult[];
45
+ summary: {
46
+ allow: number;
47
+ deny: number;
48
+ ask: number;
49
+ wouldBlock: number;
50
+ unknown: number;
51
+ };
52
+ }
53
+ /**
54
+ * Policies seeded when the machine has no cached bundle (not enrolled). Plain
55
+ * action policies in the shape the engine evaluates — labelled so the output
56
+ * cannot be mistaken for the org's real configuration.
57
+ */
58
+ export declare const DEMO_POLICIES: ({
59
+ id: string;
60
+ name: string;
61
+ resourceType: string;
62
+ rules: {
63
+ operations: string[];
64
+ verdict: string;
65
+ }[];
66
+ } | {
67
+ id: string;
68
+ name: string;
69
+ resourceType: string;
70
+ rules: {
71
+ operations: string[];
72
+ verdict: string;
73
+ constraints: {
74
+ field: string;
75
+ operator: string;
76
+ value: string;
77
+ }[];
78
+ }[];
79
+ })[];
80
+ export declare const DEMO_SCENARIOS: DemoScenario[];
81
+ /**
82
+ * Build the throw-away HOME for an offline replay. Copies this machine's cached
83
+ * policy bundle when there is one (refreshed so the hook does not try to
84
+ * revalidate against the dead API), otherwise seeds the demo policy set.
85
+ */
86
+ export interface OfflineHome {
87
+ home: string;
88
+ policySource: 'machine-cache' | 'demo-policies';
89
+ shieldId: string;
90
+ /** Machine mode carried over from the cache (`monitor` machines record would-block instead of denying). */
91
+ mode: 'block' | 'monitor' | 'shadow';
92
+ /** Number of org action policies in the replayed cache (0 = enrolled org without policies yet). */
93
+ policyCount: number;
94
+ }
95
+ export declare function prepareOfflineHome(realHome: string, shieldId: string, forceDemoPolicies?: boolean): OfflineHome;
96
+ interface RunOptions {
97
+ cliPath: string;
98
+ home: string;
99
+ /** Offline replay: explicit sandbox credentials + dead API. Live: absent — the hook loads the machine's real config (incl. DPAPI keys). */
100
+ sandbox?: {
101
+ apiUrl: string;
102
+ shieldId: string;
103
+ shieldKey: string;
104
+ };
105
+ timeoutMs: number;
106
+ developerId: string;
107
+ }
108
+ export declare function runScenario(scenario: DemoScenario, opts: RunOptions): DemoScenarioResult;
109
+ /**
110
+ * Which catalog rules matched at WARN level for a scenario — the same scan the hook ran
111
+ * (same guard, same snapshot) — so the report can show "allowed, but flagged". The hook
112
+ * spools these as findings; it does not put them in the verdict it hands the IDE.
113
+ */
114
+ export declare function flaggedRules(scenario: DemoScenario, snapshot: LocalSafetySnapshot | undefined): string[];
115
+ export declare function renderReport(report: DemoActionsReport): string;
116
+ export declare function demoActionsCommand(args: DemoActionsArgs, config: BotGuardConfig): Promise<void>;
117
+ export {};