fullcourtdefense-cli 1.26.16 → 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.
- package/dist/actionPolicyEngine.d.ts +16 -0
- package/dist/actionPolicyEngine.js +20 -4
- package/dist/cliVersion.d.ts +11 -0
- package/dist/cliVersion.js +65 -0
- package/dist/commands/ciProtect.js +13 -1
- package/dist/commands/hook.js +28 -10
- package/dist/commands/mcpGateway.js +4 -9
- package/dist/commands/workloadProtect.d.ts +12 -0
- package/dist/commands/workloadProtect.js +35 -1
- package/dist/hostRuntime.d.ts +68 -0
- package/dist/hostRuntime.js +509 -0
- package/dist/machineIdentity.d.ts +26 -0
- package/dist/machineIdentity.js +62 -0
- package/dist/telemetry.js +6 -0
- package/dist/version.json +1 -1
- package/package.json +1 -1
|
@@ -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) {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The installed CLI version.
|
|
3
|
+
*
|
|
4
|
+
* `process.env.npm_package_version` is only populated when npm itself runs the
|
|
5
|
+
* script (`npm run …`). A container entrypoint, an MSI install, or a CI step
|
|
6
|
+
* calling the `fullcourtdefense` binary directly gets `undefined` — which is how
|
|
7
|
+
* enrolled workloads ended up with a blank CLI version in the fleet console.
|
|
8
|
+
* Resolve it from disk instead: the build stamp `dist/version.json` first
|
|
9
|
+
* (same source `--version` prints), then the package manifest.
|
|
10
|
+
*/
|
|
11
|
+
export declare function cliVersion(): string | undefined;
|
|
@@ -0,0 +1,65 @@
|
|
|
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.cliVersion = cliVersion;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
/**
|
|
40
|
+
* The installed CLI version.
|
|
41
|
+
*
|
|
42
|
+
* `process.env.npm_package_version` is only populated when npm itself runs the
|
|
43
|
+
* script (`npm run …`). A container entrypoint, an MSI install, or a CI step
|
|
44
|
+
* calling the `fullcourtdefense` binary directly gets `undefined` — which is how
|
|
45
|
+
* enrolled workloads ended up with a blank CLI version in the fleet console.
|
|
46
|
+
* Resolve it from disk instead: the build stamp `dist/version.json` first
|
|
47
|
+
* (same source `--version` prints), then the package manifest.
|
|
48
|
+
*/
|
|
49
|
+
function cliVersion() {
|
|
50
|
+
const candidates = [
|
|
51
|
+
path.join(__dirname, 'version.json'),
|
|
52
|
+
path.resolve(__dirname, '..', 'package.json'),
|
|
53
|
+
];
|
|
54
|
+
for (const file of candidates) {
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
57
|
+
if (typeof parsed.version === 'string' && parsed.version)
|
|
58
|
+
return parsed.version;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// try the next location
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
@@ -3,7 +3,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.detectCiContext = detectCiContext;
|
|
4
4
|
exports.ciProtectCommand = ciProtectCommand;
|
|
5
5
|
const config_1 = require("../config");
|
|
6
|
+
const cliVersion_1 = require("../cliVersion");
|
|
6
7
|
const ephemeralStack_1 = require("./ephemeralStack");
|
|
8
|
+
const hostRuntime_1 = require("../hostRuntime");
|
|
7
9
|
/** Detect the pipeline from standard CI env vars (GitHub Actions, GitLab CI). */
|
|
8
10
|
function detectCiContext(env = process.env) {
|
|
9
11
|
if (env.GITHUB_ACTIONS === 'true') {
|
|
@@ -67,10 +69,15 @@ async function ciProtectCommand(args, config) {
|
|
|
67
69
|
}
|
|
68
70
|
const runId = (args.runId || detected?.runId || '').trim() || undefined;
|
|
69
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)();
|
|
70
76
|
console.log('\x1b[1m\x1b[36mFullCourtDefense — CI runtime protection\x1b[0m');
|
|
71
77
|
console.log(` Pipeline: ${provider}/${repo}/${workflow}`);
|
|
72
78
|
if (runId)
|
|
73
79
|
console.log(` Run: ${runId}`);
|
|
80
|
+
console.log(` Runner: ${(0, hostRuntime_1.describeHostRuntime)(hostRuntime)}`);
|
|
74
81
|
const sendLog = ciLogSender(apiUrl, apiKey, { provider, repo, workflow, runId, runUrl });
|
|
75
82
|
const resp = await fetch(`${apiUrl}/api/cli/enroll/ci`, {
|
|
76
83
|
method: 'POST',
|
|
@@ -81,7 +88,8 @@ async function ciProtectCommand(args, config) {
|
|
|
81
88
|
workflow,
|
|
82
89
|
runId,
|
|
83
90
|
runUrl,
|
|
84
|
-
cliVersion:
|
|
91
|
+
cliVersion: (0, cliVersion_1.cliVersion)(),
|
|
92
|
+
hostRuntime,
|
|
85
93
|
}),
|
|
86
94
|
});
|
|
87
95
|
const data = (await resp.json().catch(() => ({})));
|
|
@@ -105,6 +113,10 @@ async function ciProtectCommand(args, config) {
|
|
|
105
113
|
FCD_MACHINE_ID: result.machineId,
|
|
106
114
|
FCD_DEVELOPER_NAME: `ci@${repo.toLowerCase()}`,
|
|
107
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',
|
|
108
120
|
}, { githubEnvPath: process.env.GITHUB_ENV });
|
|
109
121
|
console.log(`\x1b[32m✓ ${result.reused ? 'Pipeline re-enrolled' : 'Pipeline enrolled'}\x1b[0m ${result.shieldName}`);
|
|
110
122
|
console.log(` Mode: ${result.enforcementMode} · Config: ${savedPath}`);
|
package/dist/commands/hook.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,13 +33,36 @@ 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"));
|
|
39
40
|
const os = __importStar(require("os"));
|
|
40
41
|
const path = __importStar(require("path"));
|
|
41
42
|
const config_1 = require("../config");
|
|
43
|
+
const cliVersion_1 = require("../cliVersion");
|
|
42
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
|
+
}
|
|
43
66
|
/** Cloud coding agents that run as unattended workloads (env markers they set). */
|
|
44
67
|
const CLOUD_AGENT_MARKERS = [
|
|
45
68
|
['CURSOR_AGENT', 'cursor-cloud-agent'],
|
|
@@ -108,10 +131,16 @@ async function workloadProtectCommand(args, config) {
|
|
|
108
131
|
if (args.ttlHours !== undefined && (!Number.isFinite(ttlHours) || ttlHours <= 0)) {
|
|
109
132
|
throw new Error('--ttl-hours must be a positive number of hours (1–168).');
|
|
110
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' });
|
|
111
139
|
console.log('\x1b[1m\x1b[36mFullCourtDefense — workload runtime protection\x1b[0m');
|
|
112
140
|
console.log(` Workload: ${kind}/${name}${environment ? `/${environment}` : ''}`);
|
|
113
141
|
if (instanceId)
|
|
114
142
|
console.log(` Instance: ${instanceId}`);
|
|
143
|
+
console.log(` Detected: ${(0, hostRuntime_1.describeHostRuntime)(hostRuntime)}`);
|
|
115
144
|
const resp = await fetch(`${apiUrl}/api/cli/enroll/workload`, {
|
|
116
145
|
method: 'POST',
|
|
117
146
|
headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
|
|
@@ -122,7 +151,10 @@ async function workloadProtectCommand(args, config) {
|
|
|
122
151
|
instanceId,
|
|
123
152
|
image,
|
|
124
153
|
ttlHours,
|
|
125
|
-
|
|
154
|
+
// Read from our own package.json — npm_package_version only exists under
|
|
155
|
+
// `npm run`, never when the binary runs from a container entrypoint.
|
|
156
|
+
cliVersion: (0, cliVersion_1.cliVersion)(),
|
|
157
|
+
hostRuntime,
|
|
126
158
|
}),
|
|
127
159
|
});
|
|
128
160
|
const data = (await resp.json().catch(() => ({})));
|
|
@@ -144,6 +176,8 @@ async function workloadProtectCommand(args, config) {
|
|
|
144
176
|
FCD_DEVELOPER_NAME: `workload@${result.workload.name.toLowerCase()}`,
|
|
145
177
|
FCD_MACHINE_HOSTNAME: result.workload.key,
|
|
146
178
|
FCD_EPHEMERAL: '1',
|
|
179
|
+
// Policy engine context: policies written for containers fire here, laptop-only ones never do.
|
|
180
|
+
FCD_MACHINE_KIND: 'workload',
|
|
147
181
|
};
|
|
148
182
|
const envFilePath = args.identityFile === 'false'
|
|
149
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;
|
package/dist/machineIdentity.js
CHANGED
|
@@ -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