fullcourtdefense-cli 1.26.17 → 1.27.0

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.
@@ -59,10 +59,26 @@ export interface ActionPolicyRule {
59
59
  /** Approval routing for `require_approval` rules. Absent = org admin approval, no memory, block on timeout. */
60
60
  approval?: ActionPolicyApproval;
61
61
  }
62
+ /**
63
+ * The three kinds of machine a fleet policy can be written for:
64
+ * - `workstation` — laptops / desktops a person uses (default for any machine that does not say otherwise)
65
+ * - `ci` — ephemeral CI runner VMs enrolled with `ci-protect` (one row per pipeline)
66
+ * - `workload` — containers, pods, jobs, functions enrolled with `workload-protect`
67
+ * A call that carries no `machineKind` in its context is treated as a workstation, so a
68
+ * policy written only for containers can never fire on a laptop (and vice versa).
69
+ */
70
+ export type MachineKind = 'workstation' | 'ci' | 'workload';
71
+ export declare const MACHINE_KINDS: readonly MachineKind[];
62
72
  export interface ActionPolicyTargeting {
63
73
  developerNames?: string[];
64
74
  machineNames?: string[];
75
+ /** Restrict to these machine kinds. Empty/absent = every kind (a GLOBAL policy). */
76
+ machineKinds?: MachineKind[];
65
77
  }
78
+ /** Normalize a context value into a MachineKind; anything unknown is a workstation. */
79
+ export declare function normalizeMachineKind(value: string | undefined): MachineKind;
80
+ /** Does this policy's kind targeting include the given machine kind? Empty targeting = yes. */
81
+ export declare function policyTargetsMachineKind(appliesTo: ActionPolicyTargeting | undefined, kind: MachineKind): boolean;
66
82
  /** The minimal policy shape the engine needs — structurally satisfied by the backend's ActionPolicy. */
