@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,373 @@
1
+ import type { MacvlanAddressing, SandboxCapabilities } from "./capabilities";
2
+ import type { NetworkPolicy } from "./policy";
3
+
4
+ /**
5
+ * Network egress control (plan §5.3 / §6 D3).
6
+ *
7
+ * Egress is filtered at the KERNEL — a network namespace — not at the language
8
+ * runtime, so it covers `fetch`, raw sockets, DNS, and anything else the child
9
+ * tries, uniformly. There is no app-level shim a script can bypass.
10
+ *
11
+ * Mechanism, delegated to the same external wrapper that delivers filesystem
12
+ * isolation (external-wrapper-first, §6 D2):
13
+ *
14
+ * - `deny` = drop the child into a fresh network namespace with ONLY
15
+ * loopback. A fresh netns is ROUTELESS — it has no path to the
16
+ * host network — so "no egress" is the kernel default and that
17
+ * is EXACTLY the goal. Deliverable by ANY namespace wrapper
18
+ * (`bwrap --unshare-net`, `nsjail --clone_newnet`); no packet
19
+ * filter is needed because there is nothing to filter.
20
+ * - `allowlist` = a fresh network namespace PLUS REAL EGRESS plumbed into it
21
+ * PLUS an nftables ruleset that permits only the listed
22
+ * IPv4/IPv6 CIDRs (and loopback) and rejects everything else.
23
+ * The plumbing is the critical part: a fresh netns is
24
+ * routeless, so without an uplink the allowlist would blackhole
25
+ * ALL traffic (including the allowed CIDRs) — that is a silent
26
+ * under-block, NOT enforcement. Egress is plumbed by ONE of two
27
+ * paths, preferred in order:
28
+ * 1. PRIVILEGED macvlan (`nsjail` + CAP_NET_ADMIN + a usable
29
+ * host interface + static addressing, i.e.
30
+ * `caps.netEgressIface` + `caps.netEgressAddressing`); or
31
+ * 2. ROOTLESS slirp4netns (`bwrap` + creatable user+net
32
+ * namespace + `slirp4netns`, i.e. `caps.netEgressRootless`)
33
+ * — a userspace TCP/IP stack with deterministic built-in
34
+ * addressing, needing NO privilege/uplink/operator config.
35
+ * Either way the SAME nftables ruleset filters the plumbed
36
+ * egress. When NEITHER path is available it DEGRADES (surfaced)
37
+ * to host net — never a blackhole, never a silent allow-all.
38
+ * - `unrestricted` = keep the host network namespace. The ALWAYS-ON
39
+ * metadata/link-local block (`denyLinkLocalAndMetadata`) needs
40
+ * a netns WITH real egress so that everything EXCEPT the
41
+ * blocked ranges still reaches the network. If egress can be
42
+ * plumbed, the block is realised as a default-accept-except-
43
+ * metadata ruleset inside a plumbed namespace. If it CANNOT be
44
+ * plumbed (`bwrap`-only, no uplink, non-root), engaging a
45
+ * routeless netns would sever ALL egress — so we DO NOT engage
46
+ * one: we keep host net and surface the block as unenforceable.
47
+ *
48
+ * INVARIANT: a routeless fresh netns is used ONLY for `deny`. `unrestricted`
49
+ * and `allowlist` either get real plumbed + filtered egress, or degrade to
50
+ * surfaced host net. `enforced.network` reflects reality — it is never true
51
+ * when egress is actually severed or unfiltered.
52
+ *
53
+ * IP/CIDR only for v1 (§6 D3). Domain allowlisting (DNS-aware) is a v2 concern.
54
+ */
55
+
56
+ /**
57
+ * Always-blocked destinations, even in `unrestricted`/`allowlist` mode when
58
+ * `denyLinkLocalAndMetadata` is on. Covers IPv4 link-local + the cloud-metadata
59
+ * endpoint and the IPv6 link-local / unique-local ranges (the IPv6 metadata
60
+ * alias `fd00:ec2::254` falls inside `fc00::/7`).
61
+ */
62
+ export const ALWAYS_BLOCKED_CIDRS = [
63
+ // IPv4 link-local — includes 169.254.169.254 (AWS/GCP/Azure/OpenStack metadata).
64
+ "169.254.0.0/16",
65
+ // IPv6 link-local.
66
+ "fe80::/10",
67
+ // IPv6 unique-local — covers fd00:ec2::254 (IMDS over IPv6) and similar.
68
+ "fc00::/7",
69
+ ] as const;
70
+
71
+ /** Loopback ranges always permitted (the child's own localhost). */
72
+ const LOOPBACK_CIDRS = ["127.0.0.0/8", "::1/128"] as const;
73
+
74
+ /**
75
+ * The net behaviour the wrapper must deliver, resolved from policy + caps.
76
+ * Consumed by the namespace composer in `wrapper.ts`/`filesystem.ts`.
77
+ */
78
+ export type NetworkDecision =
79
+ | {
80
+ /** Keep the host net namespace; no kernel filter applied. */
81
+ kind: "host";
82
+ /**
83
+ * True when the metadata/link-local block was REQUESTED but cannot be
84
+ * enforced without a netns + nftables on this host. The caller surfaces
85
+ * this as a downgrade. When false, nothing was requested (or it is moot).
86
+ */
87
+ metadataBlockUnenforceable: boolean;
88
+ }
89
+ | {
90
+ /**
91
+ * Drop into a fresh net namespace. For `deny` this is routeless
92
+ * (loopback only) and that is the goal. For `allowlist` it is plumbed
93
+ * with `egressIface` and filtered by `nftRuleset`.
94
+ */
95
+ kind: "namespaced";
96
+ mode: "deny" | "allowlist";
97
+ /**
98
+ * How real egress is plumbed into the namespace for `allowlist`:
99
+ * - `"macvlan"` — privileged nsjail macvlan uplink (`egressIface` +
100
+ * `egressAddressing` present);
101
+ * - `"rootless"` — unprivileged slirp4netns userspace stack (no iface /
102
+ * addressing; the launcher orchestrates it).
103
+ * Absent for `deny` (a routeless namespace IS the goal — no plumbing).
104
+ */
105
+ egressPath?: "macvlan" | "rootless";
106
+ /**
107
+ * An nftables ruleset to install inside the child's namespace, or
108
+ * undefined for pure `deny` (a loopback-only routeless netns needs no
109
+ * rules — there is no route out to filter). Present for `allowlist` (incl.
110
+ * the metadata-only block realised as an allowlist-shaped ruleset).
111
+ */
112
+ nftRuleset?: string;
113
+ /**
114
+ * The host interface to plumb real egress into the namespace via macvlan.
115
+ * Present (and required) for `allowlist` via the MACVLAN path — without it
116
+ * the namespace is routeless and the allowlist would blackhole everything.
117
+ * Absent for `deny` (routeless IS the goal) and for the ROOTLESS path
118
+ * (slirp4netns needs no host iface).
119
+ */
120
+ egressIface?: string;
121
+ /**
122
+ * Static addressing for the macvlan endpoint (`--macvlan_vs_ip/_nm/_gw`).
123
+ * Present alongside `egressIface` for the MACVLAN `allowlist` /
124
+ * metadata-block; a macvlan with no address still blackholes, so
125
+ * addressing is REQUIRED to route and the macvlan decision only reaches
126
+ * `namespaced` allowlist when it is available. Absent for the ROOTLESS
127
+ * path (slirp4netns has deterministic built-in addressing).
128
+ */
129
+ egressAddressing?: MacvlanAddressing;
130
+ }
131
+ | {
132
+ /** Could not enforce as requested; caller degrades or fails. */
133
+ kind: "degrade";
134
+ reason: string;
135
+ };
136
+
137
+ /**
138
+ * Build the nftables ruleset installed inside the child's network namespace.
139
+ *
140
+ * For `allowlist`: default-drop egress, then accept loopback, accept the listed
141
+ * CIDRs, and (independently of the allowlist) drop the always-blocked
142
+ * metadata/link-local ranges so a CIDR that overlaps them cannot re-open the
143
+ * hole.
144
+ *
145
+ * For the `unrestricted` metadata block: default-accept egress, but drop the
146
+ * always-blocked ranges.
147
+ */
148
+ export function buildEgressNftRules({
149
+ mode,
150
+ allow,
151
+ denyMetadata,
152
+ }: {
153
+ mode: "allowlist" | "metadata-only";
154
+ allow: string[];
155
+ denyMetadata: boolean;
156
+ }): string {
157
+ const v4Allow: string[] = [];
158
+ const v6Allow: string[] = [];
159
+ for (const cidr of allow) {
160
+ (isIpv6Cidr(cidr) ? v6Allow : v4Allow).push(cidr);
161
+ }
162
+ const blocked = denyMetadata ? [...ALWAYS_BLOCKED_CIDRS] : [];
163
+ const v4Blocked = blocked.filter((c) => !isIpv6Cidr(c));
164
+ const v6Blocked = blocked.filter((c) => isIpv6Cidr(c));
165
+
166
+ // Always reject the metadata/link-local ranges FIRST so neither an explicit
167
+ // allow entry nor the default-accept (metadata-only) can reach them.
168
+ const blockLines: string[] = [
169
+ ...(v4Blocked.length > 0 ? [` ip daddr { ${v4Blocked.join(", ")} } drop`] : []),
170
+ ...(v6Blocked.length > 0 ? [` ip6 daddr { ${v6Blocked.join(", ")} } drop`] : []),
171
+ ];
172
+
173
+ // For allowlist: loopback is always reachable (the child's own services),
174
+ // then the explicit allow entries, then a final default drop.
175
+ const allowLines: string[] =
176
+ mode === "allowlist"
177
+ ? [
178
+ ` ip daddr { ${LOOPBACK_CIDRS[0]} } accept`,
179
+ ` ip6 daddr { ${LOOPBACK_CIDRS[1]} } accept`,
180
+ ...(v4Allow.length > 0 ? [` ip daddr { ${v4Allow.join(", ")} } accept`] : []),
181
+ ...(v6Allow.length > 0 ? [` ip6 daddr { ${v6Allow.join(", ")} } accept`] : []),
182
+ " drop",
183
+ ]
184
+ : [];
185
+
186
+ const lines: string[] = [
187
+ "table inet checkstack_egress {",
188
+ " chain output {",
189
+ " type filter hook output priority 0; policy accept;",
190
+ ...blockLines,
191
+ ...allowLines,
192
+ " }",
193
+ "}",
194
+ ];
195
+ return lines.join("\n");
196
+ }
197
+
198
+ /** Crude IPv4-vs-IPv6 CIDR discriminator: IPv6 literals contain a colon. */
199
+ function isIpv6Cidr(cidr: string): boolean {
200
+ return cidr.includes(":");
201
+ }
202
+
203
+ /**
204
+ * Resolve WHICH plumbed-egress path is deliverable on this host, preferring the
205
+ * privileged macvlan path over the rootless slirp4netns path. Returns `null`
206
+ * when NEITHER is available (the caller then degrades-and-surfaces — never a
207
+ * blackhole). Pure; reads only the pre-detected caps.
208
+ */
209
+ type EgressPlumbing =
210
+ | { path: "macvlan"; iface: string; addressing: MacvlanAddressing }
211
+ | { path: "rootless" };
212
+
213
+ function resolveEgressPlumbing(caps: SandboxCapabilities): EgressPlumbing | null {
214
+ // 1. Privileged macvlan: needs the iface AND static addressing (an
215
+ // unaddressed macvlan blackholes). Preferred when available.
216
+ if (caps.netEgressIface !== null && caps.netEgressAddressing !== null) {
217
+ return {
218
+ path: "macvlan",
219
+ iface: caps.netEgressIface,
220
+ addressing: caps.netEgressAddressing,
221
+ };
222
+ }
223
+ // 2. Rootless slirp4netns: no privilege / uplink / operator addressing
224
+ // required; the launcher orchestrates a userspace stack with deterministic
225
+ // addressing and filters it with the same nftables ruleset.
226
+ if (caps.netEgressRootless) {
227
+ return { path: "rootless" };
228
+ }
229
+ return null;
230
+ }
231
+
232
+ /**
233
+ * Resolve the network layer for a run. Pure & synchronous (no probing — caps
234
+ * are pre-detected and cached upstream).
235
+ *
236
+ * The caller (the namespace composer) maps the decision onto wrapper flags:
237
+ * - `host` → keep the host net (`--share-net` / `--disable_clone_newnet`).
238
+ * - `namespaced` → create a net namespace (`--unshare-net` /
239
+ * `--clone_newnet`) and, when `nftRuleset` is set, install it.
240
+ * - `degrade` → drop network control to the host net (default) or refuse
241
+ * (`onUnavailable: "fail"`), per the caller.
242
+ */
243
+ export function buildNetworkLayer({
244
+ policy,
245
+ caps,
246
+ }: {
247
+ policy: NetworkPolicy;
248
+ caps: SandboxCapabilities;
249
+ }): NetworkDecision {
250
+ // --- unrestricted: only the always-on metadata block may apply ----------
251
+ if (policy.mode === "unrestricted") {
252
+ if (!policy.denyLinkLocalAndMetadata) {
253
+ // Nothing requested at the network layer: pure host net, nothing to do.
254
+ return { kind: "host", metadataBlockUnenforceable: false };
255
+ }
256
+ // The metadata/link-local block must keep ordinary egress WORKING while
257
+ // dropping only the blocked ranges. That requires a netns WITH real egress
258
+ // plumbed in (a routeless OR unaddressed namespace would sever ALL traffic —
259
+ // a blackhole, not a block). Engage a namespace only when egress can
260
+ // actually be plumbed (privileged macvlan OR rootless slirp4netns);
261
+ // otherwise keep host net and surface the block as unenforceable. NEVER
262
+ // engage a routeless/unaddressed netns here (that is the BLOCKER this
263
+ // guards against).
264
+ const plumbing = resolveEgressPlumbing(caps);
265
+ if (plumbing !== null) {
266
+ const nftRuleset = buildEgressNftRules({
267
+ mode: "metadata-only",
268
+ allow: [],
269
+ denyMetadata: true,
270
+ });
271
+ return plumbing.path === "macvlan"
272
+ ? {
273
+ kind: "namespaced",
274
+ mode: "allowlist",
275
+ egressPath: "macvlan",
276
+ egressIface: plumbing.iface,
277
+ egressAddressing: plumbing.addressing,
278
+ nftRuleset,
279
+ }
280
+ : {
281
+ kind: "namespaced",
282
+ mode: "allowlist",
283
+ egressPath: "rootless",
284
+ nftRuleset,
285
+ };
286
+ }
287
+ return { kind: "host", metadataBlockUnenforceable: true };
288
+ }
289
+
290
+ // --- deny: a routeless fresh netns IS the goal --------------------------
291
+ if (policy.mode === "deny") {
292
+ if (caps.platform !== "linux") {
293
+ return {
294
+ kind: "degrade",
295
+ reason: `network deny requires Linux net namespaces (platform=${caps.platform}); egress unrestricted`,
296
+ };
297
+ }
298
+ if (!caps.netNamespaces) {
299
+ return {
300
+ kind: "degrade",
301
+ reason:
302
+ "network deny requires a net-namespace-capable wrapper (bwrap/nsjail) + unprivileged user namespaces, unavailable on this host; egress unrestricted",
303
+ };
304
+ }
305
+ // Loopback-only routeless namespace = no egress. No filter, no plumbing.
306
+ return { kind: "namespaced", mode: "deny" };
307
+ }
308
+
309
+ // --- allowlist: needs a fresh netns WITH plumbed egress + a filter ------
310
+ if (caps.platform !== "linux") {
311
+ return {
312
+ kind: "degrade",
313
+ reason: `network allowlist requires Linux net namespaces (platform=${caps.platform}); egress unrestricted`,
314
+ };
315
+ }
316
+ if (!caps.netNamespaces) {
317
+ return {
318
+ kind: "degrade",
319
+ reason:
320
+ "network allowlist requires a net-namespace-capable wrapper + unprivileged user namespaces, unavailable on this host; egress unrestricted",
321
+ };
322
+ }
323
+ // EMPTY allowlist == deny ALL egress. This is the SECURE DEFAULT
324
+ // (`network: allowlist, allow: []`). Semantically it permits nothing, so it is
325
+ // exactly the `deny` routeless-namespace behaviour: loopback only, no route
326
+ // out. Resolve it to the routeless `deny` path, which needs NEITHER plumbed
327
+ // egress (slirp4netns/macvlan) NOR an nftables ruleset, and therefore works on
328
+ // ANY host with a net-namespace-capable wrapper - including rootless
329
+ // containers where in-namespace nftables is not permitted. A routeless netns
330
+ // also inherently blocks metadata/link-local (nothing routes), so the always-
331
+ // on block is satisfied. Non-empty allow lists still take the plumbed+filtered
332
+ // path below (which requires macvlan or rootless slirp4netns + working nft).
333
+ if (policy.allow.length === 0) {
334
+ return { kind: "namespaced", mode: "deny" };
335
+ }
336
+ const plumbing = resolveEgressPlumbing(caps);
337
+ if (plumbing === null) {
338
+ // The wrapper can create the namespace, but we cannot plumb real egress
339
+ // into it by EITHER path:
340
+ // - privileged macvlan needs nsjail + CAP_NET_ADMIN + a usable host iface +
341
+ // static addressing (CHECKSTACK_SANDBOX_MACVLAN_IP/_NM/_GW);
342
+ // - rootless slirp4netns needs bwrap + a creatable user+net namespace +
343
+ // slirp4netns on PATH.
344
+ // A routeless netns would blackhole the allowed CIDRs too — a silent
345
+ // under-block, not enforcement — so degrade-and-surface to host net.
346
+ return {
347
+ kind: "degrade",
348
+ reason:
349
+ "network allowlist requires plumbing real egress into the namespace, by EITHER the privileged macvlan path (nsjail + CAP_NET_ADMIN + a usable host interface + CHECKSTACK_SANDBOX_MACVLAN_IP/_NM/_GW addressing) OR the rootless path (bwrap + an unprivileged user+net namespace + slirp4netns on PATH); this host can deliver neither, and a routeless namespace would blackhole the allowed destinations; egress unrestricted",
350
+ };
351
+ }
352
+
353
+ const nftRuleset = buildEgressNftRules({
354
+ mode: "allowlist",
355
+ allow: policy.allow,
356
+ denyMetadata: policy.denyLinkLocalAndMetadata,
357
+ });
358
+ return plumbing.path === "macvlan"
359
+ ? {
360
+ kind: "namespaced",
361
+ mode: "allowlist",
362
+ egressPath: "macvlan",
363
+ egressIface: plumbing.iface,
364
+ egressAddressing: plumbing.addressing,
365
+ nftRuleset,
366
+ }
367
+ : {
368
+ kind: "namespaced",
369
+ mode: "allowlist",
370
+ egressPath: "rootless",
371
+ nftRuleset,
372
+ };
373
+ }
@@ -0,0 +1,210 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type { SandboxCapabilities } from "./capabilities";
3
+ import {
4
+ logSandboxCapabilitiesAtStartup,
5
+ summarizeCapabilities,
6
+ summarizeRunDowngrades,
7
+ surfaceRunDowngrades,
8
+ } from "./observability";
9
+ import { DEFAULT_SANDBOX_PROFILE } from "./policy";
10
+ import type { EffectiveSandbox } from "./report";
11
+
12
+ const MACOS_NONE: SandboxCapabilities = {
13
+ platform: "darwin",
14
+ euidIsRoot: false,
15
+ hasPrlimit: false,
16
+ rlimitNative: false,
17
+ wrapper: null,
18
+ userNamespaces: false,
19
+ userNsCreatable: false,
20
+ netNamespaces: false,
21
+ netEgressIface: null,
22
+ netEgressAddressing: null,
23
+ netEgressRootless: false,
24
+ };
25
+
26
+ const LINUX_FULL: SandboxCapabilities = {
27
+ platform: "linux",
28
+ euidIsRoot: true,
29
+ hasPrlimit: true,
30
+ rlimitNative: true,
31
+ wrapper: "nsjail",
32
+ userNamespaces: true,
33
+ userNsCreatable: true,
34
+ netNamespaces: true,
35
+ netEgressIface: "eth0",
36
+ netEgressAddressing: {
37
+ ip: "10.0.0.2",
38
+ netmask: "255.255.255.0",
39
+ gateway: "10.0.0.1",
40
+ },
41
+ netEgressRootless: false,
42
+ };
43
+
44
+ function recordingLogger() {
45
+ const infos: Array<{ message: string; args: unknown[] }> = [];
46
+ const warns: Array<{ message: string; args: unknown[] }> = [];
47
+ return {
48
+ infos,
49
+ warns,
50
+ logger: {
51
+ info(message: string, ...args: unknown[]) {
52
+ infos.push({ message, args });
53
+ },
54
+ warn(message: string, ...args: unknown[]) {
55
+ warns.push({ message, args });
56
+ },
57
+ },
58
+ };
59
+ }
60
+
61
+ describe("summarizeCapabilities", () => {
62
+ it("projects the detected primitives into a flat, loggable shape", () => {
63
+ const s = summarizeCapabilities(LINUX_FULL);
64
+ expect(s.platform).toBe("linux");
65
+ expect(s.supervisorEuidIsRoot).toBe(true);
66
+ expect(s.wrapper).toBe("nsjail");
67
+ expect(s.netEgressIface).toBe("eth0");
68
+ });
69
+ });
70
+
71
+ describe("logSandboxCapabilitiesAtStartup", () => {
72
+ it("emits ONE info line with capabilities + effective enforcement", () => {
73
+ const { infos, logger } = recordingLogger();
74
+ logSandboxCapabilitiesAtStartup({
75
+ logger,
76
+ globalDefault: DEFAULT_SANDBOX_PROFILE,
77
+ caps: LINUX_FULL,
78
+ });
79
+ expect(infos).toHaveLength(1);
80
+ expect(infos[0]?.message).toContain("script sandbox capabilities");
81
+ });
82
+
83
+ it("never throws even for an onUnavailable:fail global default on a weak host", () => {
84
+ const { logger } = recordingLogger();
85
+ expect(() =>
86
+ logSandboxCapabilitiesAtStartup({
87
+ logger,
88
+ globalDefault: { ...DEFAULT_SANDBOX_PROFILE, onUnavailable: "fail" },
89
+ caps: MACOS_NONE,
90
+ }),
91
+ ).not.toThrow();
92
+ });
93
+ });
94
+
95
+ describe("summarizeRunDowngrades", () => {
96
+ it("returns undefined when fully enforced (nothing to surface)", () => {
97
+ const effective: EffectiveSandbox = {
98
+ requested: DEFAULT_SANDBOX_PROFILE,
99
+ enforced: {
100
+ resources: true,
101
+ filesystem: true,
102
+ network: true,
103
+ privilege: true,
104
+ },
105
+ downgrades: [],
106
+ notes: [],
107
+ platform: "linux",
108
+ };
109
+ expect(summarizeRunDowngrades(effective)).toBeUndefined();
110
+ expect(summarizeRunDowngrades(undefined)).toBeUndefined();
111
+ });
112
+
113
+ it("summarizes the degraded layers + reasons", () => {
114
+ const effective: EffectiveSandbox = {
115
+ requested: DEFAULT_SANDBOX_PROFILE,
116
+ enforced: {
117
+ resources: false,
118
+ filesystem: false,
119
+ network: false,
120
+ privilege: false,
121
+ },
122
+ downgrades: [
123
+ { layer: "network", reason: "metadata block unenforceable" },
124
+ { layer: "filesystem", reason: "no wrapper" },
125
+ ],
126
+ notes: [],
127
+ platform: "darwin",
128
+ };
129
+ const summary = summarizeRunDowngrades(effective);
130
+ expect(summary?.layers).toEqual(["network", "filesystem"]);
131
+ expect(summary?.reasons.network).toContain("metadata");
132
+ expect(summary?.platform).toBe("darwin");
133
+ });
134
+ });
135
+
136
+ describe("surfaceRunDowngrades", () => {
137
+ it("logs a single structured warning when a layer degraded", () => {
138
+ const { warns, logger } = recordingLogger();
139
+ surfaceRunDowngrades({
140
+ logger,
141
+ effective: {
142
+ requested: DEFAULT_SANDBOX_PROFILE,
143
+ enforced: {
144
+ resources: true,
145
+ filesystem: false,
146
+ network: false,
147
+ privilege: true,
148
+ },
149
+ downgrades: [
150
+ { layer: "filesystem", reason: "no wrapper on this host" },
151
+ ],
152
+ notes: [],
153
+ platform: "darwin",
154
+ },
155
+ });
156
+ expect(warns).toHaveLength(1);
157
+ expect(warns[0]?.message).toContain("script sandbox degraded");
158
+ expect(warns[0]?.message).toContain("filesystem");
159
+ });
160
+
161
+ it("is a no-op when the run was fully enforced (no downgrades, no notes)", () => {
162
+ const { warns, infos, logger } = recordingLogger();
163
+ surfaceRunDowngrades({
164
+ logger,
165
+ effective: {
166
+ requested: DEFAULT_SANDBOX_PROFILE,
167
+ enforced: {
168
+ resources: true,
169
+ filesystem: true,
170
+ network: true,
171
+ privilege: true,
172
+ },
173
+ downgrades: [],
174
+ notes: [],
175
+ platform: "linux",
176
+ },
177
+ });
178
+ expect(warns).toHaveLength(0);
179
+ expect(infos).toHaveLength(0);
180
+ });
181
+
182
+ it("logs non-fatal notes at INFO (separate from downgrades)", () => {
183
+ const { warns, infos, logger } = recordingLogger();
184
+ surfaceRunDowngrades({
185
+ logger,
186
+ effective: {
187
+ requested: DEFAULT_SANDBOX_PROFILE,
188
+ enforced: {
189
+ resources: true,
190
+ filesystem: true,
191
+ network: true,
192
+ privilege: true,
193
+ },
194
+ downgrades: [],
195
+ notes: [
196
+ {
197
+ layer: "resources",
198
+ note: "per-run memory (memoryBytes) is NOT enforced; cgroup is the ceiling",
199
+ },
200
+ ],
201
+ platform: "linux",
202
+ },
203
+ });
204
+ // A note is NOT a degradation: no warning, an INFO line instead.
205
+ expect(warns).toHaveLength(0);
206
+ expect(infos).toHaveLength(1);
207
+ expect(infos[0]?.message).toContain("script sandbox notes");
208
+ expect(infos[0]?.message).toContain("resources");
209
+ });
210
+ });