@checkstack/backend-api 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +205 -0
  2. package/package.json +12 -11
  3. package/src/advisory-lock-pool.it.test.ts +282 -0
  4. package/src/advisory-lock.test.ts +144 -3
  5. package/src/advisory-lock.ts +97 -55
  6. package/src/auth-strategy.ts +6 -3
  7. package/src/bearer-token.ts +13 -0
  8. package/src/collector-strategy.ts +9 -0
  9. package/src/config-versioning.test.ts +227 -0
  10. package/src/config-versioning.ts +172 -0
  11. package/src/core-services.ts +14 -0
  12. package/src/esm-script-runner.test.ts +55 -16
  13. package/src/esm-script-runner.ts +212 -55
  14. package/src/index.ts +3 -0
  15. package/src/render-templatable-config.test.ts +168 -0
  16. package/src/render-templatable-config.ts +193 -0
  17. package/src/schema-utils.ts +3 -0
  18. package/src/script-sandbox/capabilities.test.ts +122 -0
  19. package/src/script-sandbox/capabilities.ts +372 -0
  20. package/src/script-sandbox/capped-output.test.ts +116 -0
  21. package/src/script-sandbox/capped-output.ts +172 -0
  22. package/src/script-sandbox/env-guard.test.ts +105 -0
  23. package/src/script-sandbox/env-guard.ts +129 -0
  24. package/src/script-sandbox/filesystem.test.ts +437 -0
  25. package/src/script-sandbox/filesystem.ts +514 -0
  26. package/src/script-sandbox/forkbomb.it.test.ts +121 -0
  27. package/src/script-sandbox/global-default.test.ts +161 -0
  28. package/src/script-sandbox/global-default.ts +100 -0
  29. package/src/script-sandbox/index.ts +14 -0
  30. package/src/script-sandbox/network.test.ts +356 -0
  31. package/src/script-sandbox/network.ts +373 -0
  32. package/src/script-sandbox/observability.test.ts +210 -0
  33. package/src/script-sandbox/observability.ts +168 -0
  34. package/src/script-sandbox/output-truncation.test.ts +53 -0
  35. package/src/script-sandbox/output-truncation.ts +69 -0
  36. package/src/script-sandbox/policy.test.ts +189 -0
  37. package/src/script-sandbox/policy.ts +220 -0
  38. package/src/script-sandbox/provider.test.ts +61 -0
  39. package/src/script-sandbox/provider.ts +134 -0
  40. package/src/script-sandbox/readiness.test.ts +80 -0
  41. package/src/script-sandbox/readiness.ts +117 -0
  42. package/src/script-sandbox/report.ts +88 -0
  43. package/src/script-sandbox/rootless-egress.it.test.ts +86 -0
  44. package/src/script-sandbox/rootless-egress.test.ts +99 -0
  45. package/src/script-sandbox/rootless-egress.ts +218 -0
  46. package/src/script-sandbox/shell-quote.test.ts +32 -0
  47. package/src/script-sandbox/shell-quote.ts +10 -0
  48. package/src/script-sandbox/wrapper.test.ts +1194 -0
  49. package/src/script-sandbox/wrapper.ts +714 -0
  50. package/src/shell-script-runner.test.ts +243 -0
  51. package/src/shell-script-runner.ts +210 -45
  52. package/src/zod-config.test.ts +60 -0
  53. package/src/zod-config.ts +38 -14
  54. package/tsconfig.json +3 -0