67
83
  export interface EngineActionPolicy {
68
84
  id: string;
@@ -18,7 +18,9 @@
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;
21
+ exports.DEFAULT_ACTION_POLICY_APPROVAL = exports.MACHINE_KINDS = void 0;
22
+ exports.normalizeMachineKind = normalizeMachineKind;
23
+ exports.policyTargetsMachineKind = policyTargetsMachineKind;
22
24
  exports.resolveApprovalOptions = resolveApprovalOptions;
23
25
  exports.effectiveApprovalScope = effectiveApprovalScope;
24
26
  exports.deriveToolOperations = deriveToolOperations;
@@ -26,6 +28,17 @@ exports.toolCapabilities = toolCapabilities;
26
28
  exports.evaluateActionPolicies = evaluateActionPolicies;
27
29
  exports.inferToolContext = inferToolContext;
28
30
  exports.inventoryToolMatchesActionPolicy = inventoryToolMatchesActionPolicy;
31
+ exports.MACHINE_KINDS = ['workstation', 'ci', 'workload'];
32
+ /** Normalize a context value into a MachineKind; anything unknown is a workstation. */
33
+ function normalizeMachineKind(value) {
34
+ const kind = (value || '').trim().toLowerCase();
35
+ return kind === 'ci' || kind === 'workload' ? kind : 'workstation';
36
+ }
37
+ /** Does this policy's kind targeting include the given machine kind? Empty targeting = yes. */
38
+ function policyTargetsMachineKind(appliesTo, kind) {
39
+ const kinds = (appliesTo?.machineKinds || []).filter(Boolean);
40
+ return kinds.length === 0 || kinds.includes(kind);
41
+ }
29
42
  exports.DEFAULT_ACTION_POLICY_APPROVAL = {
30
43
  scope: 'org',
31
44
  remember: 'none',
@@ -408,7 +421,7 @@ const OPERATION_MATCH_SKIP_FIELDS = new Set([
408
421
  'tool.status', 'operation.status', 'agent.baseline',
409
422
  // Identity / metadata — NOT action signals. Excluded so the tool/agent name can never
410
423
  // pollute operation matching.
411
- 'toolName', 'agentName', 'developerName', 'machineName', 'ipAddress', 'agentClient', 'gateway', 'mcpServer', 'environment',
424
+ 'toolName', 'agentName', 'developerName', 'machineName', 'machineKind', 'ipAddress', 'agentClient', 'gateway', 'mcpServer', 'environment',
412
425
  // Document / file CONTENT — data being written or displayed, not the action itself.
413
426
  // A file whose text mentions "delete" or "npm publish" is not a delete or a deploy;
414
427
  // matching rule verbs against document bodies pauses ordinary file edits (real incident:
@@ -484,11 +497,14 @@ function isInternalHost(hostname) {
484
497
  return Boolean(private172 && Number(private172[1]) >= 16 && Number(private172[1]) <= 31);
485
498
  }
486
499
  /**
487
- * First-class policy targeting (appliesTo.developerNames / machineNames).
500
+ * First-class policy targeting (appliesTo.developerNames / machineNames / machineKinds).
488
501
  * Empty targeting = applies to everyone. A targeted policy only applies when the call
489
- * carries the matching identity context (case-insensitive exact match).
502
+ * carries the matching identity context (case-insensitive exact match). Kind targeting
503
+ * reads `context.machineKind` (workstation when absent).
490
504
  */
491
505
  function policyTargetsCall(policy, context) {
506
+ if (!policyTargetsMachineKind(policy.appliesTo, normalizeMachineKind(context.machineKind)))
507
+ return false;
492
508
  const developerTargets = policy.appliesTo?.developerNames?.filter(Boolean) || [];
493
509
  const machineTargets = policy.appliesTo?.machineNames?.filter(Boolean) || [];
494
510
  if (developerTargets.length > 0) {
@@ -5,6 +5,7 @@ exports.ciProtectCommand = ciProtectCommand;
5
5
  const config_1 = require("../config");
6
6
  const cliVersion_1 = require("../cliVersion");
7
7
  const ephemeralStack_1 = require("./ephemeralStack");
8
+ const hostRuntime_1 = require("../hostRuntime");
8
9
  /** Detect the pipeline from standard CI env vars (GitHub Actions, GitLab CI). */
9
10
  function detectCiContext(env = process.env) {
10
11
  if (env.GITHUB_ACTIONS === 'true') {
@@ -68,10 +69,15 @@ async function ciProtectCommand(args, config) {
68
69
  }
69
70
  const runId = (args.runId || detected?.runId || '').trim() || undefined;
70
71
  const runUrl = (args.runUrl || detected?.runUrl || '').trim() || undefined;
72
+ // The runner's real substrate (VM / container / physical, hypervisor, cloud)
73
+ // from firmware + /proc facts: the fleet shows WHERE this pipeline ran, not
74
+ // just which provider claimed to run it.
75
+ const hostRuntime = (0, hostRuntime_1.getHostRuntime)();
71
76
  console.log('\x1b[1m\x1b[36mFullCourtDefense — CI runtime protection\x1b[0m');
72
77
  console.log(` Pipeline: ${provider}/${repo}/${workflow}`);
73
78
  if (runId)
74
79
  console.log(` Run: ${runId}`);
80
+ console.log(` Runner: ${(0, hostRuntime_1.describeHostRuntime)(hostRuntime)}`);
75
81
  const sendLog = ciLogSender(apiUrl, apiKey, { provider, repo, workflow, runId, runUrl });
76
82
  const resp = await fetch(`${apiUrl}/api/cli/enroll/ci`, {
77
83
  method: 'POST',
@@ -83,6 +89,7 @@ async function ciProtectCommand(args, config) {
83
89
  runId,
84
90
  runUrl,
85
91
  cliVersion: (0, cliVersion_1.cliVersion)(),
92
+ hostRuntime,
86
93
  }),
87
94
  });
88
95
  const data = (await resp.json().catch(() => ({})));
@@ -106,6 +113,10 @@ async function ciProtectCommand(args, config) {
106
113
  FCD_MACHINE_ID: result.machineId,
107
114
  FCD_DEVELOPER_NAME: `ci@${repo.toLowerCase()}`,
108
115
  FCD_MACHINE_HOSTNAME: result.pipeline.key,
116
+ // No human at this keyboard: developer-scoped approvals route to the org
117
+ // queue (never self-approve), and policies written for CI runners fire here.
118
+ FCD_EPHEMERAL: '1',
119
+ FCD_MACHINE_KIND: 'ci',
109
120
  }, { githubEnvPath: process.env.GITHUB_ENV });
110
121
  console.log(`\x1b[32m✓ ${result.reused ? 'Pipeline re-enrolled' : 'Pipeline enrolled'}\x1b[0m ${result.shieldName}`);
111
122
  console.log(` Mode: ${result.enforcementMode} · Config: ${savedPath}`);
@@ -608,8 +608,7 @@ function tryLocalPolicyEnforcement(ctx, call, detail, opts = {}) {
608
608
  }
609
609
  const { operation, context } = (0, actionPolicyEngine_1.inferToolContext)(call.toolName, call.toolArgs);
610
610
  context.toolName = call.toolName;
611
- context.developerName = developerId();
612
- context.machineName = os.hostname();
611
+ (0, machineIdentity_1.stampMachineIdentityContext)(context, developerId());
613
612
  const declaredOperations = (0, actionPolicyEngine_1.deriveToolOperations)(call.toolName);
614
613
  const local = (0, actionPolicyEngine_1.evaluateActionPolicies)(policies, call.toolName, operation, context, declaredOperations);
615
614
  (0, hookIo_1.dbg)({ phase: 'policy_local_verdict', event: ctx.event, tool: call.toolName, operation, verdict: local.verdict, policyName: local.policyName, detail });
@@ -722,6 +721,9 @@ function machineMetadata(client = 'cursor') {
722
721
  ...(machineId ? { machineId } : {}),
723
722
  developerName: developerId(),
724
723
  machineName: os.hostname(),
724
+ // Same kind the local engine sees — the server's check-tool-call must
725
+ // evaluate kind-targeted policies identically to the offline path.
726
+ machineKind: (0, machineIdentity_1.localMachineKind)(),
725
727
  os: `${os.platform()} ${os.release()}`,
726
728
  username,
727
729
  cwd: process.cwd(),
@@ -760,10 +762,11 @@ function spoolLocalFinding(input) {
760
762
  /**
761
763
  * warn-action rule matches: the action PROCEEDS — spool each finding as a
762
764
  * 'warn' event so it lands in the org console/timeline without stopping work.
765
+ * Callers defer this until the verdict is known (see `deferLocalWarnings`), so
766
+ * a blocked action never also produces a "Warned" row.
763
767
  */
764
768
  function spoolLocalWarnings(warnings, toolName, operation) {
765
769
  for (const finding of warnings) {
766
- (0, hookIo_1.dbg)({ phase: 'local_deterministic_warn', tool: toolName, ruleId: finding.ruleId, itemId: finding.itemId });
767
770
  spoolLocalFinding({ finding, toolName, operation, decision: 'warn' });
768
771
  }
769
772
  }
@@ -1092,9 +1095,25 @@ async function hookCommandInner(args, config, io) {
1092
1095
  // permission prompt — Cursor `permission: "ask"`, Claude
1093
1096
  // `permissionDecision: "ask"` — with our reason as the prompt text. No
1094
1097
  // FCD dialog of its own; prompt events never ask.
1098
+ // warn-action rule matches are held here until the verdict is known. A
1099
+ // 'warn' record means "this proceeded, but note it" — so it is only written
1100
+ // when the action actually proceeds. If the same action ends up BLOCKED
1101
+ // (local guard, taint, or policy), the block record already tells the whole
1102
+ // story and a sibling "Warned" row would read as if the action ran twice.
1103
+ const pendingLocalWarnings = [];
1104
+ const deferLocalWarnings = (warnings, toolName, operation) => {
1105
+ for (const finding of warnings) {
1106
+ (0, hookIo_1.dbg)({ phase: 'local_deterministic_warn', tool: toolName, ruleId: finding.ruleId, itemId: finding.itemId });
1107
+ pendingLocalWarnings.push({ finding, toolName, operation });
1108
+ }
1109
+ };
1095
1110
  const respond = (blocked, userMsg, agentMsg) => {
1096
1111
  const ask = blocked === 'ask' && event !== 'prompt';
1097
1112
  const denied = blocked === true;
1113
+ if (!denied && pendingLocalWarnings.length > 0) {
1114
+ spoolLocalWarnings(pendingLocalWarnings.map(w => w.finding), pendingLocalWarnings[0].toolName, pendingLocalWarnings[0].operation);
1115
+ }
1116
+ pendingLocalWarnings.length = 0;
1098
1117
  let decision;
1099
1118
  if (hookFormat === 'claude') {
1100
1119
  if (event === 'prompt') {
@@ -1175,7 +1194,7 @@ async function hookCommandInner(args, config, io) {
1175
1194
  if (event === 'shell' || event === 'mcp' || event === 'file' || event === 'read') {
1176
1195
  await enforceActionPolicy({
1177
1196
  event, payload, apiUrl: apiUrl, shieldId: shieldId, shieldKey,
1178
- shadow, failClosed, timeoutMs, approvalMode, approvalTimeoutMs, approvalPollMs, respond,
1197
+ shadow, failClosed, timeoutMs, approvalMode, approvalTimeoutMs, approvalPollMs, respond, deferLocalWarnings,
1179
1198
  effectivePolicyHash, localPolicies, client,
1180
1199
  });
1181
1200
  return;
@@ -1199,7 +1218,7 @@ async function hookCommandInner(args, config, io) {
1199
1218
  preferCached: shadow,
1200
1219
  });
1201
1220
  const promptOutcome = (0, deterministicGuard_1.resolveDeterministicOutcome)(opts => (0, deterministicGuard_1.scanDeterministicPrompt)(text, opts), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
1202
- spoolLocalWarnings(promptOutcome.warnings, 'prompt', 'prompt');
1221
+ deferLocalWarnings(promptOutcome.warnings, 'prompt', 'prompt');
1203
1222
  const localBlock = promptOutcome.blockingFinding;
1204
1223
  if (localBlock) {
1205
1224
  (0, hookIo_1.dbg)({ phase: 'local_deterministic_prompt_block', event, ruleId: localBlock.ruleId, category: localBlock.category });
@@ -1220,7 +1239,7 @@ async function hookCommandInner(args, config, io) {
1220
1239
  }
1221
1240
  await enforceShieldText({
1222
1241
  event, text, payload, apiUrl: apiUrl, shieldId: shieldId, shieldKey,
1223
- shadow, failClosed, timeoutMs, respond, client,
1242
+ shadow, failClosed, timeoutMs, respond, deferLocalWarnings, client,
1224
1243
  });
1225
1244
  return;
1226
1245
  }
@@ -1304,7 +1323,7 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
1304
1323
  return false;
1305
1324
  }
1306
1325
  async function enforceActionPolicy(ctx) {
1307
- const { event, payload, apiUrl, shieldId, shieldKey, shadow, timeoutMs, respond, effectivePolicyHash } = ctx;
1326
+ const { event, payload, apiUrl, shieldId, shieldKey, shadow, timeoutMs, respond, deferLocalWarnings, effectivePolicyHash } = ctx;
1308
1327
  const call = buildToolCall(event, payload);
1309
1328
  if (!call) {
1310
1329
  respond(false);
@@ -1324,7 +1343,7 @@ async function enforceActionPolicy(ctx) {
1324
1343
  cwd: typeof call.toolArgs.cwd === 'string' ? call.toolArgs.cwd : process.cwd(),
1325
1344
  inspectScripts: event === 'shell',
1326
1345
  }));
1327
- spoolLocalWarnings(toolOutcome.warnings, call.toolName, event);
1346
+ deferLocalWarnings(toolOutcome.warnings, call.toolName, event);
1328
1347
  const localBlock = toolOutcome.blockingFinding;
1329
1348
  if (localBlock) {
1330
1349
  (0, hookIo_1.dbg)({ phase: 'local_deterministic_block', event, tool: call.toolName, ruleId: localBlock.ruleId, category: localBlock.category });
@@ -1427,8 +1446,7 @@ async function enforceActionPolicy(ctx) {
1427
1446
  if (ctx.localPolicies && ctx.localPolicies.length > 0) {
1428
1447
  const { operation: localOp, context: localCtx } = (0, actionPolicyEngine_1.inferToolContext)(call.toolName, call.toolArgs);
1429
1448
  localCtx.toolName = call.toolName;
1430
- localCtx.developerName = developerId();
1431
- localCtx.machineName = os.hostname();
1449
+ (0, machineIdentity_1.stampMachineIdentityContext)(localCtx, developerId());
1432
1450
  const localVerdict = (0, actionPolicyEngine_1.evaluateActionPolicies)(ctx.localPolicies, call.toolName, localOp, localCtx, (0, actionPolicyEngine_1.deriveToolOperations)(call.toolName));
1433
1451
  (0, hookIo_1.dbg)({ phase: 'policy_local_first', event, tool: call.toolName, operation: localOp, verdict: localVerdict.verdict, policyName: localVerdict.policyName });
1434
1452
  if (localVerdict.verdict === 'allow') {
@@ -254,6 +254,7 @@ function machineMetadata(developerName, agentClient = 'cursor') {
254
254
  ...(machineId ? { machineId } : {}),
255
255
  developerName: developerName || machineScopedUser(),
256
256
  machineName: os.hostname(),
257
+ machineKind: (0, machineIdentity_1.localMachineKind)(),
257
258
  os: `${os.platform()} ${os.release()}`,
258
259
  username: safeUserName(),
259
260
  ipAddress: firstPrivateIp(),
@@ -1159,9 +1160,7 @@ class McpGatewayServer {
1159
1160
  if (cachedPolicies && cachedPolicies.length > 0) {
1160
1161
  const context = inferred.context;
1161
1162
  context.toolName = toolName;
1162
- if (this.gatewayConfig.developerName)
1163
- context.developerName = this.gatewayConfig.developerName;
1164
- context.machineName = os.hostname();
1163
+ (0, machineIdentity_1.stampMachineIdentityContext)(context, this.gatewayConfig.developerName);
1165
1164
  const local = (0, actionPolicyEngine_1.evaluateActionPolicies)(cachedPolicies, toolName, localOperation, context, (0, actionPolicyEngine_1.deriveToolOperations)(toolName));
1166
1165
  if (local.verdict === 'block' || local.verdict === 'require_approval') {
1167
1166
  const wouldWhat = local.verdict === 'require_approval' ? 'require approval for' : 'block';
@@ -1205,9 +1204,7 @@ class McpGatewayServer {
1205
1204
  const localOperation = operation || inferred.operation;
1206
1205
  const context = inferred.context;
1207
1206
  context.toolName = toolName;
1208
- if (this.gatewayConfig.developerName)
1209
- context.developerName = this.gatewayConfig.developerName;
1210
- context.machineName = os.hostname();
1207
+ (0, machineIdentity_1.stampMachineIdentityContext)(context, this.gatewayConfig.developerName);
1211
1208
  const local = (0, actionPolicyEngine_1.evaluateActionPolicies)(cachedPolicies, toolName, localOperation, context, (0, actionPolicyEngine_1.deriveToolOperations)(toolName));
1212
1209
  if (local.verdict === 'block') {
1213
1210
  const reason = local.reason || `${toolName}: blocked by org Action Policy`;
@@ -1275,9 +1272,7 @@ class McpGatewayServer {
1275
1272
  const localOperation = operation || inferred.operation;
1276
1273
  const context = inferred.context;
1277
1274
  context.toolName = toolName;
1278
- if (this.gatewayConfig.developerName)
1279
- context.developerName = this.gatewayConfig.developerName;
1280
- context.machineName = os.hostname();
1275
+ (0, machineIdentity_1.stampMachineIdentityContext)(context, this.gatewayConfig.developerName);
1281
1276
  const local = (0, actionPolicyEngine_1.evaluateActionPolicies)(cachedPolicies, toolName, localOperation, context, (0, actionPolicyEngine_1.deriveToolOperations)(toolName));
1282
1277
  const blocking = local.verdict === 'block' || local.verdict === 'require_approval';
1283
1278
  if (blocking && !reportOnlyMode) {
@@ -1,4 +1,16 @@
1
1
  import { BotGuardConfig } from '../config';
2
+ import { type HostRuntime } from '../hostRuntime';
3
+ /**
4
+ * A workload claim must match the machine's own fingerprint. `workload-protect`
5
+ * run on a developer's laptop (or a long-lived VM) with `--kind container`
6
+ * would file that machine under Containers with a 24h TTL and no human — the
7
+ * exact attribution mistake this guard exists to stop. Only a HIGH-confidence
8
+ * physical verdict refuses; unknown / low-confidence hosts proceed (exotic
9
+ * sandboxes with no readable firmware must still be protectable).
10
+ */
11
+ export declare function assertWorkloadSubstrate(kind: string, rt: HostRuntime, options?: {
12
+ force?: boolean;
13
+ }): void;
2
14
  /**
3
15
  * `fullcourtdefense workload-protect` — one-command runtime protection for
4
16
  * EPHEMERAL WORKLOADS: containers, Kubernetes jobs, serverless functions and
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.assertWorkloadSubstrate = assertWorkloadSubstrate;
36
37
  exports.detectWorkloadContext = detectWorkloadContext;
37
38
  exports.workloadProtectCommand = workloadProtectCommand;
38
39
  const fs = __importStar(require("fs"));
@@ -41,6 +42,27 @@ const path = __importStar(require("path"));
41
42
  const config_1 = require("../config");
42
43
  const cliVersion_1 = require("../cliVersion");
43
44
  const ephemeralStack_1 = require("./ephemeralStack");
45
+ const hostRuntime_1 = require("../hostRuntime");
46
+ /**
47
+ * A workload claim must match the machine's own fingerprint. `workload-protect`
48
+ * run on a developer's laptop (or a long-lived VM) with `--kind container`
49
+ * would file that machine under Containers with a 24h TTL and no human — the
50
+ * exact attribution mistake this guard exists to stop. Only a HIGH-confidence
51
+ * physical verdict refuses; unknown / low-confidence hosts proceed (exotic
52
+ * sandboxes with no readable firmware must still be protectable).
53
+ */
54
+ function assertWorkloadSubstrate(kind, rt, options = {}) {
55
+ const claimsIsolated = kind === 'container' || kind === 'kubernetes' || kind === 'serverless';
56
+ if (!claimsIsolated || rt.substrate !== 'physical' || rt.confidence !== 'high')
57
+ return;
58
+ if (options.force) {
59
+ console.warn(` \x1b[33mWarning:\x1b[0m enrolling as ${kind}, but this host looks like a physical machine (${rt.evidence.join('; ')}). Continuing because --force true.`);
60
+ return;
61
+ }
62
+ throw new Error(`This host looks like a PHYSICAL machine, not a ${kind} (${rt.evidence.join('; ')}). `
63
+ + 'workload-protect is for containers, pods and functions. On a laptop or long-lived VM run `fullcourtdefense onboard` instead, '
64
+ + 'or pass --force true if this really is a disposable workload host.');
65
+ }
44
66
  /** Cloud coding agents that run as unattended workloads (env markers they set). */
45
67
  const CLOUD_AGENT_MARKERS = [
46
68
  ['CURSOR_AGENT', 'cursor-cloud-agent'],
@@ -109,10 +131,16 @@ async function workloadProtectCommand(args, config) {
109
131
  if (args.ttlHours !== undefined && (!Number.isFinite(ttlHours) || ttlHours <= 0)) {
110
132
  throw new Error('--ttl-hours must be a positive number of hours (1–168).');
111
133
  }
134
+ // What this machine REALLY is, from firmware / cgroup / marker facts — sent
135
+ // with the enrollment so the fleet attributes every event to a verified
136
+ // substrate, not to the `--kind` the caller claimed.
137
+ const hostRuntime = (0, hostRuntime_1.getHostRuntime)();
138
+ assertWorkloadSubstrate(kind, hostRuntime, { force: args.force === 'true' });
112
139
  console.log('\x1b[1m\x1b[36mFullCourtDefense — workload runtime protection\x1b[0m');
113
140
  console.log(` Workload: ${kind}/${name}${environment ? `/${environment}` : ''}`);
114
141
  if (instanceId)
115
142
  console.log(` Instance: ${instanceId}`);
143
+ console.log(` Detected: ${(0, hostRuntime_1.describeHostRuntime)(hostRuntime)}`);
116
144
  const resp = await fetch(`${apiUrl}/api/cli/enroll/workload`, {
117
145
  method: 'POST',
118
146
  headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
@@ -126,6 +154,7 @@ async function workloadProtectCommand(args, config) {
126
154
  // Read from our own package.json — npm_package_version only exists under
127
155
  // `npm run`, never when the binary runs from a container entrypoint.
128
156
  cliVersion: (0, cliVersion_1.cliVersion)(),
157
+ hostRuntime,
129
158
  }),
130
159
  });
131
160
  const data = (await resp.json().catch(() => ({})));
@@ -147,6 +176,8 @@ async function workloadProtectCommand(args, config) {
147
176
  FCD_DEVELOPER_NAME: `workload@${result.workload.name.toLowerCase()}`,
148
177
  FCD_MACHINE_HOSTNAME: result.workload.key,
149
178
  FCD_EPHEMERAL: '1',
179
+ // Policy engine context: policies written for containers fire here, laptop-only ones never do.
180
+ FCD_MACHINE_KIND: 'workload',
150
181
  };
151
182
  const envFilePath = args.identityFile === 'false'
152
183
  ? undefined
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Host runtime fingerprint — WHAT KIND OF MACHINE is this process really on:
3
+ * a container, a virtual machine, or physical hardware; and, when it can be
4
+ * told from local facts, which container runtime / hypervisor / cloud.
5
+ *
6
+ * Why this exists: `ci-protect` and `workload-protect` let the caller CLAIM a
7
+ * kind (`--kind container`). Fleet attribution must not rest on a claim. Every
8
+ * enrollment and heartbeat carries this fingerprint so the console can state
9
+ * the source of every event precisely ("Docker container on a Google Cloud
10
+ * VM", "Azure VM — GitHub-hosted runner", "physical Windows laptop") and flag a
11
+ * mismatch between what was claimed and what the machine itself reports.
12
+ *
13
+ * Evidence only, no network: DMI/SMBIOS (`/sys/class/dmi/id`), `/proc`,
14
+ * well-known marker files, platform env vars, and on Windows/macOS one local
15
+ * system query. Never a metadata endpoint — this runs at enrollment inside
16
+ * customer infrastructure and must stay offline-safe and side-effect free.
17
+ *
18
+ * Pure core (`detectHostRuntime(probe)`) with an injectable probe so every
19
+ * substrate has a unit test; `getHostRuntime()` wires the real OS and caches.
20
+ */
21
+ export type HostSubstrate = 'container' | 'vm' | 'physical' | 'unknown';
22
+ export interface HostRuntime {
23
+ /** Schema tag so the backend can evolve parsing. */
24
+ v: 1;
25
+ substrate: HostSubstrate;
26
+ /** How sure the substrate verdict is: `high` = a definitive marker, `medium` = strong inference, `low` = weak/absent signals. */
27
+ confidence: 'high' | 'medium' | 'low';
28
+ /** Container engine / sandbox when substrate is `container`. */
29
+ containerRuntime?: 'docker' | 'podman' | 'containerd' | 'cri-o' | 'lxc' | 'gvisor' | 'windows-container' | 'unknown';
30
+ /** Platform that scheduled the container / function, when known. */
31
+ orchestrator?: 'kubernetes' | 'ecs' | 'cloud-run' | 'lambda' | 'azure-functions' | 'azure-container-apps' | 'nomad' | 'fly';
32
+ /** Hypervisor when substrate is `vm` (or the VM under a container, when readable). */
33
+ hypervisor?: 'kvm' | 'hyper-v' | 'vmware' | 'xen' | 'virtualbox' | 'parallels' | 'qemu' | 'firecracker' | 'apple-vz' | 'nitro' | 'unknown';
34
+ /** Cloud / hosting provider inferred from firmware or platform markers. */
35
+ cloud?: 'gcp' | 'aws' | 'azure' | 'oracle' | 'digitalocean' | 'hetzner' | 'alibaba' | 'openstack' | 'fly' | 'unknown';
36
+ /** CI system running this process, when any. */
37
+ ci?: {
38
+ provider: 'github-actions' | 'gitlab-ci' | 'jenkins' | 'circleci' | 'azure-pipelines' | 'bitbucket' | 'buildkite' | 'other';
39
+ hosted: 'provider-hosted' | 'self-hosted' | 'unknown';
40
+ };
41
+ os: 'linux' | 'windows' | 'macos' | 'other';
42
+ arch: string;
43
+ /** Short, deterministic facts that produced the verdict — shown to admins verbatim. Max ~12. */
44
+ evidence: string[];
45
+ detectedAt: string;
46
+ }
47
+ /** Everything the detector reads. Injected in tests; real OS in production. */
48
+ export interface HostRuntimeProbe {
49
+ platform: NodeJS.Platform;
50
+ arch: string;
51
+ env: NodeJS.ProcessEnv;
52
+ exists: (p: string) => boolean;
53
+ read: (p: string) => string | undefined;
54
+ /** Windows: `Manufacturer|Model|SystemFamily` from Win32_ComputerSystem; macOS: `hw.model|kern.hv_vmm_present`. */
55
+ systemInfo: () => string | undefined;
56
+ }
57
+ /** Pure detector. Every read goes through `probe` so each substrate has a fixture test. */
58
+ export declare function detectHostRuntime(probe: HostRuntimeProbe, now?: Date): HostRuntime;
59
+ export declare function realHostRuntimeProbe(): HostRuntimeProbe;
60
+ /**
61
+ * Fingerprint of the machine this process runs on (cached per process). Not
62
+ * for the hook hot path: Windows/macOS spend one local system query (reg.exe /
63
+ * sysctl — tens of milliseconds, never PowerShell).
64
+ * Callers: enrollment commands and the daemon heartbeat.
65
+ */
66
+ export declare function getHostRuntime(): HostRuntime;
67
+ /** One line for terminals: "container (docker) · kubernetes · linux x64". */
68
+ export declare function describeHostRuntime(rt: HostRuntime): string;
@@ -0,0 +1,509 @@
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.detectHostRuntime = detectHostRuntime;
37
+ exports.realHostRuntimeProbe = realHostRuntimeProbe;
38
+ exports.getHostRuntime = getHostRuntime;
39
+ exports.describeHostRuntime = describeHostRuntime;
40
+ const child_process_1 = require("child_process");
41
+ const fs = __importStar(require("fs"));
42
+ const os = __importStar(require("os"));
43
+ const MAX_EVIDENCE = 12;
44
+ function push(evidence, fact) {
45
+ if (evidence.length < MAX_EVIDENCE && !evidence.includes(fact))
46
+ evidence.push(fact);
47
+ }
48
+ function lower(value) {
49
+ return (value || '').trim().toLowerCase();
50
+ }
51
+ /** Firmware vendor strings → hypervisor + cloud. Order matters: most specific first. */
52
+ function classifyFirmware(text) {
53
+ const t = text.toLowerCase();
54
+ if (t.includes('google'))
55
+ return { hypervisor: 'kvm', cloud: 'gcp', label: 'Google Compute Engine' };
56
+ if (t.includes('amazon ec2') || t.includes('amazon'))
57
+ return { hypervisor: 'nitro', cloud: 'aws', label: 'Amazon EC2' };
58
+ if (t.includes('hvm domu'))
59
+ return { hypervisor: 'xen', cloud: 'aws', label: 'Amazon EC2 (Xen)' };
60
+ if (t.includes('7783-7084-3265-9085-8269-3286-77'))
61
+ return { hypervisor: 'hyper-v', cloud: 'azure', label: 'Microsoft Azure' };
62
+ if (t.includes('microsoft corporation') && t.includes('virtual machine'))
63
+ return { hypervisor: 'hyper-v', label: 'Hyper-V' };
64
+ if (t.includes('vmware'))
65
+ return { hypervisor: 'vmware', label: 'VMware' };
66
+ if (t.includes('virtualbox') || t.includes('innotek'))
67
+ return { hypervisor: 'virtualbox', label: 'VirtualBox' };
68
+ if (t.includes('parallels'))
69
+ return { hypervisor: 'parallels', label: 'Parallels' };
70
+ if (t.includes('digitalocean'))
71
+ return { hypervisor: 'kvm', cloud: 'digitalocean', label: 'DigitalOcean' };
72
+ if (t.includes('hetzner'))
73
+ return { hypervisor: 'kvm', cloud: 'hetzner', label: 'Hetzner' };
74
+ if (t.includes('alibaba'))
75
+ return { hypervisor: 'kvm', cloud: 'alibaba', label: 'Alibaba Cloud' };
76
+ if (t.includes('oraclecloud') || t.includes('oracle cloud'))
77
+ return { hypervisor: 'kvm', cloud: 'oracle', label: 'Oracle Cloud' };
78
+ if (t.includes('openstack'))
79
+ return { hypervisor: 'kvm', cloud: 'openstack', label: 'OpenStack' };
80
+ if (t.includes('firecracker'))
81
+ return { hypervisor: 'firecracker', label: 'Firecracker' };
82
+ if (t.includes('qemu') || t.includes('standard pc'))
83
+ return { hypervisor: 'qemu', label: 'QEMU/KVM' };
84
+ if (t.includes('kvm'))
85
+ return { hypervisor: 'kvm', label: 'KVM' };
86
+ if (t.includes('xen'))
87
+ return { hypervisor: 'xen', label: 'Xen' };
88
+ if (t.includes('bochs'))
89
+ return { hypervisor: 'qemu', label: 'Bochs/QEMU' };
90
+ return {};
91
+ }
92
+ function detectCi(env, evidence) {
93
+ if (env.GITHUB_ACTIONS === 'true') {
94
+ const hosted = lower(env.RUNNER_ENVIRONMENT);
95
+ push(evidence, `env:GITHUB_ACTIONS runner=${hosted || 'unknown'}`);
96
+ return { provider: 'github-actions', hosted: hosted === 'github-hosted' ? 'provider-hosted' : hosted === 'self-hosted' ? 'self-hosted' : 'unknown' };
97
+ }
98
+ if (env.GITLAB_CI === 'true') {
99
+ const shared = /gitlab\.com/i.test(env.CI_SERVER_HOST || '') && /shared|saas|gitlab-runner/i.test(env.CI_RUNNER_DESCRIPTION || 'shared');
100
+ push(evidence, `env:GITLAB_CI server=${env.CI_SERVER_HOST || 'unknown'}`);
101
+ return { provider: 'gitlab-ci', hosted: shared ? 'provider-hosted' : env.CI_RUNNER_DESCRIPTION ? 'self-hosted' : 'unknown' };
102
+ }
103
+ if (env.JENKINS_URL) {
104
+ push(evidence, 'env:JENKINS_URL');
105
+ return { provider: 'jenkins', hosted: 'self-hosted' };
106
+ }
107
+ if (env.CIRCLECI === 'true') {
108
+ push(evidence, 'env:CIRCLECI');
109
+ return { provider: 'circleci', hosted: 'provider-hosted' };
110
+ }
111
+ if (env.TF_BUILD === 'True' || env.TF_BUILD === 'true') {
112
+ push(evidence, 'env:TF_BUILD');
113
+ return { provider: 'azure-pipelines', hosted: env.AGENT_NAME?.startsWith('Hosted') || env.AGENT_NAME?.startsWith('Azure Pipelines') ? 'provider-hosted' : 'unknown' };
114
+ }
115
+ if (env.BITBUCKET_BUILD_NUMBER) {
116
+ push(evidence, 'env:BITBUCKET_BUILD_NUMBER');
117
+ return { provider: 'bitbucket', hosted: 'provider-hosted' };
118
+ }
119
+ if (env.BUILDKITE === 'true') {
120
+ push(evidence, 'env:BUILDKITE');
121
+ return { provider: 'buildkite', hosted: 'self-hosted' };
122
+ }
123
+ if (env.CI === 'true' || env.CI === '1') {
124
+ push(evidence, 'env:CI');
125
+ return { provider: 'other', hosted: 'unknown' };
126
+ }
127
+ return undefined;
128
+ }
129
+ function detectLinux(probe, out) {
130
+ const { env, exists, read } = probe;
131
+ const evidence = out.evidence;
132
+ // ── Serverless / managed platforms (env markers set by the platform, not the user) ──
133
+ if (env.AWS_LAMBDA_FUNCTION_NAME) {
134
+ // Lambda executes in a Firecracker microVM; the function code sees a container-like rootfs.
135
+ push(evidence, 'env:AWS_LAMBDA_FUNCTION_NAME');
136
+ out.substrate = 'vm';
137
+ out.confidence = 'high';
138
+ out.hypervisor = 'firecracker';
139
+ out.cloud = 'aws';
140
+ out.orchestrator = 'lambda';
141
+ return;
142
+ }
143
+ if (env.K_SERVICE && env.K_REVISION) {
144
+ push(evidence, 'env:K_SERVICE/K_REVISION (Knative)');
145
+ out.orchestrator = 'cloud-run';
146
+ if (env.CLOUD_RUN_JOB || env.K_CONFIGURATION || !env.KUBERNETES_SERVICE_HOST) {
147
+ out.substrate = 'container';
148
+ out.confidence = 'high';
149
+ out.containerRuntime = 'gvisor';
150
+ out.cloud = 'gcp';
151
+ push(evidence, 'cloud-run sandbox (no KUBERNETES_SERVICE_HOST)');
152
+ return;
153
+ }
154
+ }
155
+ if (env.FUNCTIONS_WORKER_RUNTIME && env.WEBSITE_SITE_NAME) {
156
+ push(evidence, 'env:FUNCTIONS_WORKER_RUNTIME');
157
+ out.substrate = 'container';
158
+ out.confidence = 'high';
159
+ out.orchestrator = 'azure-functions';
160
+ out.cloud = 'azure';
161
+ out.containerRuntime = 'unknown';
162
+ return;
163
+ }
164
+ if (env.ECS_CONTAINER_METADATA_URI_V4 || env.ECS_CONTAINER_METADATA_URI) {
165
+ push(evidence, 'env:ECS_CONTAINER_METADATA_URI');
166
+ out.substrate = 'container';
167
+ out.confidence = 'high';
168
+ out.orchestrator = 'ecs';
169
+ out.cloud = 'aws';
170
+ out.containerRuntime = 'docker';
171
+ return;
172
+ }
173
+ if (env.FLY_APP_NAME && env.FLY_MACHINE_ID) {
174
+ push(evidence, 'env:FLY_MACHINE_ID');
175
+ out.substrate = 'vm';
176
+ out.confidence = 'high';
177
+ out.hypervisor = 'firecracker';
178
+ out.cloud = 'fly';
179
+ out.orchestrator = 'fly';
180
+ return;
181
+ }
182
+ if (env.CONTAINER_APP_NAME && env.CONTAINER_APP_REVISION) {
183
+ push(evidence, 'env:CONTAINER_APP_REVISION');
184
+ out.orchestrator = 'azure-container-apps';
185
+ out.cloud = 'azure';
186
+ }
187
+ if (env.NOMAD_ALLOC_ID) {
188
+ push(evidence, 'env:NOMAD_ALLOC_ID');
189
+ out.orchestrator = 'nomad';
190
+ }
191
+ // ── Container markers (definitive) ──
192
+ let container;
193
+ if (exists('/.dockerenv')) {
194
+ container = 'docker';
195
+ push(evidence, '/.dockerenv present');
196
+ }
197
+ if (exists('/run/.containerenv')) {
198
+ container = container || 'podman';
199
+ push(evidence, '/run/.containerenv present (podman)');
200
+ }
201
+ const cgroup1 = read('/proc/1/cgroup') || '';
202
+ const selfCgroup = read('/proc/self/cgroup') || '';
203
+ const cgroupText = `${cgroup1}\n${selfCgroup}`;
204
+ if (/kubepods/.test(cgroupText)) {
205
+ container = container || 'containerd';
206
+ push(evidence, 'cgroup: kubepods');
207
+ out.orchestrator = out.orchestrator || 'kubernetes';
208
+ }
209
+ if (/\/docker[/-]/.test(cgroupText)) {
210
+ container = container || 'docker';
211
+ push(evidence, 'cgroup: docker');
212
+ }
213
+ if (/cri-containerd|containerd/.test(cgroupText)) {
214
+ container = container || 'containerd';
215
+ push(evidence, 'cgroup: containerd');
216
+ }
217
+ if (/crio-|cri-o/.test(cgroupText)) {
218
+ container = container || 'cri-o';
219
+ push(evidence, 'cgroup: cri-o');
220
+ }
221
+ if (/libpod|podman/.test(cgroupText)) {
222
+ container = container || 'podman';
223
+ push(evidence, 'cgroup: libpod');
224
+ }
225
+ if (/\/lxc\//.test(cgroupText)) {
226
+ container = container || 'lxc';
227
+ push(evidence, 'cgroup: lxc');
228
+ }
229
+ if (env.KUBERNETES_SERVICE_HOST || exists('/var/run/secrets/kubernetes.io/serviceaccount/token')) {
230
+ out.orchestrator = out.orchestrator || 'kubernetes';
231
+ container = container || 'containerd';
232
+ push(evidence, env.KUBERNETES_SERVICE_HOST ? 'env:KUBERNETES_SERVICE_HOST' : 'k8s serviceaccount mounted');
233
+ }
234
+ if (lower(env.container)) {
235
+ push(evidence, `env:container=${lower(env.container)}`);
236
+ if (!container)
237
+ container = lower(env.container) === 'podman' ? 'podman' : lower(env.container) === 'lxc' ? 'lxc' : 'unknown';
238
+ }
239
+ // cgroup v2 with a private cgroup namespace: PID 1 reports the root cgroup.
240
+ // On a host with systemd PID 1 lives in /init.scope, so a bare "0::/" is a
241
+ // strong container signal. Only counted alongside a non-init PID 1.
242
+ const pid1Comm = lower(read('/proc/1/comm'));
243
+ const pid1Sched = lower((read('/proc/1/sched') || '').split('\n')[0]);
244
+ const pid1IsInit = pid1Comm === 'systemd' || pid1Comm === 'init' || pid1Sched.startsWith('systemd') || pid1Sched.startsWith('init');
245
+ if (!container && /^0::\/\s*$/m.test(cgroup1) && pid1Comm && !pid1IsInit) {
246
+ container = 'unknown';
247
+ push(evidence, `cgroup v2 root + pid1=${pid1Comm}`);
248
+ }
249
+ const mountinfo = read('/proc/self/mountinfo') || '';
250
+ if (!container && /\/docker\/containers\//.test(mountinfo)) {
251
+ container = 'docker';
252
+ push(evidence, 'mountinfo: docker containers');
253
+ }
254
+ if (/ \/ [^ ]+ [^ ]+ overlay /.test(mountinfo) || /^\S+ \S+ \S+ \S+ \/ .* - overlay /m.test(mountinfo)) {
255
+ push(evidence, 'rootfs: overlay');
256
+ if (!container && pid1Comm && !pid1IsInit)
257
+ container = 'unknown';
258
+ }
259
+ if (out.orchestrator === 'cloud-run' && !container) {
260
+ container = 'gvisor';
261
+ out.cloud = 'gcp';
262
+ }
263
+ // ── Virtualization markers (firmware) — readable inside most containers too ──
264
+ const dmi = ['sys_vendor', 'product_name', 'board_vendor', 'bios_vendor', 'chassis_asset_tag', 'product_version']
265
+ .map(name => read(`/sys/class/dmi/id/${name}`))
266
+ .filter(Boolean)
267
+ .join(' | ');
268
+ const deviceTree = read('/proc/device-tree/hypervisor/compatible') || read('/sys/firmware/devicetree/base/hypervisor/compatible');
269
+ const cpuinfo = read('/proc/cpuinfo') || '';
270
+ const hypervisorFlag = /^flags\s*:.*\bhypervisor\b/m.test(cpuinfo);
271
+ const xenType = lower(read('/sys/hypervisor/type'));
272
+ const firmware = classifyFirmware(`${dmi} ${deviceTree || ''} ${xenType}`);
273
+ if (firmware.hypervisor) {
274
+ out.hypervisor = firmware.hypervisor;
275
+ if (firmware.cloud)
276
+ out.cloud = out.cloud || firmware.cloud;
277
+ push(evidence, `dmi: ${firmware.label}`);
278
+ }
279
+ else if (hypervisorFlag) {
280
+ out.hypervisor = 'unknown';
281
+ push(evidence, 'cpuinfo: hypervisor flag');
282
+ }
283
+ else if (dmi) {
284
+ push(evidence, `dmi: ${dmi.split(' | ')[0].slice(0, 40)}`);
285
+ }
286
+ // ── Verdict ──
287
+ if (container) {
288
+ out.substrate = 'container';
289
+ out.containerRuntime = container;
290
+ out.confidence = container === 'unknown' ? 'medium' : 'high';
291
+ return;
292
+ }
293
+ if (out.hypervisor) {
294
+ out.substrate = 'vm';
295
+ out.confidence = firmware.hypervisor ? 'high' : 'medium';
296
+ return;
297
+ }
298
+ if (dmi || pid1IsInit) {
299
+ // Real firmware vendor with no hypervisor markers, and a normal init: bare metal.
300
+ out.substrate = 'physical';
301
+ out.confidence = dmi ? 'high' : 'medium';
302
+ if (pid1IsInit)
303
+ push(evidence, `pid1=${pid1Comm || 'init'}`);
304
+ return;
305
+ }
306
+ out.substrate = 'unknown';
307
+ out.confidence = 'low';
308
+ push(evidence, 'no firmware, cgroup or marker evidence readable');
309
+ }
310
+ function detectWindows(probe, out) {
311
+ const { env, exists } = probe;
312
+ const evidence = out.evidence;
313
+ // Windows containers: the sandbox mount point and the mapped-directories
314
+ // folder exist only inside a Windows Server / Hyper-V isolated container.
315
+ if (env.CONTAINER_SANDBOX_MOUNT_POINT || exists('C:\\ContainerMappedDirectories')) {
316
+ push(evidence, env.CONTAINER_SANDBOX_MOUNT_POINT ? 'env:CONTAINER_SANDBOX_MOUNT_POINT' : 'C:\\ContainerMappedDirectories present');
317
+ out.substrate = 'container';
318
+ out.confidence = 'high';
319
+ out.containerRuntime = 'windows-container';
320
+ return;
321
+ }
322
+ const info = probe.systemInfo() || '';
323
+ const firmware = classifyFirmware(info);
324
+ if (info)
325
+ push(evidence, `smbios: ${info.slice(0, 60)}`);
326
+ if (firmware.hypervisor) {
327
+ out.substrate = 'vm';
328
+ out.confidence = 'high';
329
+ out.hypervisor = firmware.hypervisor;
330
+ if (firmware.cloud)
331
+ out.cloud = firmware.cloud;
332
+ push(evidence, `firmware: ${firmware.label}`);
333
+ return;
334
+ }
335
+ if (info) {
336
+ out.substrate = 'physical';
337
+ out.confidence = 'high';
338
+ return;
339
+ }
340
+ out.substrate = 'unknown';
341
+ out.confidence = 'low';
342
+ push(evidence, 'system info unavailable');
343
+ }
344
+ function detectMac(probe, out) {
345
+ const info = probe.systemInfo() || '';
346
+ const evidence = out.evidence;
347
+ const [model = '', hvPresent = ''] = info.split('|');
348
+ if (model)
349
+ push(evidence, `hw.model=${model.trim().slice(0, 40)}`);
350
+ const firmware = classifyFirmware(model);
351
+ if (firmware.hypervisor) {
352
+ out.substrate = 'vm';
353
+ out.confidence = 'high';
354
+ out.hypervisor = firmware.hypervisor;
355
+ push(evidence, `firmware: ${firmware.label}`);
356
+ return;
357
+ }
358
+ if (hvPresent.trim() === '1') {
359
+ push(evidence, 'kern.hv_vmm_present=1');
360
+ out.substrate = 'vm';
361
+ out.confidence = 'high';
362
+ out.hypervisor = 'apple-vz';
363
+ return;
364
+ }
365
+ if (info) {
366
+ out.substrate = 'physical';
367
+ out.confidence = 'high';
368
+ return;
369
+ }
370
+ out.substrate = 'unknown';
371
+ out.confidence = 'low';
372
+ push(evidence, 'sysctl unavailable');
373
+ }
374
+ /** Pure detector. Every read goes through `probe` so each substrate has a fixture test. */
375
+ function detectHostRuntime(probe, now = new Date()) {
376
+ const out = {
377
+ v: 1,
378
+ substrate: 'unknown',
379
+ confidence: 'low',
380
+ os: probe.platform === 'linux' ? 'linux' : probe.platform === 'win32' ? 'windows' : probe.platform === 'darwin' ? 'macos' : 'other',
381
+ arch: probe.arch,
382
+ evidence: [],
383
+ detectedAt: now.toISOString(),
384
+ };
385
+ const ci = detectCi(probe.env, out.evidence);
386
+ if (ci)
387
+ out.ci = ci;
388
+ if (probe.platform === 'linux')
389
+ detectLinux(probe, out);
390
+ else if (probe.platform === 'win32')
391
+ detectWindows(probe, out);
392
+ else if (probe.platform === 'darwin')
393
+ detectMac(probe, out);
394
+ else {
395
+ out.evidence.push(`platform ${probe.platform} not fingerprinted`);
396
+ }
397
+ // Provider-hosted CI runners are the provider's cloud VMs; name the cloud
398
+ // when the firmware did not (GitHub-hosted runners are Azure VMs).
399
+ if (ci?.provider === 'github-actions' && ci.hosted === 'provider-hosted' && out.substrate === 'vm' && !out.cloud)
400
+ out.cloud = 'azure';
401
+ if (ci?.provider === 'gitlab-ci' && ci.hosted === 'provider-hosted' && !out.cloud)
402
+ out.cloud = 'gcp';
403
+ return out;
404
+ }
405
+ function safeRead(p) {
406
+ try {
407
+ const text = fs.readFileSync(p, 'utf8');
408
+ return text.length > 64 * 1024 ? text.slice(0, 64 * 1024) : text;
409
+ }
410
+ catch {
411
+ return undefined;
412
+ }
413
+ }
414
+ /**
415
+ * Windows firmware identity WITHOUT PowerShell. SMBIOS values are mirrored
416
+ * into `HKLM\HARDWARE\DESCRIPTION\System\BIOS` at boot (SystemManufacturer /
417
+ * SystemProductName / SystemFamily = Win32_ComputerSystem Manufacturer /
418
+ * Model / SystemFamily). One `reg.exe query` (~30ms) instead of a PowerShell
419
+ * + CIM startup (seconds, and PowerShell is exactly what EDR-hardened fleets
420
+ * block — the daemon heartbeat must never wait on it).
421
+ */
422
+ function windowsFirmwareInfo() {
423
+ const out = (0, child_process_1.execFileSync)('reg', ['query', 'HKLM\\HARDWARE\\DESCRIPTION\\System\\BIOS'], { encoding: 'utf8', windowsHide: true, timeout: 4000 });
424
+ const value = (name) => {
425
+ const match = out.match(new RegExp(`^\\s*${name}\\s+REG_SZ\\s+(.*)$`, 'im'));
426
+ return match ? match[1].trim() : '';
427
+ };
428
+ const parts = [value('SystemManufacturer'), value('SystemProductName'), value('SystemFamily')];
429
+ return parts.some(Boolean) ? parts.join('|') : undefined;
430
+ }
431
+ function realSystemInfo(platform) {
432
+ try {
433
+ if (platform === 'win32')
434
+ return windowsFirmwareInfo();
435
+ if (platform === 'darwin') {
436
+ const model = (0, child_process_1.execFileSync)('sysctl', ['-n', 'hw.model'], { encoding: 'utf8', timeout: 3000 }).trim();
437
+ let hv = '';
438
+ try {
439
+ hv = (0, child_process_1.execFileSync)('sysctl', ['-n', 'kern.hv_vmm_present'], { encoding: 'utf8', timeout: 3000 }).trim();
440
+ }
441
+ catch { /* older macOS */ }
442
+ return `${model}|${hv}`;
443
+ }
444
+ }
445
+ catch {
446
+ return undefined;
447
+ }
448
+ return undefined;
449
+ }
450
+ function realHostRuntimeProbe() {
451
+ const platform = os.platform();
452
+ return {
453
+ platform,
454
+ arch: os.arch(),
455
+ env: process.env,
456
+ exists: p => { try {
457
+ return fs.existsSync(p);
458
+ }
459
+ catch {
460
+ return false;
461
+ } },
462
+ read: safeRead,
463
+ systemInfo: () => realSystemInfo(platform),
464
+ };
465
+ }
466
+ let cached;
467
+ /**
468
+ * Fingerprint of the machine this process runs on (cached per process). Not
469
+ * for the hook hot path: Windows/macOS spend one local system query (reg.exe /
470
+ * sysctl — tens of milliseconds, never PowerShell).
471
+ * Callers: enrollment commands and the daemon heartbeat.
472
+ */
473
+ function getHostRuntime() {
474
+ if (cached)
475
+ return cached;
476
+ // Test seam for the offline drills (they simulate a pod / CI runner on a
477
+ // developer laptop): a complete fingerprint as JSON. Ignored unless valid.
478
+ const fixture = process.env.FCD_TEST_HOST_RUNTIME_JSON;
479
+ if (fixture) {
480
+ try {
481
+ const parsed = JSON.parse(fixture);
482
+ if (parsed && parsed.v === 1 && typeof parsed.substrate === 'string')
483
+ return (cached = parsed);
484
+ }
485
+ catch { /* fall through to real detection */ }
486
+ }
487
+ cached = detectHostRuntime(realHostRuntimeProbe());
488
+ return cached;
489
+ }
490
+ /** One line for terminals: "container (docker) · kubernetes · linux x64". */
491
+ function describeHostRuntime(rt) {
492
+ const parts = [];
493
+ if (rt.substrate === 'container')
494
+ parts.push(`container${rt.containerRuntime && rt.containerRuntime !== 'unknown' ? ` (${rt.containerRuntime})` : ''}`);
495
+ else if (rt.substrate === 'vm')
496
+ parts.push(`virtual machine${rt.hypervisor && rt.hypervisor !== 'unknown' ? ` (${rt.hypervisor})` : ''}`);
497
+ else if (rt.substrate === 'physical')
498
+ parts.push('physical machine');
499
+ else
500
+ parts.push('unknown substrate');
501
+ if (rt.orchestrator)
502
+ parts.push(rt.orchestrator);
503
+ if (rt.cloud && rt.cloud !== 'unknown')
504
+ parts.push(rt.cloud);
505
+ if (rt.ci)
506
+ parts.push(`${rt.ci.provider}${rt.ci.hosted === 'provider-hosted' ? ' hosted runner' : rt.ci.hosted === 'self-hosted' ? ' self-hosted runner' : ''}`);
507
+ parts.push(`${rt.os} ${rt.arch}`);
508
+ return parts.join(' · ');
509
+ }
@@ -19,5 +19,31 @@ export declare function normalizeHostname(raw: string): string;
19
19
  * (SHA-256, 32 hex chars) so we never transmit the raw hardware UUID.
20
20
  */
21
21
  export declare function getMachineIdentity(): MachineIdentity;
22
+ export type LocalMachineKind = 'workstation' | 'ci' | 'workload';
23
+ /**
24
+ * Which KIND of fleet machine this process belongs to — the value the policy
25
+ * engine reads as `context.machineKind`, so a policy written for containers
26
+ * fires in containers and never on a laptop (and vice versa).
27
+ *
28
+ * Order: `FCD_MACHINE_KIND` (exported by ci-protect / workload-protect into
29
+ * the job/container env), then the ephemeral identity marker those commands
30
+ * write next to the credentials, then the FCD_DEVELOPER_NAME prefix older
31
+ * ephemeral stacks exported (`ci@…` / `workload@…`). Anything else is a
32
+ * workstation. Cheap: one env read, at most one small file read per process.
33
+ */
34
+ export declare function localMachineKind(env?: NodeJS.ProcessEnv): LocalMachineKind;
35
+ /** Test seam: forget the cached kind (the marker / env changed). */
36
+ export declare function resetLocalMachineKindCache(): void;
37
+ /**
38
+ * Stamp THIS machine's identity onto a policy-engine context — the single
39
+ * place the hook and the MCP gateway describe "who/where" to the engine, so
40
+ * `appliesTo.machineNames` / `appliesTo.machineKinds` / `developerName`
41
+ * targeting sees identical values on every enforcement point.
42
+ *
43
+ * `machineName` is the raw OS hostname (what admins pin in appliesTo);
44
+ * `machineKind` is what `localMachineKind()` resolved (workstation / ci /
45
+ * workload). `developerName` is only set when the caller resolved one.
46
+ */
47
+ export declare function stampMachineIdentityContext<T extends Record<string, string>>(context: T, developerName?: string): T;
22
48
  /** Suggested per-machine Shield name, e.g. `boazl@boaz-pc · Windows 11`. */
23
49
  export declare function suggestedShieldName(identity?: MachineIdentity): string;
@@ -35,6 +35,9 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.normalizeHostname = normalizeHostname;
37
37
  exports.getMachineIdentity = getMachineIdentity;
38
+ exports.localMachineKind = localMachineKind;
39
+ exports.resetLocalMachineKindCache = resetLocalMachineKindCache;
40
+ exports.stampMachineIdentityContext = stampMachineIdentityContext;
38
41
  exports.suggestedShieldName = suggestedShieldName;
39
42
  const child_process_1 = require("child_process");
40
43
  const crypto = __importStar(require("crypto"));
@@ -229,6 +232,65 @@ function getMachineIdentity() {
229
232
  };
230
233
  return cached;
231
234
  }
235
+ let cachedKind;
236
+ /**
237
+ * Which KIND of fleet machine this process belongs to — the value the policy
238
+ * engine reads as `context.machineKind`, so a policy written for containers
239
+ * fires in containers and never on a laptop (and vice versa).
240
+ *
241
+ * Order: `FCD_MACHINE_KIND` (exported by ci-protect / workload-protect into
242
+ * the job/container env), then the ephemeral identity marker those commands
243
+ * write next to the credentials, then the FCD_DEVELOPER_NAME prefix older
244
+ * ephemeral stacks exported (`ci@…` / `workload@…`). Anything else is a
245
+ * workstation. Cheap: one env read, at most one small file read per process.
246
+ */
247
+ function localMachineKind(env = process.env) {
248
+ if (cachedKind)
249
+ return cachedKind;
250
+ const explicit = (env.FCD_MACHINE_KIND || '').trim().toLowerCase();
251
+ if (explicit === 'ci' || explicit === 'workload')
252
+ return (cachedKind = explicit);
253
+ if ((env.FCD_MACHINE_ID || '').trim()) {
254
+ const dev = (env.FCD_DEVELOPER_NAME || '').trim().toLowerCase();
255
+ if (dev.startsWith('workload@'))
256
+ return (cachedKind = 'workload');
257
+ if (dev.startsWith('ci@'))
258
+ return (cachedKind = 'ci');
259
+ }
260
+ const marker = safe(() => fs.readFileSync(path.join(os.homedir(), '.fullcourtdefense', 'ephemeral-identity.json'), 'utf8'));
261
+ if (marker) {
262
+ try {
263
+ const kind = String(JSON.parse(marker).kind || '').toLowerCase();
264
+ if (kind === 'workload')
265
+ return (cachedKind = 'workload');
266
+ if (kind === 'pipeline' || kind === 'ci')
267
+ return (cachedKind = 'ci');
268
+ }
269
+ catch { /* malformed marker — treat as a workstation */ }
270
+ }
271
+ return (cachedKind = 'workstation');
272
+ }
273
+ /** Test seam: forget the cached kind (the marker / env changed). */
274
+ function resetLocalMachineKindCache() {
275
+ cachedKind = undefined;
276
+ }
277
+ /**
278
+ * Stamp THIS machine's identity onto a policy-engine context — the single
279
+ * place the hook and the MCP gateway describe "who/where" to the engine, so
280
+ * `appliesTo.machineNames` / `appliesTo.machineKinds` / `developerName`
281
+ * targeting sees identical values on every enforcement point.
282
+ *
283
+ * `machineName` is the raw OS hostname (what admins pin in appliesTo);
284
+ * `machineKind` is what `localMachineKind()` resolved (workstation / ci /
285
+ * workload). `developerName` is only set when the caller resolved one.
286
+ */
287
+ function stampMachineIdentityContext(context, developerName) {
288
+ if (developerName)
289
+ context.developerName = developerName;
290
+ context.machineName = safe(() => os.hostname()) || 'unknown-host';
291
+ context.machineKind = localMachineKind();
292
+ return context;
293
+ }
232
294
  /** Suggested per-machine Shield name, e.g. `boazl@boaz-pc · Windows 11`. */
233
295
  function suggestedShieldName(identity = getMachineIdentity()) {
234
296
  return `${identity.developerName} · ${identity.osFriendly}`;
package/dist/telemetry.js CHANGED
@@ -372,6 +372,12 @@ async function flushSpoolLocked(input) {
372
372
  // the console reads root causes instead of asking the customer.
373
373
  distress: input.distress,
374
374
  lastCrash: input.lastCrash,
375
+ // What kind of machine this really is (container / VM / physical,
376
+ // runtime, hypervisor, cloud) from local firmware + /proc facts —
377
+ // the console states the source of every event from THIS, never
378
+ // from what an enrollment command claimed. Heartbeats only run
379
+ // in the daemon / onboarding process, never on the hook path.
380
+ hostRuntime: (await Promise.resolve().then(() => __importStar(require('./hostRuntime')))).getHostRuntime(),
375
381
  }
376
382
  : undefined,
377
383
  events: batch,
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.26.17"
2
+ "version": "1.27.0"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.26.17",
3
+ "version": "1.27.0",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {