nawabari 0.7.1 → 0.9.2
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/README.md +264 -29
- package/dist/cli-command-registry.d.ts +50 -0
- package/dist/cli-command-registry.js +509 -0
- package/dist/cli-command-registry.js.map +1 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +302 -583
- package/dist/cli.js.map +1 -1
- package/dist/contract.d.ts +6 -0
- package/dist/contract.js +296 -192
- package/dist/contract.js.map +1 -1
- package/dist/domain/cgroups-v2.d.ts +77 -0
- package/dist/domain/cgroups-v2.js +434 -0
- package/dist/domain/cgroups-v2.js.map +1 -0
- package/dist/domain/doctor.d.ts +4 -1
- package/dist/domain/doctor.js +64 -13
- package/dist/domain/doctor.js.map +1 -1
- package/dist/domain/errors.d.ts +1 -1
- package/dist/domain/errors.js.map +1 -1
- package/dist/domain/landlock.d.ts +55 -0
- package/dist/domain/landlock.js +279 -0
- package/dist/domain/landlock.js.map +1 -0
- package/dist/domain/runtime.d.ts +12 -0
- package/dist/domain/runtime.js +50 -0
- package/dist/domain/runtime.js.map +1 -0
- package/dist/domain/sandbox-launcher.d.ts +26 -0
- package/dist/domain/sandbox-launcher.js +312 -23
- package/dist/domain/sandbox-launcher.js.map +1 -1
- package/dist/domain/sandbox-seccomp.d.ts +46 -0
- package/dist/domain/sandbox-seccomp.js +302 -0
- package/dist/domain/sandbox-seccomp.js.map +1 -0
- package/dist/domain/sandbox.d.ts +45 -1
- package/dist/domain/sandbox.js +176 -25
- package/dist/domain/sandbox.js.map +1 -1
- package/dist/domain/session-backend.d.ts +4 -1
- package/dist/domain/session-backend.js +256 -3
- package/dist/domain/session-backend.js.map +1 -1
- package/dist/domain/session.d.ts +207 -3
- package/dist/domain/session.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/failure-code-vocabulary.d.ts +20 -0
- package/dist/failure-code-vocabulary.js +216 -0
- package/dist/failure-code-vocabulary.js.map +1 -0
- package/dist/git.js +92 -6
- package/dist/git.js.map +1 -1
- package/dist/operation-authorization.d.ts +1 -1
- package/dist/product-state-manifest.d.ts +152 -0
- package/dist/product-state-manifest.js +240 -0
- package/dist/product-state-manifest.js.map +1 -0
- package/dist/public-contract.d.ts +25 -0
- package/dist/public-contract.js +29 -0
- package/dist/public-contract.js.map +1 -0
- package/dist/public-state.d.ts +75 -0
- package/dist/public-state.js +60 -0
- package/dist/public-state.js.map +1 -0
- package/dist/registry/lock.d.ts +8 -0
- package/dist/registry/lock.js +38 -20
- package/dist/registry/lock.js.map +1 -1
- package/dist/repository-evidence.d.ts +16 -0
- package/dist/repository-evidence.js.map +1 -1
- package/dist/resource-claims.d.ts +2 -2
- package/dist/resource-claims.js +96 -27
- package/dist/resource-claims.js.map +1 -1
- package/dist/session-lifecycle-actions.d.ts +64 -0
- package/dist/session-lifecycle-actions.js +117 -0
- package/dist/session-lifecycle-actions.js.map +1 -0
- package/dist/session-lifecycle-classification.d.ts +120 -0
- package/dist/session-lifecycle-classification.js +88 -0
- package/dist/session-lifecycle-classification.js.map +1 -0
- package/dist/session-registry.d.ts +210 -2
- package/dist/session-registry.js +1330 -87
- package/dist/session-registry.js.map +1 -1
- package/dist/state/index.d.ts +5 -0
- package/dist/state/index.js +4 -0
- package/dist/state/index.js.map +1 -0
- package/dist/state/session/actors.d.ts +71 -0
- package/dist/state/session/actors.js +259 -0
- package/dist/state/session/actors.js.map +1 -0
- package/dist/state/session/guards.d.ts +25 -0
- package/dist/state/session/guards.js +108 -0
- package/dist/state/session/guards.js.map +1 -0
- package/dist/state/session/index.d.ts +6 -0
- package/dist/state/session/index.js +5 -0
- package/dist/state/session/index.js.map +1 -0
- package/dist/state/session/machine.d.ts +185 -0
- package/dist/state/session/machine.js +408 -0
- package/dist/state/session/machine.js.map +1 -0
- package/dist/state/session/types.d.ts +94 -0
- package/dist/state/session/types.js +14 -0
- package/dist/state/session/types.js.map +1 -0
- package/package.json +46 -26
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { type DomainResult } from "./errors.js";
|
|
2
|
+
import { type LandlockEffectiveState } from "./landlock.js";
|
|
3
|
+
import { type SandboxSeccompProfileMetadata } from "./sandbox-seccomp.js";
|
|
2
4
|
import type { SandboxExecutionRequest } from "./sandbox.js";
|
|
5
|
+
import { type CgroupAccounting } from "./cgroups-v2.js";
|
|
3
6
|
export type SandboxCommand = {
|
|
4
7
|
readonly command: string;
|
|
5
8
|
readonly args?: readonly string[];
|
|
@@ -13,6 +16,14 @@ export type SandboxInvocation = {
|
|
|
13
16
|
readonly args: readonly string[];
|
|
14
17
|
readonly cwd: string;
|
|
15
18
|
readonly env: Readonly<Record<string, string>>;
|
|
19
|
+
/** Raw sock_filter bytes supplied to bubblewrap through inherited fd 3. */
|
|
20
|
+
readonly seccomp_profile: Uint8Array;
|
|
21
|
+
readonly seccomp_profile_metadata: SandboxSeccompProfileMetadata;
|
|
22
|
+
readonly landlock: {
|
|
23
|
+
readonly abi: number | null;
|
|
24
|
+
readonly state: LandlockEffectiveState;
|
|
25
|
+
readonly rule_count: number;
|
|
26
|
+
};
|
|
16
27
|
};
|
|
17
28
|
export type SandboxExecutionResult = {
|
|
18
29
|
readonly exit_code: number | null;
|
|
@@ -20,11 +31,25 @@ export type SandboxExecutionResult = {
|
|
|
20
31
|
readonly stdout: string;
|
|
21
32
|
readonly stderr: string;
|
|
22
33
|
readonly duration_ms: number;
|
|
34
|
+
/** Applied policy identity, retained as bounded machine-readable evidence. */
|
|
35
|
+
readonly seccomp_profile?: SandboxSeccompProfileMetadata;
|
|
36
|
+
readonly resources?: {
|
|
37
|
+
readonly cgroup_contract_id: string;
|
|
38
|
+
readonly scope: string;
|
|
39
|
+
readonly accounting: CgroupAccounting;
|
|
40
|
+
readonly limit_events: readonly string[];
|
|
41
|
+
};
|
|
42
|
+
readonly landlock?: {
|
|
43
|
+
readonly abi: number | null;
|
|
44
|
+
readonly state: LandlockEffectiveState;
|
|
45
|
+
readonly rule_count: number;
|
|
46
|
+
};
|
|
23
47
|
};
|
|
24
48
|
export type SandboxLauncherOptions = {
|
|
25
49
|
readonly timeout_ms?: number;
|
|
26
50
|
readonly max_output_bytes?: number;
|
|
27
51
|
};
|
|
52
|
+
export type { LandlockRule } from "./landlock.js";
|
|
28
53
|
/**
|
|
29
54
|
* Compile one authoritative request into a deterministic bubblewrap argv.
|
|
30
55
|
* Mounts are derived from fixed topology fields; caller data is used only as
|
|
@@ -33,3 +58,4 @@ export type SandboxLauncherOptions = {
|
|
|
33
58
|
export declare function compileSandboxInvocation(request: SandboxExecutionRequest, command: SandboxCommand): DomainResult<SandboxInvocation>;
|
|
34
59
|
/** Execute the compiled invocation with shell execution disabled and no fallback path. */
|
|
35
60
|
export declare function runSandboxedCommand(request: SandboxExecutionRequest, command: SandboxCommand, options?: SandboxLauncherOptions): Promise<DomainResult<SandboxExecutionResult>>;
|
|
61
|
+
export { deriveLandlockRules } from "./landlock.js";
|
|
@@ -3,6 +3,9 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import process from "node:process";
|
|
5
5
|
import { DomainError, failure, success } from "./errors.js";
|
|
6
|
+
import { deriveLandlockRules, LANDLOCK_ABI_MINIMUM, LANDLOCK_SETUP_FAILURE_PREFIX, LANDLOCK_TRAMPOLINE, } from "./landlock.js";
|
|
7
|
+
import { compileSandboxSeccompProfile, SANDBOX_CAPABILITY_BASELINE_ID, SANDBOX_CAPABILITY_BASELINE_VERSION, SANDBOX_SECCOMP_PROFILE_ID, SANDBOX_SECCOMP_PROFILE_VERSION, } from "./sandbox-seccomp.js";
|
|
8
|
+
import { attachProcessToCgroup, cleanupCgroupScope, cgroupLimitEvents, createCgroupScope, readCgroupAccounting, } from "./cgroups-v2.js";
|
|
6
9
|
const SANDBOX_HOME = "/home/nawabari";
|
|
7
10
|
const SANDBOX_CONFIG_HOME = `${SANDBOX_HOME}/.config`;
|
|
8
11
|
const SANDBOX_LOCAL_HOME = `${SANDBOX_HOME}/.local`;
|
|
@@ -143,13 +146,11 @@ function validateTopology(request) {
|
|
|
143
146
|
function pathMatches(candidate, root) {
|
|
144
147
|
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
145
148
|
}
|
|
146
|
-
|
|
149
|
+
const BROAD_FHS_SYSTEM_ROOTS = ["/usr", "/bin", "/lib", "/lib64"];
|
|
150
|
+
function allowedSystemPath(candidate, nixOSRuntime = false) {
|
|
147
151
|
const normalized = path.normalize(candidate);
|
|
148
152
|
const roots = [
|
|
149
|
-
|
|
150
|
-
"/bin",
|
|
151
|
-
"/lib",
|
|
152
|
-
"/lib64",
|
|
153
|
+
...BROAD_FHS_SYSTEM_ROOTS,
|
|
153
154
|
"/nix/store",
|
|
154
155
|
"/run/current-system",
|
|
155
156
|
"/run/wrappers",
|
|
@@ -162,10 +163,16 @@ function allowedSystemPath(candidate) {
|
|
|
162
163
|
"/etc/pki",
|
|
163
164
|
"/etc/ca-certificates",
|
|
164
165
|
];
|
|
166
|
+
if (nixOSRuntime && BROAD_FHS_SYSTEM_ROOTS.some((root) => pathMatches(normalized, root)))
|
|
167
|
+
return false;
|
|
165
168
|
if (roots.some((root) => pathMatches(normalized, root)))
|
|
166
169
|
return true;
|
|
167
170
|
return new Set(["/etc/passwd", "/etc/group", "/etc/nsswitch.conf", "/etc/hosts", "/etc/resolv.conf"]).has(normalized);
|
|
168
171
|
}
|
|
172
|
+
function isNixOSRuntime(request) {
|
|
173
|
+
const runtimePaths = new Set(request.filesystem.runtime_paths);
|
|
174
|
+
return runtimePaths.has("/nix/store") && runtimePaths.has("/run/current-system");
|
|
175
|
+
}
|
|
169
176
|
function allowedRuntimePath(candidate) {
|
|
170
177
|
const normalized = path.normalize(candidate);
|
|
171
178
|
return (normalized === "/dev" ||
|
|
@@ -278,7 +285,7 @@ function validateExecutable(request) {
|
|
|
278
285
|
path: request.sandbox_executable,
|
|
279
286
|
}));
|
|
280
287
|
}
|
|
281
|
-
if (!allowedSystemPath(resolved) &&
|
|
288
|
+
if (!allowedSystemPath(resolved, isNixOSRuntime(request)) &&
|
|
282
289
|
allowedUserToolPath(resolved, request.filesystem.user_tool_home) === null &&
|
|
283
290
|
!allowedRuntimePath(resolved)) {
|
|
284
291
|
return topologyError("Bubblewrap resolved outside the fixed runtime profile.", {
|
|
@@ -294,7 +301,54 @@ function validateExecutable(request) {
|
|
|
294
301
|
}
|
|
295
302
|
return checked;
|
|
296
303
|
}
|
|
304
|
+
function sandboxPathForLandlockHelper(source, request) {
|
|
305
|
+
const kind = allowedUserToolPath(source, request.filesystem.user_tool_home);
|
|
306
|
+
if (kind === "local_bin")
|
|
307
|
+
return `${SANDBOX_LOCAL_HOME}/bin/${path.basename(source)}`;
|
|
308
|
+
if (kind === "local_lib")
|
|
309
|
+
return `${SANDBOX_LOCAL_HOME}/lib/${path.basename(source)}`;
|
|
310
|
+
if (kind === "pnpm_bin")
|
|
311
|
+
return `${SANDBOX_LOCAL_HOME}/share/pnpm/${path.basename(source)}`;
|
|
312
|
+
return source;
|
|
313
|
+
}
|
|
314
|
+
function validateLandlockExecutable(request) {
|
|
315
|
+
const source = request.landlock_executable;
|
|
316
|
+
if (source === undefined || source === null)
|
|
317
|
+
return success(null);
|
|
318
|
+
if (!path.isAbsolute(source) || source.includes("\0")) {
|
|
319
|
+
return topologyError("The Landlock runtime adapter must be an absolute path without NUL bytes.", {
|
|
320
|
+
path: source,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
const checked = ensureSourcePath(source, "Landlock runtime adapter");
|
|
324
|
+
if (!checked.ok)
|
|
325
|
+
return checked;
|
|
326
|
+
try {
|
|
327
|
+
const stat = fs.statSync(source);
|
|
328
|
+
if (!stat.isFile() || (stat.mode & 0o111) === 0) {
|
|
329
|
+
return failure(new DomainError("SANDBOX_CAPABILITY_UNAVAILABLE", "The Landlock runtime adapter is not executable.", {
|
|
330
|
+
path: source,
|
|
331
|
+
}));
|
|
332
|
+
}
|
|
333
|
+
const resolved = fs.realpathSync.native(source);
|
|
334
|
+
if (!allowedSystemPath(resolved, isNixOSRuntime(request)) &&
|
|
335
|
+
!allowedRuntimePath(resolved) &&
|
|
336
|
+
allowedUserToolPath(resolved, request.filesystem.user_tool_home) === null) {
|
|
337
|
+
return topologyError("The Landlock runtime adapter resolves outside the canonical runtime profile.", {
|
|
338
|
+
path: source,
|
|
339
|
+
resolved,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
return success(sandboxPathForLandlockHelper(resolved, request));
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return failure(new DomainError("SANDBOX_CAPABILITY_UNAVAILABLE", "The Landlock runtime adapter cannot be canonicalized.", {
|
|
346
|
+
path: source,
|
|
347
|
+
}));
|
|
348
|
+
}
|
|
349
|
+
}
|
|
297
350
|
function validateProfilePaths(request) {
|
|
351
|
+
const nixOSRuntime = isNixOSRuntime(request);
|
|
298
352
|
for (const source of request.filesystem.runtime_paths) {
|
|
299
353
|
if (!allowedRuntimePath(source)) {
|
|
300
354
|
return topologyError("The runtime path is outside the canonical profile.", { path: source });
|
|
@@ -318,7 +372,7 @@ function validateProfilePaths(request) {
|
|
|
318
372
|
}
|
|
319
373
|
}
|
|
320
374
|
for (const source of request.filesystem.system_paths) {
|
|
321
|
-
if (!allowedSystemPath(source)) {
|
|
375
|
+
if (!allowedSystemPath(source, nixOSRuntime)) {
|
|
322
376
|
return topologyError("The system path is outside the canonical profile.", { path: source });
|
|
323
377
|
}
|
|
324
378
|
const checked = ensureSourcePath(source, "system path");
|
|
@@ -326,7 +380,7 @@ function validateProfilePaths(request) {
|
|
|
326
380
|
return checked;
|
|
327
381
|
try {
|
|
328
382
|
const resolved = fs.realpathSync.native(source);
|
|
329
|
-
if (!allowedSystemPath(resolved)) {
|
|
383
|
+
if (!allowedSystemPath(resolved, nixOSRuntime)) {
|
|
330
384
|
return topologyError("The system path symlink resolves outside the canonical profile.", {
|
|
331
385
|
path: source,
|
|
332
386
|
resolved,
|
|
@@ -453,6 +507,49 @@ export function compileSandboxInvocation(request, command) {
|
|
|
453
507
|
const profilePaths = validateProfilePaths(request);
|
|
454
508
|
if (!profilePaths.ok)
|
|
455
509
|
return profilePaths;
|
|
510
|
+
if (request.seccomp_profile.id !== SANDBOX_SECCOMP_PROFILE_ID ||
|
|
511
|
+
request.seccomp_profile.version !== SANDBOX_SECCOMP_PROFILE_VERSION ||
|
|
512
|
+
request.capability_baseline.id !== SANDBOX_CAPABILITY_BASELINE_ID ||
|
|
513
|
+
request.capability_baseline.version !== SANDBOX_CAPABILITY_BASELINE_VERSION ||
|
|
514
|
+
request.capability_baseline.ambient_capabilities.length !== 0) {
|
|
515
|
+
return failure(new DomainError("SANDBOX_CAPABILITY_UNAVAILABLE", "The protected execution security baseline is incompatible.", {
|
|
516
|
+
seccomp_profile_id: request.seccomp_profile.id,
|
|
517
|
+
seccomp_profile_version: request.seccomp_profile.version,
|
|
518
|
+
capability_baseline_id: request.capability_baseline.id,
|
|
519
|
+
capability_baseline_version: request.capability_baseline.version,
|
|
520
|
+
}));
|
|
521
|
+
}
|
|
522
|
+
const seccompProfile = compileSandboxSeccompProfile(request.seccomp_profile.architecture);
|
|
523
|
+
if (!seccompProfile.ok)
|
|
524
|
+
return seccompProfile;
|
|
525
|
+
const landlockAbi = request.landlock_abi ?? null;
|
|
526
|
+
const landlockSupported = landlockAbi !== null && Number.isSafeInteger(landlockAbi) && landlockAbi >= LANDLOCK_ABI_MINIMUM;
|
|
527
|
+
const landlockUnavailableState = request.landlock_state === "error" || (landlockAbi !== null && !Number.isSafeInteger(landlockAbi))
|
|
528
|
+
? "error"
|
|
529
|
+
: landlockAbi === null
|
|
530
|
+
? "reduced-defense"
|
|
531
|
+
: "incompatible";
|
|
532
|
+
const landlockRequired = request.landlock_required === true;
|
|
533
|
+
if (landlockRequired && !landlockSupported) {
|
|
534
|
+
return failure(new DomainError("SANDBOX_CAPABILITY_UNAVAILABLE", "The required Landlock ABI is unavailable or incompatible.", {
|
|
535
|
+
session_id: request.session_id,
|
|
536
|
+
abi: landlockAbi,
|
|
537
|
+
effective_state: landlockUnavailableState,
|
|
538
|
+
}));
|
|
539
|
+
}
|
|
540
|
+
const landlockSource = request.landlock_executable ?? null;
|
|
541
|
+
const shouldValidateLandlock = landlockRequired || (landlockSupported && landlockSource !== null);
|
|
542
|
+
const landlockExecutable = shouldValidateLandlock ? validateLandlockExecutable(request) : success(null);
|
|
543
|
+
if (!landlockExecutable.ok)
|
|
544
|
+
return landlockExecutable;
|
|
545
|
+
if (landlockRequired && landlockExecutable.value === null) {
|
|
546
|
+
return failure(new DomainError("SANDBOX_CAPABILITY_UNAVAILABLE", "The required Landlock runtime adapter is unavailable.", {
|
|
547
|
+
session_id: request.session_id,
|
|
548
|
+
abi: landlockAbi,
|
|
549
|
+
}));
|
|
550
|
+
}
|
|
551
|
+
const landlockEnabled = landlockSupported && landlockExecutable.value !== null;
|
|
552
|
+
const landlockRules = deriveLandlockRules(request.filesystem);
|
|
456
553
|
const gitMetadata = prepareGitMetadata(request, topology.value);
|
|
457
554
|
if (!gitMetadata.ok)
|
|
458
555
|
return gitMetadata;
|
|
@@ -471,6 +568,8 @@ export function compileSandboxInvocation(request, command) {
|
|
|
471
568
|
"0",
|
|
472
569
|
"--cap-drop",
|
|
473
570
|
"ALL",
|
|
571
|
+
"--seccomp",
|
|
572
|
+
"3",
|
|
474
573
|
"--clearenv",
|
|
475
574
|
"--setenv",
|
|
476
575
|
"HOME",
|
|
@@ -551,7 +650,18 @@ export function compileSandboxInvocation(request, command) {
|
|
|
551
650
|
seenDirectories.add(parent);
|
|
552
651
|
args.push("--dir", parent);
|
|
553
652
|
}
|
|
554
|
-
|
|
653
|
+
const commandArgv = landlockEnabled
|
|
654
|
+
? [
|
|
655
|
+
landlockExecutable.value,
|
|
656
|
+
"-c",
|
|
657
|
+
LANDLOCK_TRAMPOLINE,
|
|
658
|
+
JSON.stringify(landlockRules),
|
|
659
|
+
"--",
|
|
660
|
+
command.command,
|
|
661
|
+
...commandArgs,
|
|
662
|
+
]
|
|
663
|
+
: [command.command, ...commandArgs];
|
|
664
|
+
args.push("--chdir", worktreeDestination, "--", ...commandArgv);
|
|
555
665
|
const env = {
|
|
556
666
|
PATH: pathValue,
|
|
557
667
|
HOME: SANDBOX_HOME,
|
|
@@ -563,21 +673,116 @@ export function compileSandboxInvocation(request, command) {
|
|
|
563
673
|
LOGNAME: "nawabari",
|
|
564
674
|
NAWABARI_SESSION_ID: request.session_id,
|
|
565
675
|
};
|
|
566
|
-
return success({
|
|
676
|
+
return success({
|
|
677
|
+
executable: executable.value,
|
|
678
|
+
args,
|
|
679
|
+
cwd: topology.value.worktree,
|
|
680
|
+
env,
|
|
681
|
+
seccomp_profile: seccompProfile.value,
|
|
682
|
+
seccomp_profile_metadata: request.seccomp_profile,
|
|
683
|
+
landlock: {
|
|
684
|
+
abi: landlockAbi,
|
|
685
|
+
state: landlockEnabled ? "enforced" : landlockUnavailableState,
|
|
686
|
+
rule_count: landlockRules.length,
|
|
687
|
+
},
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Give bubblewrap an unlinked, read-only profile file. The descriptor stays
|
|
692
|
+
* open only for the launch; no profile pathname is exposed inside the child.
|
|
693
|
+
*/
|
|
694
|
+
function openSeccompProfile(profile) {
|
|
695
|
+
let directory = null;
|
|
696
|
+
let fd = null;
|
|
697
|
+
try {
|
|
698
|
+
directory = fs.mkdtempSync(path.join(requirementTempDirectory(), "nawabari-seccomp-"));
|
|
699
|
+
const profilePath = path.join(directory, "profile.bpf");
|
|
700
|
+
fs.writeFileSync(profilePath, profile, { mode: 0o600 });
|
|
701
|
+
fd = fs.openSync(profilePath, fs.constants.O_RDONLY);
|
|
702
|
+
fs.unlinkSync(profilePath);
|
|
703
|
+
fs.rmdirSync(directory);
|
|
704
|
+
directory = null;
|
|
705
|
+
return success({
|
|
706
|
+
fd,
|
|
707
|
+
close: () => {
|
|
708
|
+
try {
|
|
709
|
+
fs.closeSync(fd);
|
|
710
|
+
}
|
|
711
|
+
catch {
|
|
712
|
+
// Closing an already-closed descriptor is harmless cleanup.
|
|
713
|
+
}
|
|
714
|
+
},
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
catch (error) {
|
|
718
|
+
if (fd !== null) {
|
|
719
|
+
try {
|
|
720
|
+
fs.closeSync(fd);
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
// Preserve the original setup failure.
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
if (directory !== null) {
|
|
727
|
+
try {
|
|
728
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
729
|
+
}
|
|
730
|
+
catch {
|
|
731
|
+
// Best-effort cleanup; the profile is never used after setup failure.
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return failure(new DomainError("SANDBOX_EXECUTION_FAILED", "The seccomp baseline could not be prepared for protected execution.", {
|
|
735
|
+
profile_id: SANDBOX_SECCOMP_PROFILE_ID,
|
|
736
|
+
reason: error instanceof Error ? error.message.slice(0, 200) : "unknown",
|
|
737
|
+
}));
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
function requirementTempDirectory() {
|
|
741
|
+
return process.env.TMPDIR !== undefined && path.isAbsolute(process.env.TMPDIR) ? process.env.TMPDIR : "/tmp";
|
|
567
742
|
}
|
|
568
743
|
function boundedOutput(chunks, bytes, maxBytes, value) {
|
|
569
|
-
const
|
|
570
|
-
const textBytes =
|
|
744
|
+
const encoded = typeof value === "string" ? Buffer.from(value, "utf8") : value;
|
|
745
|
+
const textBytes = encoded.byteLength;
|
|
571
746
|
const remaining = maxBytes - bytes;
|
|
572
747
|
if (remaining <= 0)
|
|
573
748
|
return { bytes, exceeded: textBytes > 0 };
|
|
574
749
|
if (textBytes <= remaining) {
|
|
575
|
-
chunks.push(
|
|
750
|
+
chunks.push(encoded.toString("utf8"));
|
|
576
751
|
return { bytes: bytes + textBytes, exceeded: false };
|
|
577
752
|
}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
753
|
+
// Do not let a partial UTF-8 code point turn into U+FFFD and exceed the
|
|
754
|
+
// advertised byte bound. The launcher reports text, but the bound is
|
|
755
|
+
// defined in bytes so that machine callers can rely on it for arbitrary
|
|
756
|
+
// child output.
|
|
757
|
+
let end = remaining;
|
|
758
|
+
while (end > 0) {
|
|
759
|
+
const last = encoded[end - 1] ?? 0;
|
|
760
|
+
if ((last & 0x80) === 0)
|
|
761
|
+
break;
|
|
762
|
+
if ((last & 0xc0) === 0x80) {
|
|
763
|
+
let start = end - 1;
|
|
764
|
+
while (start > 0 && ((encoded[start - 1] ?? 0) & 0xc0) === 0x80)
|
|
765
|
+
start -= 1;
|
|
766
|
+
const lead = encoded[start] ?? 0;
|
|
767
|
+
const width = (lead & 0xe0) === 0xc0 ? 2 : (lead & 0xf0) === 0xe0 ? 3 : (lead & 0xf8) === 0xf0 ? 4 : 1;
|
|
768
|
+
if (start + width > end) {
|
|
769
|
+
end = start;
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
break;
|
|
774
|
+
}
|
|
775
|
+
chunks.push(encoded.subarray(0, end).toString("utf8"));
|
|
776
|
+
return { bytes: bytes + end, exceeded: true };
|
|
777
|
+
}
|
|
778
|
+
function landlockSetupDiagnostic(stderr) {
|
|
779
|
+
const marker = stderr.indexOf(LANDLOCK_SETUP_FAILURE_PREFIX);
|
|
780
|
+
if (marker === -1)
|
|
781
|
+
return null;
|
|
782
|
+
return stderr
|
|
783
|
+
.slice(marker + LANDLOCK_SETUP_FAILURE_PREFIX.length)
|
|
784
|
+
.trim()
|
|
785
|
+
.slice(0, 240);
|
|
581
786
|
}
|
|
582
787
|
/** Execute the compiled invocation with shell execution disabled and no fallback path. */
|
|
583
788
|
export function runSandboxedCommand(request, command, options = {}) {
|
|
@@ -596,25 +801,55 @@ export function runSandboxedCommand(request, command, options = {}) {
|
|
|
596
801
|
})));
|
|
597
802
|
}
|
|
598
803
|
const started = Date.now();
|
|
804
|
+
const profile = openSeccompProfile(invocation.value.seccomp_profile);
|
|
805
|
+
if (!profile.ok)
|
|
806
|
+
return Promise.resolve(profile);
|
|
807
|
+
let cgroupScope = null;
|
|
808
|
+
const cgroupConfig = request.cgroups;
|
|
809
|
+
if (cgroupConfig !== undefined && (cgroupConfig.required || cgroupConfig.limits !== undefined)) {
|
|
810
|
+
const scope = createCgroupScope({ session_id: request.session_id, execution_id: cgroupConfig.execution_id }, { root: cgroupConfig.root, limits: cgroupConfig.limits });
|
|
811
|
+
if (!scope.ok) {
|
|
812
|
+
profile.value.close();
|
|
813
|
+
return Promise.resolve(scope);
|
|
814
|
+
}
|
|
815
|
+
cgroupScope = scope.value;
|
|
816
|
+
}
|
|
599
817
|
let child;
|
|
600
818
|
try {
|
|
601
819
|
child = spawn(invocation.value.executable, [...invocation.value.args], {
|
|
602
820
|
cwd: invocation.value.cwd,
|
|
603
821
|
env: { ...invocation.value.env },
|
|
604
822
|
shell: false,
|
|
605
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
823
|
+
stdio: ["ignore", "pipe", "pipe", profile.value.fd],
|
|
606
824
|
});
|
|
825
|
+
profile.value.close();
|
|
607
826
|
}
|
|
608
827
|
catch (error) {
|
|
828
|
+
profile.value.close();
|
|
829
|
+
if (cgroupScope !== null)
|
|
830
|
+
cleanupCgroupScope(cgroupScope);
|
|
609
831
|
return Promise.resolve(failure(new DomainError("SANDBOX_EXECUTION_FAILED", "Bubblewrap could not be started.", {
|
|
610
832
|
reason: error instanceof Error ? error.message.slice(0, 200) : "unknown",
|
|
611
833
|
})));
|
|
612
834
|
}
|
|
835
|
+
if (cgroupScope !== null) {
|
|
836
|
+
const childPid = child.pid;
|
|
837
|
+
if (typeof childPid !== "number" || !Number.isSafeInteger(childPid) || childPid < 1) {
|
|
838
|
+
child.kill("SIGKILL");
|
|
839
|
+
cleanupCgroupScope(cgroupScope);
|
|
840
|
+
return Promise.resolve(failure(new DomainError("SANDBOX_CGROUP_SETUP_FAILED", "The protected process did not expose a valid process id.", {})));
|
|
841
|
+
}
|
|
842
|
+
const attached = attachProcessToCgroup(cgroupScope, childPid);
|
|
843
|
+
if (!attached.ok) {
|
|
844
|
+
child.kill("SIGKILL");
|
|
845
|
+
cleanupCgroupScope(cgroupScope);
|
|
846
|
+
return Promise.resolve(attached);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
613
849
|
return new Promise((resolve) => {
|
|
614
850
|
const stdout = [];
|
|
615
851
|
const stderr = [];
|
|
616
|
-
let
|
|
617
|
-
let stderrBytes = 0;
|
|
852
|
+
let outputBytes = 0;
|
|
618
853
|
let timedOut = false;
|
|
619
854
|
let outputExceeded = false;
|
|
620
855
|
let spawnError = null;
|
|
@@ -628,21 +863,62 @@ export function runSandboxedCommand(request, command, options = {}) {
|
|
|
628
863
|
return;
|
|
629
864
|
settled = true;
|
|
630
865
|
clearTimeout(timer);
|
|
866
|
+
const accounting = cgroupScope === null ? null : readCgroupAccounting(cgroupScope);
|
|
867
|
+
if (cgroupScope !== null) {
|
|
868
|
+
const cleaned = cleanupCgroupScope(cgroupScope);
|
|
869
|
+
if (!cleaned.ok) {
|
|
870
|
+
resolve(cleaned);
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
const resourceDetails = cgroupScope === null || accounting === null
|
|
875
|
+
? {}
|
|
876
|
+
: {
|
|
877
|
+
cgroup_scope: cgroupScope.name,
|
|
878
|
+
cgroup_accounting: accounting,
|
|
879
|
+
cgroup_limit_events: cgroupLimitEvents(accounting),
|
|
880
|
+
};
|
|
631
881
|
if (spawnError !== null) {
|
|
632
882
|
resolve(failure(new DomainError("SANDBOX_EXECUTION_FAILED", "Bubblewrap failed before the command could run.", {
|
|
633
883
|
reason: spawnError.message.slice(0, 200),
|
|
884
|
+
...resourceDetails,
|
|
634
885
|
})));
|
|
635
886
|
return;
|
|
636
887
|
}
|
|
637
888
|
if (timedOut) {
|
|
638
889
|
resolve(failure(new DomainError("SANDBOX_EXECUTION_TIMEOUT", "The sandboxed command exceeded its execution timeout.", {
|
|
639
890
|
timeout_ms: timeoutMs,
|
|
891
|
+
...resourceDetails,
|
|
640
892
|
})));
|
|
641
893
|
return;
|
|
642
894
|
}
|
|
643
895
|
if (outputExceeded) {
|
|
644
896
|
resolve(failure(new DomainError("SANDBOX_OUTPUT_LIMIT", "The sandboxed command exceeded its output limit.", {
|
|
645
897
|
max_output_bytes: maxOutputBytes,
|
|
898
|
+
...resourceDetails,
|
|
899
|
+
})));
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
const diagnostic = stderr.join("");
|
|
903
|
+
if (code !== 0 && /^bwrap:/mu.test(diagnostic)) {
|
|
904
|
+
const seccompFailure = /seccomp/u.test(diagnostic);
|
|
905
|
+
resolve(failure(new DomainError(seccompFailure ? "SANDBOX_CAPABILITY_UNAVAILABLE" : "SANDBOX_EXECUTION_FAILED", seccompFailure
|
|
906
|
+
? "The required seccomp baseline could not be applied by bubblewrap."
|
|
907
|
+
: "Bubblewrap could not establish the protected execution boundary.", {
|
|
908
|
+
profile_id: invocation.value.seccomp_profile_metadata.id,
|
|
909
|
+
profile_version: invocation.value.seccomp_profile_metadata.version,
|
|
910
|
+
stderr: diagnostic.slice(0, maxOutputBytes),
|
|
911
|
+
})));
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
const landlockDiagnostic = invocation.value.landlock.state === "enforced" ? landlockSetupDiagnostic(diagnostic) : null;
|
|
915
|
+
if (landlockDiagnostic !== null) {
|
|
916
|
+
resolve(failure(new DomainError("SANDBOX_EXECUTION_FAILED", "Landlock setup failed; the protected command was not executed.", {
|
|
917
|
+
session_id: request.session_id,
|
|
918
|
+
abi: invocation.value.landlock.abi,
|
|
919
|
+
effective_state: "error",
|
|
920
|
+
retryable: false,
|
|
921
|
+
diagnostic: landlockDiagnostic,
|
|
646
922
|
})));
|
|
647
923
|
return;
|
|
648
924
|
}
|
|
@@ -652,19 +928,31 @@ export function runSandboxedCommand(request, command, options = {}) {
|
|
|
652
928
|
stdout: stdout.join(""),
|
|
653
929
|
stderr: stderr.join(""),
|
|
654
930
|
duration_ms: Date.now() - started,
|
|
931
|
+
seccomp_profile: invocation.value.seccomp_profile_metadata,
|
|
932
|
+
...(cgroupScope === null || accounting === null
|
|
933
|
+
? {}
|
|
934
|
+
: {
|
|
935
|
+
resources: {
|
|
936
|
+
cgroup_contract_id: cgroupScope.contract_id,
|
|
937
|
+
scope: cgroupScope.name,
|
|
938
|
+
accounting,
|
|
939
|
+
limit_events: cgroupLimitEvents(accounting),
|
|
940
|
+
},
|
|
941
|
+
}),
|
|
942
|
+
landlock: invocation.value.landlock,
|
|
655
943
|
}));
|
|
656
944
|
};
|
|
657
945
|
child.stdout?.on("data", (value) => {
|
|
658
|
-
const collected = boundedOutput(stdout,
|
|
659
|
-
|
|
946
|
+
const collected = boundedOutput(stdout, outputBytes, maxOutputBytes, value);
|
|
947
|
+
outputBytes = collected.bytes;
|
|
660
948
|
if (collected.exceeded) {
|
|
661
949
|
outputExceeded = true;
|
|
662
950
|
child.kill("SIGKILL");
|
|
663
951
|
}
|
|
664
952
|
});
|
|
665
953
|
child.stderr?.on("data", (value) => {
|
|
666
|
-
const collected = boundedOutput(stderr,
|
|
667
|
-
|
|
954
|
+
const collected = boundedOutput(stderr, outputBytes, maxOutputBytes, value);
|
|
955
|
+
outputBytes = collected.bytes;
|
|
668
956
|
if (collected.exceeded) {
|
|
669
957
|
outputExceeded = true;
|
|
670
958
|
child.kill("SIGKILL");
|
|
@@ -676,4 +964,5 @@ export function runSandboxedCommand(request, command, options = {}) {
|
|
|
676
964
|
child.on("close", (code, signal) => finish(code, signal));
|
|
677
965
|
});
|
|
678
966
|
}
|
|
967
|
+
export { deriveLandlockRules } from "./landlock.js";
|
|
679
968
|
//# sourceMappingURL=sandbox-launcher.js.map
|