@@ -0,0 +1,168 @@
1
+ import {
2
+ detectSandboxCapabilities,
3
+ type SandboxCapabilities,
4
+ } from "./capabilities";
5
+ import { buildSpawnHardening } from "./wrapper";
6
+ import { pickSafeEnv } from "./env-guard";
7
+ import {
8
+ resolveDefaultSandboxProfile,
9
+ type SandboxPolicy,
10
+ } from "./policy";
11
+ import type { EffectiveSandbox } from "./report";
12
+
13
+ /**
14
+ * Observability for the script sandbox (plan §5.7 / Phase 4).
15
+ *
16
+ * Two surfaces:
17
+ * 1. A single startup capability-log line per pod/satellite so operators can
18
+ * see what THIS host actually enforces for the configured global default
19
+ * (capability detection is per-host and may legitimately differ between a
20
+ * Linux pod and a macOS satellite — see {@link SandboxCapabilities}).
21
+ * 2. A compact per-run downgrade summary the call sites log (and may surface
22
+ * in the run record) whenever a layer degraded, so a degradation is never
23
+ * silent.
24
+ */
25
+
26
+ /** A logger surface narrow enough for the test mocks but matching the real one. */
27
+ export interface SandboxLogger {
28
+ info(message: string, ...args: unknown[]): void;
29
+ warn(message: string, ...args: unknown[]): void;
30
+ }
31
+
32
+ /**
33
+ * Human-readable summary of what a host can enforce, derived purely from its
34
+ * detected capabilities. Exported so tests can assert the shape without
35
+ * scraping a log string.
36
+ */
37
+ export function summarizeCapabilities(
38
+ caps: SandboxCapabilities,
39
+ ): Record<string, string | boolean | null> {
40
+ return {
41
+ platform: caps.platform,
42
+ // The supervisor's euid. The shipped images run NON-root (false here): the
43
+ // script then inherits non-root by construction and can never be host-root.
44
+ // A root supervisor (true) relies on the namespace wrapper's `--uid` to
45
+ // drop. Reported as-is so operators can see which model this host uses.
46
+ supervisorEuidIsRoot: caps.euidIsRoot,
47
+ rlimits: caps.rlimitNative,
48
+ wrapper: caps.wrapper,
49
+ // userNamespaces is now the LIVE clone verdict (not the static sysctl), so
50
+ // operators see what actually works on this host. userNsCreatable is the
51
+ // same probe surfaced explicitly for the capability log.
52
+ userNamespaces: caps.userNamespaces,
53
+ userNsCreatable: caps.userNsCreatable,
54
+ netNamespaces: caps.netNamespaces,
55
+ netEgressIface: caps.netEgressIface,
56
+ netEgressRootless: caps.netEgressRootless,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Compute the effective enforcement for a given global default on THIS host,
62
+ * by running the (pure, synchronous) hardening builder against the detected
63
+ * capabilities. No spawn, no I/O — used only to report at startup.
64
+ */
65
+ export function describeEffectiveDefault({
66
+ policy,
67
+ caps,
68
+ }: {
69
+ policy: SandboxPolicy;
70
+ caps: SandboxCapabilities;
71
+ }): EffectiveSandbox {
72
+ // The builder may throw under `onUnavailable: "fail"`; the startup probe must
73
+ // never throw, so force a degrade-mode copy purely for reporting.
74
+ const reportPolicy: SandboxPolicy = { ...policy, onUnavailable: "degrade" };
75
+ const hardening = buildSpawnHardening({
76
+ policy: reportPolicy,
77
+ caps,
78
+ baseEnv: pickSafeEnv(),
79
+ });
80
+ return hardening.effective;
81
+ }
82
+
83
+ /**
84
+ * Emit the one-time startup capability log line for this pod/satellite. Logs
85
+ * the detected primitives AND the effective enforcement of the supplied global
86
+ * default (so operators see "what my host actually does", not just "what I
87
+ * asked for"). Falls back to the shipped safe default when no global default is
88
+ * supplied.
89
+ */
90
+ export function logSandboxCapabilitiesAtStartup({
91
+ logger,
92
+ globalDefault,
93
+ caps = detectSandboxCapabilities(),
94
+ }: {
95
+ logger: SandboxLogger;
96
+ globalDefault?: SandboxPolicy;
97
+ caps?: SandboxCapabilities;
98
+ }): void {
99
+ const policy = globalDefault ?? resolveDefaultSandboxProfile();
100
+ const effective = describeEffectiveDefault({ policy, caps });
101
+ logger.info("script sandbox capabilities", {
102
+ capabilities: summarizeCapabilities(caps),
103
+ enabled: policy.enabled,
104
+ enforced: effective.enforced,
105
+ downgrades: effective.downgrades,
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Build a compact, loggable summary of the per-run downgrades, or `undefined`
111
+ * when every requested layer was fully enforced (nothing to surface). The call
112
+ * sites log this as a structured warning and may attach it to the run record so
113
+ * an operator can see, per run, which layer degraded and why (e.g. the
114
+ * metadata block being unenforceable on a bwrap-only host).
115
+ */
116
+ export function summarizeRunDowngrades(
117
+ effective: EffectiveSandbox | undefined,
118
+ ): { layers: string[]; reasons: Record<string, string>; platform: string } | undefined {
119
+ if (effective === undefined || effective.downgrades.length === 0) {
120
+ return undefined;
121
+ }
122
+ const reasons: Record<string, string> = {};
123
+ for (const d of effective.downgrades) {
124
+ // Last reason per layer wins; layers can have at most a couple of entries.
125
+ reasons[d.layer] = d.reason;
126
+ }
127
+ return {
128
+ layers: effective.downgrades.map((d) => d.layer),
129
+ reasons,
130
+ platform: effective.platform,
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Surface per-run sandbox downgrades through the supplied logger as a single
136
+ * structured warning. A no-op when the run was fully enforced. Centralized so
137
+ * every call site surfaces degradation identically (plan §5.7: degrade
138
+ * surfaces, never hides).
139
+ */
140
+ export function surfaceRunDowngrades({
141
+ logger,
142
+ effective,
143
+ }: {
144
+ logger: SandboxLogger;
145
+ effective: EffectiveSandbox | undefined;
146
+ }): void {
147
+ const summary = summarizeRunDowngrades(effective);
148
+ if (summary !== undefined) {
149
+ logger.warn(
150
+ `script sandbox degraded: ${summary.layers.join(", ")} not fully enforced on ${summary.platform}`,
151
+ summary.reasons,
152
+ );
153
+ }
154
+ // Non-fatal notes (e.g. shell per-run memory bounded by the cgroup, or
155
+ // RLIMIT_NPROC not applied under a non-root supervisor) are surfaced at INFO,
156
+ // separate from downgrades: they are accepted, expected states, not failures
157
+ // to enforce the requested policy.
158
+ if (effective !== undefined && effective.notes.length > 0) {
159
+ const noteReasons: Record<string, string> = {};
160
+ for (const n of effective.notes) {
161
+ noteReasons[n.layer] = n.note;
162
+ }
163
+ logger.info(
164
+ `script sandbox notes (${effective.notes.map((n) => n.layer).join(", ")}) on ${effective.platform}`,
165
+ noteReasons,
166
+ );
167
+ }
168
+ }
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { truncateCapturedOutput } from "./output-truncation";
3
+
4
+ describe("truncateCapturedOutput", () => {
5
+ it("passes through unchanged when no cap is set", () => {
6
+ const r = truncateCapturedOutput({
7
+ stdout: "a".repeat(100),
8
+ stderr: "b".repeat(100),
9
+ maxOutputBytes: undefined,
10
+ });
11
+ expect(r.truncated).toBe(false);
12
+ expect(r.stdout.length).toBe(100);
13
+ expect(r.stderr.length).toBe(100);
14
+ });
15
+
16
+ it("passes through unchanged when under the cap", () => {
17
+ const r = truncateCapturedOutput({
18
+ stdout: "hello",
19
+ stderr: "world",
20
+ maxOutputBytes: 100,
21
+ });
22
+ expect(r.truncated).toBe(false);
23
+ expect(r.stdout).toBe("hello");
24
+ expect(r.stderr).toBe("world");
25
+ });
26
+
27
+ it("trims and flags when combined output exceeds the cap", () => {
28
+ const r = truncateCapturedOutput({
29
+ stdout: "a".repeat(80),
30
+ stderr: "b".repeat(80),
31
+ maxOutputBytes: 100,
32
+ });
33
+ expect(r.truncated).toBe(true);
34
+ const total = Buffer.byteLength(r.stdout) + Buffer.byteLength(r.stderr);
35
+ expect(total).toBeLessThanOrEqual(100);
36
+ // stdout is preserved first.
37
+ expect(r.stdout.length).toBe(80);
38
+ expect(r.stderr.length).toBe(20);
39
+ });
40
+
41
+ it("does not split a multi-byte code point", () => {
42
+ // Each emoji is 4 UTF-8 bytes.
43
+ const r = truncateCapturedOutput({
44
+ stdout: "😀😀😀😀😀", // 20 bytes
45
+ stderr: "",
46
+ maxOutputBytes: 10, // fits 2 full emoji (8 bytes)
47
+ });
48
+ expect(r.truncated).toBe(true);
49
+ expect(Buffer.byteLength(r.stdout)).toBeLessThanOrEqual(10);
50
+ // No replacement char / partial byte: re-encoding must be lossless.
51
+ expect(Buffer.byteLength(r.stdout) % 4).toBe(0);
52
+ });
53
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Portable runner-side output truncation. Enforces `maxOutputBytes` by
3
+ * counting the combined byte length of captured stdout+stderr and trimming
4
+ * once the cap is exceeded. Works identically on every platform (pure JS),
5
+ * so it is the portable-subset fallback for the resource layer (plan §5.1).
6
+ */
7
+
8
+ const encoder = new TextEncoder();
9
+
10
+ export interface TruncateOutputResult {
11
+ stdout: string;
12
+ stderr: string;
13
+ /** True when the combined output exceeded the cap and was trimmed. */
14
+ truncated: boolean;
15
+ }
16
+
17
+ /**
18
+ * Trim `stdout`/`stderr` so their combined UTF-8 byte length does not exceed
19
+ * `maxOutputBytes`. stdout is preserved first; stderr gets whatever budget
20
+ * remains. When `maxOutputBytes` is undefined the inputs pass through
21
+ * unchanged.
22
+ */
23
+ export function truncateCapturedOutput({
24
+ stdout,
25
+ stderr,
26
+ maxOutputBytes,
27
+ }: {
28
+ stdout: string;
29
+ stderr: string;
30
+ maxOutputBytes: number | undefined;
31
+ }): TruncateOutputResult {
32
+ if (maxOutputBytes === undefined) {
33
+ return { stdout, stderr, truncated: false };
34
+ }
35
+
36
+ const stdoutBytes = encoder.encode(stdout).length;
37
+ const stderrBytes = encoder.encode(stderr).length;
38
+ if (stdoutBytes + stderrBytes <= maxOutputBytes) {
39
+ return { stdout, stderr, truncated: false };
40
+ }
41
+
42
+ // Give stdout up to the full budget, then stderr the remainder.
43
+ const trimmedStdout = trimToBytes(stdout, maxOutputBytes);
44
+ const stdoutFinalBytes = encoder.encode(trimmedStdout).length;
45
+ const stderrBudget = Math.max(0, maxOutputBytes - stdoutFinalBytes);
46
+ const trimmedStderr = trimToBytes(stderr, stderrBudget);
47
+
48
+ return { stdout: trimmedStdout, stderr: trimmedStderr, truncated: true };
49
+ }
50
+
51
+ /**
52
+ * Trim a string to at most `maxBytes` UTF-8 bytes without splitting a
53
+ * multi-byte code point. Falls back to a character-wise walk only when a
54
+ * fast slice would land mid-character.
55
+ */
56
+ function trimToBytes(value: string, maxBytes: number): string {
57
+ if (maxBytes <= 0) return "";
58
+ if (encoder.encode(value).length <= maxBytes) return value;
59
+
60
+ let result = "";
61
+ let bytes = 0;
62
+ for (const char of value) {
63
+ const charBytes = encoder.encode(char).length;
64
+ if (bytes + charBytes > maxBytes) break;
65
+ result += char;
66
+ bytes += charBytes;
67
+ }
68
+ return result;
69
+ }
@@ -0,0 +1,189 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import {
3
+ DEFAULT_SANDBOX_PROFILE,
4
+ mergeSandboxPolicy,
5
+ resolveDefaultSandboxProfile,
6
+ SANDBOX_GID_ENV,
7
+ SANDBOX_UID_ENV,
8
+ sandboxPolicySchema,
9
+ } from "./policy";
10
+
11
+ describe("sandboxPolicySchema", () => {
12
+ it("defaults enabled to true (D1: on-by-default)", () => {
13
+ const parsed = sandboxPolicySchema.parse({});
14
+ expect(parsed.enabled).toBe(true);
15
+ });
16
+
17
+ it("applies conservative bare field defaults", () => {
18
+ const parsed = sandboxPolicySchema.parse({});
19
+ expect(parsed.onUnavailable).toBe("degrade");
20
+ expect(parsed.filesystem.mode).toBe("off");
21
+ expect(parsed.network.mode).toBe("unrestricted");
22
+ expect(parsed.network.denyLinkLocalAndMetadata).toBe(true);
23
+ expect(parsed.privilege.mode).toBe("inherit");
24
+ expect(parsed.resources).toEqual({});
25
+ });
26
+
27
+ it("rejects unknown top-level keys (.strict)", () => {
28
+ expect(() =>
29
+ sandboxPolicySchema.parse({ bogus: true }),
30
+ ).toThrow();
31
+ });
32
+
33
+ it("rejects unknown nested keys (.strict)", () => {
34
+ expect(() =>
35
+ sandboxPolicySchema.parse({ resources: { bogus: 1 } }),
36
+ ).toThrow();
37
+ });
38
+
39
+ it("enforces resource bounds", () => {
40
+ expect(() =>
41
+ sandboxPolicySchema.parse({ resources: { cpuSeconds: 0 } }),
42
+ ).toThrow();
43
+ expect(() =>
44
+ sandboxPolicySchema.parse({ resources: { cpuSeconds: 99999 } }),
45
+ ).toThrow();
46
+ });
47
+
48
+ it("DEFAULT_SANDBOX_PROFILE round-trips through the schema", () => {
49
+ const reparsed = sandboxPolicySchema.parse(DEFAULT_SANDBOX_PROFILE);
50
+ expect(reparsed).toEqual(DEFAULT_SANDBOX_PROFILE);
51
+ expect(reparsed.filesystem.mode).toBe("scratch-plus-ro");
52
+ expect(reparsed.privilege.mode).toBe("drop-to-uid");
53
+ });
54
+
55
+ it("DEFAULT_SANDBOX_PROFILE is secure-by-default (deny egress until allowlisted)", () => {
56
+ // Network is an allowlist with an EMPTY allow list = egress denied until an
57
+ // operator adds entries (NOT unrestricted); plus the always-on
58
+ // metadata/link-local block. Generous resource caps sized as headroom, not
59
+ // work limits; FS confined to scratch + RO node_modules.
60
+ expect(DEFAULT_SANDBOX_PROFILE.enabled).toBe(true);
61
+ // Fail-closed by default: refuse the run when a layer can't be enforced.
62
+ expect(DEFAULT_SANDBOX_PROFILE.onUnavailable).toBe("fail");
63
+ expect(DEFAULT_SANDBOX_PROFILE.network.mode).toBe("allowlist");
64
+ expect(DEFAULT_SANDBOX_PROFILE.network.allow).toEqual([]);
65
+ expect(DEFAULT_SANDBOX_PROFILE.network.denyLinkLocalAndMetadata).toBe(true);
66
+ expect(DEFAULT_SANDBOX_PROFILE.filesystem.mode).toBe("scratch-plus-ro");
67
+ expect(DEFAULT_SANDBOX_PROFILE.resources.cpuSeconds).toBe(60);
68
+ expect(DEFAULT_SANDBOX_PROFILE.resources.memoryBytes).toBe(512 * 1024 * 1024);
69
+ expect(DEFAULT_SANDBOX_PROFILE.resources.maxProcesses).toBe(256);
70
+ // No UID baked into the constant; the dedicated target is seeded at runtime.
71
+ expect(DEFAULT_SANDBOX_PROFILE.privilege.uid).toBeUndefined();
72
+ });
73
+
74
+ it("DEFAULT_SANDBOX_PROFILE fails closed (onUnavailable=fail, never silent degrade)", () => {
75
+ // The shipped global default REFUSES a run when a requested layer cannot be
76
+ // enforced rather than silently dropping to a weaker subset. This is the
77
+ // single most security-relevant default: a malicious script must not slip
78
+ // through on a host missing a sandbox primitive.
79
+ expect(DEFAULT_SANDBOX_PROFILE.onUnavailable).toBe("fail");
80
+ });
81
+ });
82
+
83
+ describe("resolveDefaultSandboxProfile", () => {
84
+ afterEach(() => {
85
+ delete process.env[SANDBOX_UID_ENV];
86
+ delete process.env[SANDBOX_GID_ENV];
87
+ });
88
+
89
+ it("returns the base profile (no UID) when the env is unset", () => {
90
+ delete process.env[SANDBOX_UID_ENV];
91
+ delete process.env[SANDBOX_GID_ENV];
92
+ const resolved = resolveDefaultSandboxProfile();
93
+ expect(resolved.privilege.uid).toBeUndefined();
94
+ expect(resolved.privilege.gid).toBeUndefined();
95
+ expect(resolved.privilege.mode).toBe("drop-to-uid");
96
+ });
97
+
98
+ it("seeds the dedicated low-priv UID/GID from the environment", () => {
99
+ process.env[SANDBOX_UID_ENV] = "65534";
100
+ process.env[SANDBOX_GID_ENV] = "65534";
101
+ const resolved = resolveDefaultSandboxProfile();
102
+ expect(resolved.privilege.uid).toBe(65534);
103
+ expect(resolved.privilege.gid).toBe(65534);
104
+ });
105
+
106
+ it("ignores a GID with no UID (a gid-only drop is not meaningful)", () => {
107
+ delete process.env[SANDBOX_UID_ENV];
108
+ process.env[SANDBOX_GID_ENV] = "65534";
109
+ const resolved = resolveDefaultSandboxProfile();
110
+ expect(resolved.privilege.uid).toBeUndefined();
111
+ expect(resolved.privilege.gid).toBeUndefined();
112
+ });
113
+
114
+ it("treats a malformed UID as unconfigured (no crash, degrades later)", () => {
115
+ process.env[SANDBOX_UID_ENV] = "not-a-number";
116
+ const resolved = resolveDefaultSandboxProfile();
117
+ expect(resolved.privilege.uid).toBeUndefined();
118
+ });
119
+
120
+ it("still validates against the schema", () => {
121
+ process.env[SANDBOX_UID_ENV] = "1000";
122
+ const resolved = resolveDefaultSandboxProfile();
123
+ expect(() => sandboxPolicySchema.parse(resolved)).not.toThrow();
124
+ });
125
+ });
126
+
127
+ describe("mergeSandboxPolicy", () => {
128
+ it("returns base unchanged when override is undefined", () => {
129
+ const merged = mergeSandboxPolicy({ base: DEFAULT_SANDBOX_PROFILE });
130
+ expect(merged).toEqual(DEFAULT_SANDBOX_PROFILE);
131
+ });
132
+
133
+ it("per-item override wins per-field without re-widening other layers", () => {
134
+ const merged = mergeSandboxPolicy({
135
+ base: DEFAULT_SANDBOX_PROFILE,
136
+ override: { network: { mode: "deny" } },
137
+ });
138
+ // network.mode overridden...
139
+ expect(merged.network.mode).toBe("deny");
140
+ // ...but the metadata block + other layers stay from the base, NOT the
141
+ // bare zod defaults.
142
+ expect(merged.network.denyLinkLocalAndMetadata).toBe(true);
143
+ expect(merged.resources.cpuSeconds).toBe(60);
144
+ expect(merged.filesystem.mode).toBe("scratch-plus-ro");
145
+ expect(merged.privilege.mode).toBe("drop-to-uid");
146
+ });
147
+
148
+ it("a single resource field override keeps the other caps", () => {
149
+ const merged = mergeSandboxPolicy({
150
+ base: DEFAULT_SANDBOX_PROFILE,
151
+ override: { resources: { memoryBytes: 2 * 1024 * 1024 * 1024 } },
152
+ });
153
+ expect(merged.resources.memoryBytes).toBe(2 * 1024 * 1024 * 1024);
154
+ expect(merged.resources.cpuSeconds).toBe(60);
155
+ expect(merged.resources.maxProcesses).toBe(256);
156
+ });
157
+
158
+ it("enabled:false override opts out without touching the rest", () => {
159
+ const merged = mergeSandboxPolicy({
160
+ base: DEFAULT_SANDBOX_PROFILE,
161
+ override: { enabled: false },
162
+ });
163
+ expect(merged.enabled).toBe(false);
164
+ });
165
+
166
+ it("onUnavailable override is honored", () => {
167
+ const merged = mergeSandboxPolicy({
168
+ base: DEFAULT_SANDBOX_PROFILE,
169
+ override: { onUnavailable: "fail" },
170
+ });
171
+ expect(merged.onUnavailable).toBe("fail");
172
+ });
173
+
174
+ it("a fully-resolved override (the global durable default) replaces every layer", () => {
175
+ // This is the call-site shape: the global durable default is itself a
176
+ // fully-resolved SandboxPolicy passed as the override; merging it over the
177
+ // runner's DEFAULT_SANDBOX_PROFILE base must yield exactly that override
178
+ // (idempotent), so the global setting wins end-to-end.
179
+ const globalDefault = mergeSandboxPolicy({
180
+ base: DEFAULT_SANDBOX_PROFILE,
181
+ override: { network: { mode: "deny" }, resources: { cpuSeconds: 30 } },
182
+ });
183
+ const merged = mergeSandboxPolicy({
184
+ base: DEFAULT_SANDBOX_PROFILE,
185
+ override: globalDefault,
186
+ });
187
+ expect(merged).toEqual(globalDefault);
188
+ });
189
+ });