@runuai/host 0.9.150 → 0.9.152
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/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/meta/_journal.json +14 -0
- package/db/schema.ts +188 -0
- package/lib/agent-cli.ts +11 -13
- package/lib/agent.ts +177 -7
- package/lib/agents/durable-proc.ts +56 -44
- package/lib/agents/registry.ts +19 -3
- package/lib/agents/session-state.ts +23 -10
- package/lib/agents/transport.ts +101 -69
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +4 -3
- package/lib/attachments.ts +19 -17
- package/lib/capture-flow.ts +319 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2544 -0
- package/lib/cloud-credential-runtime.ts +1035 -0
- package/lib/codex-auth.ts +21 -1
- package/lib/container-runtime.ts +76 -0
- package/lib/credential-cleanup-inventory.ts +405 -0
- package/lib/credential-consumers/engine-anchor.ts +1880 -0
- package/lib/credential-consumers/engines.ts +773 -0
- package/lib/credential-consumers/github.ts +594 -0
- package/lib/credential-consumers/mcp.ts +384 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-store-pickup.ts +15 -2
- package/lib/credential-store.ts +361 -33
- package/lib/credentials-activation-authority.ts +546 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/db.ts +11 -0
- package/lib/docker-exec.ts +3 -1
- package/lib/engine-accounts.ts +42 -32
- package/lib/engine-connect.ts +47 -2
- package/lib/engine-credential-env.ts +50 -0
- package/lib/engine-login.ts +83 -1
- package/lib/env-file.ts +62 -8
- package/lib/env.ts +5 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +219 -177
- package/lib/git-identity.ts +59 -14
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +56 -21
- package/lib/host-env.ts +17 -4
- package/lib/launch-mode.ts +25 -0
- package/lib/legacy-runtime-retirement.ts +388 -0
- package/lib/machine-exec.ts +14 -3
- package/lib/machine-provider-aws.ts +50 -8
- package/lib/machine-provider-local.ts +4 -0
- package/lib/machine-provider.ts +4 -0
- package/lib/managed-operation-drain.ts +8 -0
- package/lib/managed-runtime.ts +371 -7
- package/lib/mcp-capture.ts +860 -0
- package/lib/mcp-connections.ts +8 -1
- package/lib/mcp-gateway.ts +168 -10
- package/lib/obs.ts +2 -6
- package/lib/orchestrator.ts +1837 -224
- package/lib/person-capture.ts +327 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +50 -3
- package/lib/runtime-archive.ts +68 -1
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +429 -13
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/task-trust.ts +34 -3
- package/lib/sidecar-resume.ts +18 -5
- package/lib/skills.ts +47 -8
- package/lib/standard-image.ts +157 -103
- package/lib/stopped-task-status-queue.ts +68 -10
- package/lib/task-credential-cleanup.ts +525 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +66 -163
- package/lib/task-env-source.ts +68 -14
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +69 -28
- package/lib/task-environment/docker.ts +101 -20
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +459 -96
- package/lib/task-environment/machine.ts +101 -11
- package/lib/task-environment/self.ts +108 -50
- package/lib/task-environment/types.ts +89 -5
- package/lib/task-environment/workspace-files.ts +650 -25
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-gateway-recovery.ts +4 -1
- package/lib/task-gateway-state.ts +15 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +56 -2
- package/lib/token-broker-local.ts +28 -4
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +368 -28
- package/lib/token-lanes/openai-oauth.ts +7 -3
- package/lib/token-lanes/xai-oauth.ts +7 -3
- package/lib/transcript.ts +9 -5
- package/package.json +6 -2
- package/scripts/agent/task-up.sh +242 -100
- package/src/cloud-credentials.ts +2399 -0
- package/src/event-outbox.ts +8 -6
- package/src/index.ts +1386 -875
- package/src/main.ts +1061 -139
- package/src/outbound-frame.ts +31 -0
- package/src/project-env-policy.ts +125 -10
- package/src/protocol.ts +699 -23
- package/src/ui/server.ts +48 -10
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-154 task-machine swap admission guard.
|
|
3
|
+
*
|
|
4
|
+
* A task machine must not ask for a credential lease until this guard has
|
|
5
|
+
* proved that plaintext cannot be paged to durable storage. No active swap is
|
|
6
|
+
* safe. Otherwise every active swap device must be a single dm-crypt mapping
|
|
7
|
+
* created in plain mode from `/dev/urandom`, with `swap` semantics. A device
|
|
8
|
+
* mapper mapping cannot survive a reboot and `/dev/urandom` supplies a new
|
|
9
|
+
* key whenever cryptsetup creates it, so that combination bounds the key and
|
|
10
|
+
* the plaintext it protects to the current boot.
|
|
11
|
+
*
|
|
12
|
+
* This is only an admission probe. The machine bootstrap owns replacing the
|
|
13
|
+
* legacy raw swap and removing it from the boot configuration before this is
|
|
14
|
+
* called.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import { open } from "node:fs/promises";
|
|
19
|
+
|
|
20
|
+
const PROC_SWAPS_PATH = "/proc/swaps";
|
|
21
|
+
const CRYPTTAB_PATH = "/etc/crypttab";
|
|
22
|
+
const DMSETUP_PATH = "/usr/sbin/dmsetup";
|
|
23
|
+
const FINDFS_PATH = "/usr/sbin/findfs";
|
|
24
|
+
const LOSETUP_PATH = "/usr/sbin/losetup";
|
|
25
|
+
const READLINK_PATH = "/usr/bin/readlink";
|
|
26
|
+
const STAT_PATH = "/usr/bin/stat";
|
|
27
|
+
|
|
28
|
+
const FILE_TIMEOUT_MS = 2_000;
|
|
29
|
+
const COMMAND_TIMEOUT_MS = 3_000;
|
|
30
|
+
const PROC_SWAPS_MAX_BYTES = 64 * 1024;
|
|
31
|
+
const CRYPTTAB_MAX_BYTES = 128 * 1024;
|
|
32
|
+
const COMMAND_MAX_BYTES = 32 * 1024;
|
|
33
|
+
const MAX_ACTIVE_SWAP_DEVICES = 16;
|
|
34
|
+
|
|
35
|
+
const MAPPER_NAME = /^[A-Za-z0-9_.+-]{1,127}$/;
|
|
36
|
+
const DEVICE_PATH = /^\/dev\/[A-Za-z0-9_.+:/-]{1,4089}$/;
|
|
37
|
+
const DEVICE_SELECTOR = /^(?:UUID|PARTUUID)=[A-Fa-f0-9-]{1,128}$/;
|
|
38
|
+
const INTEGER = /^(?:0|[1-9][0-9]*)$/;
|
|
39
|
+
const SIGNED_INTEGER = /^-?(?:0|[1-9][0-9]*)$/;
|
|
40
|
+
const SAFE_CIPHER = "aes-xts-plain64";
|
|
41
|
+
const SAFE_KEY_BITS = 512;
|
|
42
|
+
/** The task-machine bootstrap's one root-owned loop backing file. */
|
|
43
|
+
const MANAGED_SWAP_FILE = "/swapfile";
|
|
44
|
+
const APPROVED_CRYPTTAB_OPTIONS = new Set([
|
|
45
|
+
"plain",
|
|
46
|
+
"swap",
|
|
47
|
+
"cipher",
|
|
48
|
+
"size",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
export type SwapSafetyCommandResult =
|
|
52
|
+
| { ok: true; stdout: string }
|
|
53
|
+
| {
|
|
54
|
+
ok: false;
|
|
55
|
+
reason:
|
|
56
|
+
| "spawn"
|
|
57
|
+
| "timeout"
|
|
58
|
+
| "output-limit"
|
|
59
|
+
| "invalid-output"
|
|
60
|
+
| "exit";
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export interface SwapSafetyIo {
|
|
64
|
+
/** Read at most maxBytes and fail if the read does not finish in time. */
|
|
65
|
+
readText(
|
|
66
|
+
path: string,
|
|
67
|
+
limits: { maxBytes: number; timeoutMs: number },
|
|
68
|
+
): Promise<string>;
|
|
69
|
+
/** Execute an argv directly (never through a shell), with bounded output. */
|
|
70
|
+
runFile(
|
|
71
|
+
command: string,
|
|
72
|
+
args: readonly string[],
|
|
73
|
+
limits: { maxOutputBytes: number; timeoutMs: number },
|
|
74
|
+
): Promise<SwapSafetyCommandResult>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type TaskMachineSwapSafetyVerdict =
|
|
78
|
+
| {
|
|
79
|
+
ok: true;
|
|
80
|
+
proof: "no-active-swap" | "boot-ephemeral-dm-crypt";
|
|
81
|
+
activeSwapDevices: readonly string[];
|
|
82
|
+
}
|
|
83
|
+
| {
|
|
84
|
+
ok: false;
|
|
85
|
+
code:
|
|
86
|
+
| "unsupported-platform"
|
|
87
|
+
| "swap-state-unreadable"
|
|
88
|
+
| "swap-state-malformed"
|
|
89
|
+
| "too-many-swap-devices"
|
|
90
|
+
| "crypttab-unreadable"
|
|
91
|
+
| "crypttab-malformed"
|
|
92
|
+
| "swap-not-device-mapper"
|
|
93
|
+
| "swap-not-boot-ephemeral"
|
|
94
|
+
| "swap-probe-failed";
|
|
95
|
+
reason: string;
|
|
96
|
+
activeSwapDevices: readonly string[];
|
|
97
|
+
device?: string;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export interface TaskMachineSwapSafetyOptions {
|
|
101
|
+
io?: SwapSafetyIo;
|
|
102
|
+
platform?: NodeJS.Platform;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface ActiveSwap {
|
|
106
|
+
path: string;
|
|
107
|
+
sizeKiB: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface CrypttabEntry {
|
|
111
|
+
name: string;
|
|
112
|
+
source: string;
|
|
113
|
+
keyFile: string | null;
|
|
114
|
+
options: Map<string, string | null>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface DeviceMapperInfo {
|
|
118
|
+
name: string;
|
|
119
|
+
uuid: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
class SwapParseError extends Error {
|
|
123
|
+
constructor(
|
|
124
|
+
readonly kind: "malformed" | "too-many",
|
|
125
|
+
message: string,
|
|
126
|
+
) {
|
|
127
|
+
super(message);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function safeInteger(raw: string, signed = false): number | null {
|
|
132
|
+
if (!(signed ? SIGNED_INTEGER : INTEGER).test(raw)) return null;
|
|
133
|
+
const value = Number(raw);
|
|
134
|
+
return Number.isSafeInteger(value) ? value : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function parseProcSwaps(raw: string): ActiveSwap[] {
|
|
138
|
+
if (raw.includes("\0") || raw.includes("\r")) {
|
|
139
|
+
throw new SwapParseError("malformed", "the kernel swap table is malformed");
|
|
140
|
+
}
|
|
141
|
+
const lines = raw.split("\n");
|
|
142
|
+
if (lines.at(-1) === "") lines.pop();
|
|
143
|
+
const header = lines.shift();
|
|
144
|
+
if (!header || header.trim().split(/\s+/).join(" ") !== "Filename Type Size Used Priority") {
|
|
145
|
+
throw new SwapParseError("malformed", "the kernel swap table header is malformed");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (lines.length > MAX_ACTIVE_SWAP_DEVICES) {
|
|
149
|
+
throw new SwapParseError(
|
|
150
|
+
"too-many",
|
|
151
|
+
`more than ${MAX_ACTIVE_SWAP_DEVICES} swap devices are active`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const seen = new Set<string>();
|
|
156
|
+
const swaps: ActiveSwap[] = [];
|
|
157
|
+
for (const line of lines) {
|
|
158
|
+
if (line.trim() === "") {
|
|
159
|
+
throw new SwapParseError("malformed", "the kernel swap table contains an empty row");
|
|
160
|
+
}
|
|
161
|
+
const fields = line.trim().split(/\s+/);
|
|
162
|
+
if (fields.length !== 5) {
|
|
163
|
+
throw new SwapParseError("malformed", "the kernel swap table contains an ambiguous row");
|
|
164
|
+
}
|
|
165
|
+
const [path, type, rawSize, rawUsed, rawPriority] = fields as [
|
|
166
|
+
string,
|
|
167
|
+
string,
|
|
168
|
+
string,
|
|
169
|
+
string,
|
|
170
|
+
string,
|
|
171
|
+
];
|
|
172
|
+
const sizeKiB = safeInteger(rawSize);
|
|
173
|
+
const usedKiB = safeInteger(rawUsed);
|
|
174
|
+
const priority = safeInteger(rawPriority, true);
|
|
175
|
+
if (
|
|
176
|
+
type !== "partition" ||
|
|
177
|
+
!DEVICE_PATH.test(path) ||
|
|
178
|
+
path.split("/").some((part) => part === "." || part === "..") ||
|
|
179
|
+
sizeKiB === null ||
|
|
180
|
+
sizeKiB < 1 ||
|
|
181
|
+
usedKiB === null ||
|
|
182
|
+
usedKiB < 0 ||
|
|
183
|
+
usedKiB > sizeKiB ||
|
|
184
|
+
priority === null
|
|
185
|
+
) {
|
|
186
|
+
throw new SwapParseError(
|
|
187
|
+
"malformed",
|
|
188
|
+
"every active swap row must name one unambiguous block device",
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
if (seen.has(path)) {
|
|
192
|
+
throw new SwapParseError("malformed", "the kernel swap table contains a duplicate device");
|
|
193
|
+
}
|
|
194
|
+
seen.add(path);
|
|
195
|
+
swaps.push({ path, sizeKiB });
|
|
196
|
+
}
|
|
197
|
+
return swaps;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function stripCrypttabComment(line: string): string {
|
|
201
|
+
const comment = line.search(/(?:^|\s)#/);
|
|
202
|
+
return comment < 0 ? line : line.slice(0, comment).trimEnd();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function parseCrypttabOptions(raw: string): Map<string, string | null> {
|
|
206
|
+
const options = new Map<string, string | null>();
|
|
207
|
+
for (const item of raw.split(",")) {
|
|
208
|
+
if (item === "" || item.includes("\\") || /\s/.test(item)) {
|
|
209
|
+
throw new Error("invalid crypttab option");
|
|
210
|
+
}
|
|
211
|
+
const separator = item.indexOf("=");
|
|
212
|
+
const key = separator < 0 ? item : item.slice(0, separator);
|
|
213
|
+
const value = separator < 0 ? null : item.slice(separator + 1);
|
|
214
|
+
if (
|
|
215
|
+
!/^[a-z][a-z0-9-]*$/.test(key) ||
|
|
216
|
+
value === "" ||
|
|
217
|
+
options.has(key)
|
|
218
|
+
) {
|
|
219
|
+
throw new Error("invalid crypttab option");
|
|
220
|
+
}
|
|
221
|
+
options.set(key, value);
|
|
222
|
+
}
|
|
223
|
+
return options;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function parseCrypttab(raw: string): Map<string, CrypttabEntry> {
|
|
227
|
+
if (raw.includes("\0") || raw.includes("\r")) {
|
|
228
|
+
throw new Error("crypttab is malformed");
|
|
229
|
+
}
|
|
230
|
+
const entries = new Map<string, CrypttabEntry>();
|
|
231
|
+
for (const original of raw.split("\n")) {
|
|
232
|
+
const line = stripCrypttabComment(original).trim();
|
|
233
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
234
|
+
const fields = line.split(/\s+/);
|
|
235
|
+
if (fields.length < 2 || fields.length > 4) {
|
|
236
|
+
throw new Error("crypttab contains an ambiguous row");
|
|
237
|
+
}
|
|
238
|
+
const [name, source, keyFile = null, rawOptions = "defaults"] = fields;
|
|
239
|
+
if (
|
|
240
|
+
!name ||
|
|
241
|
+
!MAPPER_NAME.test(name) ||
|
|
242
|
+
!source ||
|
|
243
|
+
source.length > 4096 ||
|
|
244
|
+
source.includes("\\") ||
|
|
245
|
+
(source !== MANAGED_SWAP_FILE &&
|
|
246
|
+
!DEVICE_PATH.test(source) &&
|
|
247
|
+
!DEVICE_SELECTOR.test(source)) ||
|
|
248
|
+
(keyFile !== null &&
|
|
249
|
+
(keyFile.length > 4096 || keyFile.includes("\\"))) ||
|
|
250
|
+
entries.has(name)
|
|
251
|
+
) {
|
|
252
|
+
throw new Error("crypttab contains an ambiguous row");
|
|
253
|
+
}
|
|
254
|
+
entries.set(name, {
|
|
255
|
+
name,
|
|
256
|
+
source,
|
|
257
|
+
keyFile,
|
|
258
|
+
options: parseCrypttabOptions(rawOptions),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
return entries;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function parseDmInfo(raw: string): DeviceMapperInfo | null {
|
|
265
|
+
const lines = raw.trim().split("\n");
|
|
266
|
+
if (lines.length !== 1 || lines[0] === "") return null;
|
|
267
|
+
const fields = lines[0]!.split("\t").map((field) => field.trim());
|
|
268
|
+
if (fields.length !== 4) return null;
|
|
269
|
+
const [name, uuid, rawSegments, attributes] = fields as [
|
|
270
|
+
string,
|
|
271
|
+
string,
|
|
272
|
+
string,
|
|
273
|
+
string,
|
|
274
|
+
];
|
|
275
|
+
if (
|
|
276
|
+
!MAPPER_NAME.test(name) ||
|
|
277
|
+
!uuid.startsWith("CRYPT-PLAIN-") ||
|
|
278
|
+
!uuid.endsWith(`-${name}`) ||
|
|
279
|
+
safeInteger(rawSegments) !== 1 ||
|
|
280
|
+
// Live table, no inactive replacement waiting, not suspended, writable.
|
|
281
|
+
attributes !== "L--w"
|
|
282
|
+
) {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
return { name, uuid };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function crypttabEntryIsBootEphemeral(entry: CrypttabEntry): boolean {
|
|
289
|
+
if ([...entry.options.keys()].some((name) => !APPROVED_CRYPTTAB_OPTIONS.has(name))) {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
return (
|
|
293
|
+
entry.keyFile === "/dev/urandom" &&
|
|
294
|
+
entry.options.get("swap") === null &&
|
|
295
|
+
// systemd's `swap` option itself implies plain mode. Accepting an
|
|
296
|
+
// explicit valueless `plain` is harmless; a value-bearing lookalike is
|
|
297
|
+
// not. Cipher and key size are mandatory so the next boot cannot fall
|
|
298
|
+
// back to distro defaults that differ from the live table proved below.
|
|
299
|
+
(!entry.options.has("plain") || entry.options.get("plain") === null) &&
|
|
300
|
+
entry.options.get("cipher") === SAFE_CIPHER &&
|
|
301
|
+
entry.options.get("size") === String(SAFE_KEY_BITS)
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function safeDmTable(raw: string): { backing: string } | null {
|
|
306
|
+
const lines = raw.trim().split("\n");
|
|
307
|
+
if (lines.length !== 1 || lines[0] === "") return null;
|
|
308
|
+
const fields = lines[0]!.trim().split(/\s+/);
|
|
309
|
+
// No optional dm-crypt target features are approved. In particular,
|
|
310
|
+
// allow_discards would reveal allocation patterns and must not be smuggled
|
|
311
|
+
// into the live table independently of crypttab.
|
|
312
|
+
if (fields.length !== 8) return null;
|
|
313
|
+
const [rawStart, rawLength, target, cipher, hiddenKey, rawIvOffset, backing, rawDataOffset] =
|
|
314
|
+
fields as [string, string, string, string, string, string, string, string];
|
|
315
|
+
if (
|
|
316
|
+
safeInteger(rawStart) === 0 &&
|
|
317
|
+
(safeInteger(rawLength) ?? 0) > 0 &&
|
|
318
|
+
target === "crypt" &&
|
|
319
|
+
cipher === SAFE_CIPHER &&
|
|
320
|
+
hiddenKey.length === SAFE_KEY_BITS / 4 &&
|
|
321
|
+
/^0+$/.test(hiddenKey) &&
|
|
322
|
+
safeInteger(rawIvOffset) !== null &&
|
|
323
|
+
/^[0-9]+:[0-9]+$/.test(backing) &&
|
|
324
|
+
safeInteger(rawDataOffset) !== null
|
|
325
|
+
) {
|
|
326
|
+
return { backing };
|
|
327
|
+
}
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function oneLine(raw: string): string | null {
|
|
332
|
+
const lines = raw.trim().split("\n");
|
|
333
|
+
return lines.length === 1 && lines[0] !== "" ? lines[0]! : null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function resolvedSourceDeviceNumber(
|
|
337
|
+
entry: CrypttabEntry,
|
|
338
|
+
io: SwapSafetyIo,
|
|
339
|
+
limits: { maxOutputBytes: number; timeoutMs: number },
|
|
340
|
+
): Promise<string | null> {
|
|
341
|
+
let path = entry.source;
|
|
342
|
+
if (DEVICE_SELECTOR.test(path)) {
|
|
343
|
+
const found = await io.runFile(FINDFS_PATH, [path], limits);
|
|
344
|
+
if (!found.ok) return null;
|
|
345
|
+
const value = oneLine(found.stdout);
|
|
346
|
+
if (!value || !DEVICE_PATH.test(value)) return null;
|
|
347
|
+
path = value;
|
|
348
|
+
}
|
|
349
|
+
const resolved = await io.runFile(READLINK_PATH, ["-f", "--", path], limits);
|
|
350
|
+
if (!resolved.ok) return null;
|
|
351
|
+
const device = oneLine(resolved.stdout);
|
|
352
|
+
if (!device || !DEVICE_PATH.test(device)) return null;
|
|
353
|
+
const stat = await io.runFile(
|
|
354
|
+
STAT_PATH,
|
|
355
|
+
["--format=%t:%T", "--", device],
|
|
356
|
+
limits,
|
|
357
|
+
);
|
|
358
|
+
if (!stat.ok) return null;
|
|
359
|
+
const rawNumber = oneLine(stat.stdout);
|
|
360
|
+
const match = rawNumber?.match(/^([0-9a-fA-F]+):([0-9a-fA-F]+)$/);
|
|
361
|
+
if (!match) return null;
|
|
362
|
+
const major = Number.parseInt(match[1]!, 16);
|
|
363
|
+
const minor = Number.parseInt(match[2]!, 16);
|
|
364
|
+
if (!Number.isSafeInteger(major) || !Number.isSafeInteger(minor)) return null;
|
|
365
|
+
return `${String(major)}:${String(minor)}`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function configuredSourceMatchesBacking(
|
|
369
|
+
entry: CrypttabEntry,
|
|
370
|
+
liveBacking: string,
|
|
371
|
+
io: SwapSafetyIo,
|
|
372
|
+
limits: { maxOutputBytes: number; timeoutMs: number },
|
|
373
|
+
): Promise<boolean> {
|
|
374
|
+
if (entry.source !== MANAGED_SWAP_FILE) {
|
|
375
|
+
return (await resolvedSourceDeviceNumber(entry, io, limits)) === liveBacking;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// cryptsetup attaches a regular-file source through a loop device. Bind
|
|
379
|
+
// all three identities: the canonical root-owned 0600 file, the loop node
|
|
380
|
+
// named by the live dm table, and that loop node's reported backing file.
|
|
381
|
+
const resolvedFile = await io.runFile(
|
|
382
|
+
READLINK_PATH,
|
|
383
|
+
["-f", "--", MANAGED_SWAP_FILE],
|
|
384
|
+
limits,
|
|
385
|
+
);
|
|
386
|
+
if (!resolvedFile.ok || oneLine(resolvedFile.stdout) !== MANAGED_SWAP_FILE) {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
const fileStat = await io.runFile(
|
|
390
|
+
STAT_PATH,
|
|
391
|
+
["--format=%F\t%u\t%a", "--", MANAGED_SWAP_FILE],
|
|
392
|
+
limits,
|
|
393
|
+
);
|
|
394
|
+
if (!fileStat.ok || oneLine(fileStat.stdout) !== "regular file\t0\t600") {
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
if (!/^[0-9]+:[0-9]+$/.test(liveBacking)) return false;
|
|
398
|
+
const blockLink = `/dev/block/${liveBacking}`;
|
|
399
|
+
const resolvedLoop = await io.runFile(
|
|
400
|
+
READLINK_PATH,
|
|
401
|
+
["-f", "--", blockLink],
|
|
402
|
+
limits,
|
|
403
|
+
);
|
|
404
|
+
const loopDevice = resolvedLoop.ok ? oneLine(resolvedLoop.stdout) : null;
|
|
405
|
+
if (!loopDevice || !/^\/dev\/loop[0-9]+$/.test(loopDevice)) return false;
|
|
406
|
+
const loopBacking = await io.runFile(
|
|
407
|
+
LOSETUP_PATH,
|
|
408
|
+
["--noheadings", "--raw", "--output", "BACK-FILE", loopDevice],
|
|
409
|
+
limits,
|
|
410
|
+
);
|
|
411
|
+
return loopBacking.ok && oneLine(loopBacking.stdout) === MANAGED_SWAP_FILE;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function failure(
|
|
415
|
+
code: Exclude<TaskMachineSwapSafetyVerdict, { ok: true }>["code"],
|
|
416
|
+
reason: string,
|
|
417
|
+
activeSwapDevices: readonly string[],
|
|
418
|
+
device?: string,
|
|
419
|
+
): TaskMachineSwapSafetyVerdict {
|
|
420
|
+
return { ok: false, code, reason, activeSwapDevices, device };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function verifyDevice(
|
|
424
|
+
swap: ActiveSwap,
|
|
425
|
+
entries: Map<string, CrypttabEntry>,
|
|
426
|
+
io: SwapSafetyIo,
|
|
427
|
+
activeSwapDevices: readonly string[],
|
|
428
|
+
): Promise<TaskMachineSwapSafetyVerdict | null> {
|
|
429
|
+
const limits = {
|
|
430
|
+
maxOutputBytes: COMMAND_MAX_BYTES,
|
|
431
|
+
timeoutMs: COMMAND_TIMEOUT_MS,
|
|
432
|
+
};
|
|
433
|
+
const info = await io.runFile(
|
|
434
|
+
DMSETUP_PATH,
|
|
435
|
+
[
|
|
436
|
+
"info",
|
|
437
|
+
"--columns",
|
|
438
|
+
"--noheadings",
|
|
439
|
+
"--separator",
|
|
440
|
+
"\t",
|
|
441
|
+
"--options",
|
|
442
|
+
"name,uuid,segments,attr",
|
|
443
|
+
swap.path,
|
|
444
|
+
],
|
|
445
|
+
limits,
|
|
446
|
+
);
|
|
447
|
+
if (!info.ok) {
|
|
448
|
+
return failure(
|
|
449
|
+
info.reason === "exit" ? "swap-not-device-mapper" : "swap-probe-failed",
|
|
450
|
+
info.reason === "exit"
|
|
451
|
+
? `active swap ${swap.path} is not a readable device-mapper mapping`
|
|
452
|
+
: `the device-mapper identity probe for ${swap.path} did not complete safely`,
|
|
453
|
+
activeSwapDevices,
|
|
454
|
+
swap.path,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
const identity = parseDmInfo(info.stdout);
|
|
458
|
+
if (!identity) {
|
|
459
|
+
return failure(
|
|
460
|
+
"swap-not-device-mapper",
|
|
461
|
+
`active swap ${swap.path} is not one plain, cryptsetup-owned device-mapper mapping`,
|
|
462
|
+
activeSwapDevices,
|
|
463
|
+
swap.path,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const entry = entries.get(identity.name);
|
|
468
|
+
if (!entry || !crypttabEntryIsBootEphemeral(entry)) {
|
|
469
|
+
return failure(
|
|
470
|
+
"swap-not-boot-ephemeral",
|
|
471
|
+
`active swap ${swap.path} has no unambiguous boot-ephemeral crypttab entry`,
|
|
472
|
+
activeSwapDevices,
|
|
473
|
+
swap.path,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Deliberately omit dmsetup's --showkeys. The default table view replaces
|
|
478
|
+
// the key with zeroes; checking that mask also fails closed if a platform's
|
|
479
|
+
// dmsetup would unexpectedly expose key material in its ordinary output.
|
|
480
|
+
const table = await io.runFile(
|
|
481
|
+
DMSETUP_PATH,
|
|
482
|
+
["table", "--target", "crypt", swap.path],
|
|
483
|
+
limits,
|
|
484
|
+
);
|
|
485
|
+
if (!table.ok) {
|
|
486
|
+
return failure(
|
|
487
|
+
"swap-probe-failed",
|
|
488
|
+
`the encrypted mapping probe for ${swap.path} did not complete safely`,
|
|
489
|
+
activeSwapDevices,
|
|
490
|
+
swap.path,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
const liveTable = safeDmTable(table.stdout);
|
|
494
|
+
if (!liveTable) {
|
|
495
|
+
return failure(
|
|
496
|
+
"swap-not-boot-ephemeral",
|
|
497
|
+
`active swap ${swap.path} is not one ${SAFE_KEY_BITS}-bit ${SAFE_CIPHER} mapping`,
|
|
498
|
+
activeSwapDevices,
|
|
499
|
+
swap.path,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
// crypttab states how this boot creates the mapping; the live table states
|
|
503
|
+
// what it actually protects. Bind the two so an identically named mapping
|
|
504
|
+
// over another device cannot borrow the safe policy entry. The hidden key
|
|
505
|
+
// itself is intentionally not observable; this proof trusts the root-owned
|
|
506
|
+
// boot policy plus its exact live dm-crypt identity.
|
|
507
|
+
const backingMatches = await configuredSourceMatchesBacking(
|
|
508
|
+
entry,
|
|
509
|
+
liveTable.backing,
|
|
510
|
+
io,
|
|
511
|
+
limits,
|
|
512
|
+
);
|
|
513
|
+
if (!backingMatches) {
|
|
514
|
+
return failure(
|
|
515
|
+
"swap-not-boot-ephemeral",
|
|
516
|
+
`active swap ${swap.path} is not bound to its boot-ephemeral source`,
|
|
517
|
+
activeSwapDevices,
|
|
518
|
+
swap.path,
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Return an admission verdict. Callers must ask for no lease and read no
|
|
526
|
+
* credential parameter when `ok` is false.
|
|
527
|
+
*/
|
|
528
|
+
export async function verifyTaskMachineSwapSafety(
|
|
529
|
+
options: TaskMachineSwapSafetyOptions = {},
|
|
530
|
+
): Promise<TaskMachineSwapSafetyVerdict> {
|
|
531
|
+
if ((options.platform ?? process.platform) !== "linux") {
|
|
532
|
+
return failure(
|
|
533
|
+
"unsupported-platform",
|
|
534
|
+
"task-machine swap safety can only be proved on Linux",
|
|
535
|
+
[],
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
const io = options.io ?? nodeSwapSafetyIo;
|
|
539
|
+
let rawSwaps: string;
|
|
540
|
+
try {
|
|
541
|
+
rawSwaps = await io.readText(PROC_SWAPS_PATH, {
|
|
542
|
+
maxBytes: PROC_SWAPS_MAX_BYTES,
|
|
543
|
+
timeoutMs: FILE_TIMEOUT_MS,
|
|
544
|
+
});
|
|
545
|
+
} catch {
|
|
546
|
+
return failure(
|
|
547
|
+
"swap-state-unreadable",
|
|
548
|
+
"the active kernel swap table could not be read safely",
|
|
549
|
+
[],
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
let swaps: ActiveSwap[];
|
|
554
|
+
try {
|
|
555
|
+
swaps = parseProcSwaps(rawSwaps);
|
|
556
|
+
} catch (error) {
|
|
557
|
+
const kind = error instanceof SwapParseError ? error.kind : "malformed";
|
|
558
|
+
return failure(
|
|
559
|
+
kind === "too-many" ? "too-many-swap-devices" : "swap-state-malformed",
|
|
560
|
+
error instanceof Error ? error.message : "the kernel swap table is malformed",
|
|
561
|
+
[],
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
const activeSwapDevices = swaps.map((swap) => swap.path);
|
|
565
|
+
if (swaps.length === 0) {
|
|
566
|
+
return { ok: true, proof: "no-active-swap", activeSwapDevices };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
let rawCrypttab: string;
|
|
570
|
+
try {
|
|
571
|
+
rawCrypttab = await io.readText(CRYPTTAB_PATH, {
|
|
572
|
+
maxBytes: CRYPTTAB_MAX_BYTES,
|
|
573
|
+
timeoutMs: FILE_TIMEOUT_MS,
|
|
574
|
+
});
|
|
575
|
+
} catch {
|
|
576
|
+
return failure(
|
|
577
|
+
"crypttab-unreadable",
|
|
578
|
+
"active swap exists but its boot-time key policy could not be read safely",
|
|
579
|
+
activeSwapDevices,
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
let entries: Map<string, CrypttabEntry>;
|
|
583
|
+
try {
|
|
584
|
+
entries = parseCrypttab(rawCrypttab);
|
|
585
|
+
} catch {
|
|
586
|
+
return failure(
|
|
587
|
+
"crypttab-malformed",
|
|
588
|
+
"active swap exists but its boot-time key policy is ambiguous",
|
|
589
|
+
activeSwapDevices,
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Probe independently, but concurrently: the total admission latency stays
|
|
594
|
+
// bounded by two command timeouts rather than growing with the swap count.
|
|
595
|
+
const verdicts = await Promise.all(
|
|
596
|
+
swaps.map((swap) =>
|
|
597
|
+
verifyDevice(swap, entries, io, activeSwapDevices).catch(() =>
|
|
598
|
+
failure(
|
|
599
|
+
"swap-probe-failed",
|
|
600
|
+
`the swap safety probe for ${swap.path} failed closed`,
|
|
601
|
+
activeSwapDevices,
|
|
602
|
+
swap.path,
|
|
603
|
+
),
|
|
604
|
+
),
|
|
605
|
+
),
|
|
606
|
+
);
|
|
607
|
+
const rejected = verdicts.find(
|
|
608
|
+
(verdict): verdict is Exclude<TaskMachineSwapSafetyVerdict, { ok: true }> =>
|
|
609
|
+
verdict !== null && !verdict.ok,
|
|
610
|
+
);
|
|
611
|
+
if (rejected) return rejected;
|
|
612
|
+
return {
|
|
613
|
+
ok: true,
|
|
614
|
+
proof: "boot-ephemeral-dm-crypt",
|
|
615
|
+
activeSwapDevices,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async function withTimeout<T>(
|
|
620
|
+
promise: Promise<T>,
|
|
621
|
+
timeoutMs: number,
|
|
622
|
+
): Promise<T> {
|
|
623
|
+
let timer: NodeJS.Timeout | undefined;
|
|
624
|
+
try {
|
|
625
|
+
return await Promise.race([
|
|
626
|
+
promise,
|
|
627
|
+
new Promise<T>((_resolve, reject) => {
|
|
628
|
+
timer = setTimeout(() => reject(new Error("swap safety read timed out")), timeoutMs);
|
|
629
|
+
timer.unref();
|
|
630
|
+
}),
|
|
631
|
+
]);
|
|
632
|
+
} finally {
|
|
633
|
+
if (timer) clearTimeout(timer);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function readBoundedText(
|
|
638
|
+
path: string,
|
|
639
|
+
limits: { maxBytes: number; timeoutMs: number },
|
|
640
|
+
): Promise<string> {
|
|
641
|
+
const read = (async (): Promise<string> => {
|
|
642
|
+
const handle = await open(path, "r");
|
|
643
|
+
try {
|
|
644
|
+
const buffer = Buffer.allocUnsafe(limits.maxBytes + 1);
|
|
645
|
+
let offset = 0;
|
|
646
|
+
while (offset < buffer.length) {
|
|
647
|
+
const { bytesRead } = await handle.read(
|
|
648
|
+
buffer,
|
|
649
|
+
offset,
|
|
650
|
+
buffer.length - offset,
|
|
651
|
+
null,
|
|
652
|
+
);
|
|
653
|
+
if (bytesRead === 0) break;
|
|
654
|
+
offset += bytesRead;
|
|
655
|
+
}
|
|
656
|
+
if (offset > limits.maxBytes) throw new Error("swap safety file exceeds limit");
|
|
657
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset));
|
|
658
|
+
} finally {
|
|
659
|
+
await handle.close();
|
|
660
|
+
}
|
|
661
|
+
})();
|
|
662
|
+
return withTimeout(read, limits.timeoutMs);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function runBoundedFile(
|
|
666
|
+
command: string,
|
|
667
|
+
args: readonly string[],
|
|
668
|
+
limits: { maxOutputBytes: number; timeoutMs: number },
|
|
669
|
+
): Promise<SwapSafetyCommandResult> {
|
|
670
|
+
return new Promise((resolve) => {
|
|
671
|
+
let settled = false;
|
|
672
|
+
let outputBytes = 0;
|
|
673
|
+
let timer: NodeJS.Timeout | undefined;
|
|
674
|
+
const stdout: Buffer[] = [];
|
|
675
|
+
const finish = (result: SwapSafetyCommandResult): void => {
|
|
676
|
+
if (settled) return;
|
|
677
|
+
settled = true;
|
|
678
|
+
if (timer) clearTimeout(timer);
|
|
679
|
+
resolve(result);
|
|
680
|
+
};
|
|
681
|
+
const child = spawn(command, [...args], {
|
|
682
|
+
env: {
|
|
683
|
+
LANG: "C",
|
|
684
|
+
LC_ALL: "C",
|
|
685
|
+
PATH: "/usr/sbin:/usr/bin:/sbin:/bin",
|
|
686
|
+
},
|
|
687
|
+
shell: false,
|
|
688
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
689
|
+
windowsHide: true,
|
|
690
|
+
});
|
|
691
|
+
const consume = (chunk: Buffer, keep: boolean): void => {
|
|
692
|
+
if (settled) return;
|
|
693
|
+
outputBytes += chunk.length;
|
|
694
|
+
if (outputBytes > limits.maxOutputBytes) {
|
|
695
|
+
child.kill("SIGKILL");
|
|
696
|
+
finish({ ok: false, reason: "output-limit" });
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
if (keep) stdout.push(chunk);
|
|
700
|
+
};
|
|
701
|
+
child.stdout.on("data", (chunk: Buffer) => consume(chunk, true));
|
|
702
|
+
child.stderr.on("data", (chunk: Buffer) => consume(chunk, false));
|
|
703
|
+
child.once("error", () => finish({ ok: false, reason: "spawn" }));
|
|
704
|
+
child.once("close", (code) => {
|
|
705
|
+
if (code !== 0) {
|
|
706
|
+
finish({ ok: false, reason: "exit" });
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
try {
|
|
710
|
+
finish({
|
|
711
|
+
ok: true,
|
|
712
|
+
stdout: new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(stdout)),
|
|
713
|
+
});
|
|
714
|
+
} catch {
|
|
715
|
+
finish({ ok: false, reason: "invalid-output" });
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
timer = setTimeout(() => {
|
|
719
|
+
child.kill("SIGKILL");
|
|
720
|
+
finish({ ok: false, reason: "timeout" });
|
|
721
|
+
}, limits.timeoutMs);
|
|
722
|
+
timer.unref();
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const nodeSwapSafetyIo: SwapSafetyIo = {
|
|
727
|
+
readText: readBoundedText,
|
|
728
|
+
runFile: runBoundedFile,
|
|
729
|
+
};
|