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.
Files changed (90) hide show
  1. package/README.md +264 -29
  2. package/dist/cli-command-registry.d.ts +50 -0
  3. package/dist/cli-command-registry.js +509 -0
  4. package/dist/cli-command-registry.js.map +1 -0
  5. package/dist/cli.d.ts +18 -0
  6. package/dist/cli.js +302 -583
  7. package/dist/cli.js.map +1 -1
  8. package/dist/contract.d.ts +6 -0
  9. package/dist/contract.js +296 -192
  10. package/dist/contract.js.map +1 -1
  11. package/dist/domain/cgroups-v2.d.ts +77 -0
  12. package/dist/domain/cgroups-v2.js +434 -0
  13. package/dist/domain/cgroups-v2.js.map +1 -0
  14. package/dist/domain/doctor.d.ts +4 -1
  15. package/dist/domain/doctor.js +64 -13
  16. package/dist/domain/doctor.js.map +1 -1
  17. package/dist/domain/errors.d.ts +1 -1
  18. package/dist/domain/errors.js.map +1 -1
  19. package/dist/domain/landlock.d.ts +55 -0
  20. package/dist/domain/landlock.js +279 -0
  21. package/dist/domain/landlock.js.map +1 -0
  22. package/dist/domain/runtime.d.ts +12 -0
  23. package/dist/domain/runtime.js +50 -0
  24. package/dist/domain/runtime.js.map +1 -0
  25. package/dist/domain/sandbox-launcher.d.ts +26 -0
  26. package/dist/domain/sandbox-launcher.js +312 -23
  27. package/dist/domain/sandbox-launcher.js.map +1 -1
  28. package/dist/domain/sandbox-seccomp.d.ts +46 -0
  29. package/dist/domain/sandbox-seccomp.js +302 -0
  30. package/dist/domain/sandbox-seccomp.js.map +1 -0
  31. package/dist/domain/sandbox.d.ts +45 -1
  32. package/dist/domain/sandbox.js +176 -25
  33. package/dist/domain/sandbox.js.map +1 -1
  34. package/dist/domain/session-backend.d.ts +4 -1
  35. package/dist/domain/session-backend.js +256 -3
  36. package/dist/domain/session-backend.js.map +1 -1
  37. package/dist/domain/session.d.ts +207 -3
  38. package/dist/domain/session.js.map +1 -1
  39. package/dist/errors.d.ts +1 -1
  40. package/dist/failure-code-vocabulary.d.ts +20 -0
  41. package/dist/failure-code-vocabulary.js +216 -0
  42. package/dist/failure-code-vocabulary.js.map +1 -0
  43. package/dist/git.js +92 -6
  44. package/dist/git.js.map +1 -1
  45. package/dist/operation-authorization.d.ts +1 -1
  46. package/dist/product-state-manifest.d.ts +152 -0
  47. package/dist/product-state-manifest.js +240 -0
  48. package/dist/product-state-manifest.js.map +1 -0
  49. package/dist/public-contract.d.ts +25 -0
  50. package/dist/public-contract.js +29 -0
  51. package/dist/public-contract.js.map +1 -0
  52. package/dist/public-state.d.ts +75 -0
  53. package/dist/public-state.js +60 -0
  54. package/dist/public-state.js.map +1 -0
  55. package/dist/registry/lock.d.ts +8 -0
  56. package/dist/registry/lock.js +38 -20
  57. package/dist/registry/lock.js.map +1 -1
  58. package/dist/repository-evidence.d.ts +16 -0
  59. package/dist/repository-evidence.js.map +1 -1
  60. package/dist/resource-claims.d.ts +2 -2
  61. package/dist/resource-claims.js +96 -27
  62. package/dist/resource-claims.js.map +1 -1
  63. package/dist/session-lifecycle-actions.d.ts +64 -0
  64. package/dist/session-lifecycle-actions.js +117 -0
  65. package/dist/session-lifecycle-actions.js.map +1 -0
  66. package/dist/session-lifecycle-classification.d.ts +120 -0
  67. package/dist/session-lifecycle-classification.js +88 -0
  68. package/dist/session-lifecycle-classification.js.map +1 -0
  69. package/dist/session-registry.d.ts +210 -2
  70. package/dist/session-registry.js +1330 -87
  71. package/dist/session-registry.js.map +1 -1
  72. package/dist/state/index.d.ts +5 -0
  73. package/dist/state/index.js +4 -0
  74. package/dist/state/index.js.map +1 -0
  75. package/dist/state/session/actors.d.ts +71 -0
  76. package/dist/state/session/actors.js +259 -0
  77. package/dist/state/session/actors.js.map +1 -0
  78. package/dist/state/session/guards.d.ts +25 -0
  79. package/dist/state/session/guards.js +108 -0
  80. package/dist/state/session/guards.js.map +1 -0
  81. package/dist/state/session/index.d.ts +6 -0
  82. package/dist/state/session/index.js +5 -0
  83. package/dist/state/session/index.js.map +1 -0
  84. package/dist/state/session/machine.d.ts +185 -0
  85. package/dist/state/session/machine.js +408 -0
  86. package/dist/state/session/machine.js.map +1 -0
  87. package/dist/state/session/types.d.ts +94 -0
  88. package/dist/state/session/types.js +14 -0
  89. package/dist/state/session/types.js.map +1 -0
  90. 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
- function allowedSystemPath(candidate) {
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
- "/usr",
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
- args.push("--chdir", worktreeDestination, "--", command.command, ...commandArgs);
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({ executable: executable.value, args, cwd: topology.value.worktree, env });
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 text = typeof value === "string" ? value : value.toString("utf8");
570
- const textBytes = Buffer.byteLength(text, "utf8");
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(text);
750
+ chunks.push(encoded.toString("utf8"));
576
751
  return { bytes: bytes + textBytes, exceeded: false };
577
752
  }
578
- const bounded = Buffer.from(text).subarray(0, remaining).toString("utf8");
579
- chunks.push(bounded);
580
- return { bytes: maxBytes, exceeded: true };
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 stdoutBytes = 0;
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, stdoutBytes, maxOutputBytes, value);
659
- stdoutBytes = collected.bytes;
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, stderrBytes, maxOutputBytes, value);
667
- stderrBytes = collected.bytes;
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