@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,161 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type { z } from "zod";
3
+ import type { ConfigService } from "../config-service";
4
+ import type { Migration } from "../config-versioning";
5
+ import {
6
+ GLOBAL_SANDBOX_DEFAULT_CONFIG_ID,
7
+ readGlobalSandboxDefault,
8
+ writeGlobalSandboxDefault,
9
+ } from "./global-default";
10
+ import {
11
+ resolveDefaultSandboxProfile,
12
+ SANDBOX_UID_ENV,
13
+ } from "./policy";
14
+
15
+ /**
16
+ * In-memory ConfigService over a SHARED backing store, simulating the durable
17
+ * Postgres `plugin_configs` table. Two instances built over the SAME `store`
18
+ * stand in for two pods reading the one durable row — the scale-correctness
19
+ * guard (`.claude/rules/state-and-scale.md` Q2: same answer on every pod).
20
+ */
21
+ function fakeConfigService(store: Map<string, unknown>): ConfigService {
22
+ return {
23
+ async set<T>(
24
+ configId: string,
25
+ _schema: z.ZodType<T>,
26
+ _version: number,
27
+ data: T,
28
+ _migrations?: Migration<unknown, unknown>[],
29
+ ): Promise<void> {
30
+ store.set(configId, data);
31
+ },
32
+ async get<T>(
33
+ configId: string,
34
+ _schema: z.ZodType<T>,
35
+ _version: number,
36
+ _migrations?: Migration<unknown, unknown>[],
37
+ ): Promise<T | undefined> {
38
+ return store.has(configId) ? (store.get(configId) as T) : undefined;
39
+ },
40
+ async getRedacted<T>(): Promise<Partial<T> | undefined> {
41
+ throw new Error("not used in these tests");
42
+ },
43
+ async delete(configId: string): Promise<void> {
44
+ store.delete(configId);
45
+ },
46
+ async list(): Promise<string[]> {
47
+ return [...store.keys()];
48
+ },
49
+ };
50
+ }
51
+
52
+ describe("readGlobalSandboxDefault", () => {
53
+ it("falls back to the shipped safe default when no row exists (on-by-default holds)", async () => {
54
+ delete process.env[SANDBOX_UID_ENV];
55
+ const configService = fakeConfigService(new Map());
56
+ const policy = await readGlobalSandboxDefault({ configService });
57
+ expect(policy).toEqual(resolveDefaultSandboxProfile());
58
+ expect(policy.enabled).toBe(true);
59
+ expect(policy.filesystem.mode).toBe("scratch-plus-ro");
60
+ });
61
+
62
+ it("returns the stored durable policy when present", async () => {
63
+ const store = new Map<string, unknown>();
64
+ const configService = fakeConfigService(store);
65
+ await writeGlobalSandboxDefault({
66
+ configService,
67
+ policy: { enabled: false },
68
+ });
69
+ const policy = await readGlobalSandboxDefault({ configService });
70
+ expect(policy.enabled).toBe(false);
71
+ });
72
+ });
73
+
74
+ describe("global default is shared, not pod-local (scale-correctness)", () => {
75
+ it("a write on one pod is visible identically on another pod", async () => {
76
+ // ONE shared backing store = the durable Postgres row both pods read.
77
+ const store = new Map<string, unknown>();
78
+ const podA = fakeConfigService(store);
79
+ const podB = fakeConfigService(store);
80
+
81
+ // Admin configures the global default via pod A.
82
+ const written = await writeGlobalSandboxDefault({
83
+ configService: podA,
84
+ policy: { network: { mode: "deny" }, resources: { cpuSeconds: 30 } },
85
+ });
86
+
87
+ // Pod B reads the SAME value (not a pod-local copy / default).
88
+ const onB = await readGlobalSandboxDefault({ configService: podB });
89
+ expect(onB).toEqual(written);
90
+ expect(onB.network.mode).toBe("deny");
91
+ expect(onB.resources.cpuSeconds).toBe(30);
92
+ });
93
+
94
+ it("stores under the well-known, stable config id", async () => {
95
+ const store = new Map<string, unknown>();
96
+ const configService = fakeConfigService(store);
97
+ await writeGlobalSandboxDefault({
98
+ configService,
99
+ policy: { enabled: false },
100
+ });
101
+ expect(store.has(GLOBAL_SANDBOX_DEFAULT_CONFIG_ID)).toBe(true);
102
+ });
103
+ });
104
+
105
+ describe("writeGlobalSandboxDefault", () => {
106
+ it("merges a partial input over the safe default before storing", async () => {
107
+ const store = new Map<string, unknown>();
108
+ const configService = fakeConfigService(store);
109
+ const resolved = await writeGlobalSandboxDefault({
110
+ configService,
111
+ policy: { network: { mode: "deny" } },
112
+ });
113
+ expect(resolved.enabled).toBe(true);
114
+ // An unspecified onUnavailable inherits the shipped safe default, which is
115
+ // now fail-closed.
116
+ expect(resolved.onUnavailable).toBe("fail");
117
+ expect(resolved.network.mode).toBe("deny");
118
+ });
119
+
120
+ it("a PARTIAL global write does NOT reset the unspecified layers (MINOR fix)", async () => {
121
+ const store = new Map<string, unknown>();
122
+ const configService = fakeConfigService(store);
123
+ // Admin loosens only memory; everything else must keep the safe default,
124
+ // NOT collapse to the bare zod field defaults (filesystem off, etc.).
125
+ const resolved = await writeGlobalSandboxDefault({
126
+ configService,
127
+ policy: { resources: { memoryBytes: 2 * 1024 * 1024 * 1024 } },
128
+ });
129
+ expect(resolved.resources.memoryBytes).toBe(2 * 1024 * 1024 * 1024);
130
+ // Unspecified layers preserved from the safe default profile:
131
+ expect(resolved.filesystem.mode).toBe("scratch-plus-ro");
132
+ expect(resolved.privilege.mode).toBe("drop-to-uid");
133
+ expect(resolved.network.denyLinkLocalAndMetadata).toBe(true);
134
+ // Other resource caps from the default profile are preserved too.
135
+ expect(resolved.resources.cpuSeconds).toBe(60);
136
+ });
137
+
138
+ it("a global { enabled: false } persists as a disable while keeping the rest", async () => {
139
+ const store = new Map<string, unknown>();
140
+ const configService = fakeConfigService(store);
141
+ const resolved = await writeGlobalSandboxDefault({
142
+ configService,
143
+ policy: { enabled: false },
144
+ });
145
+ expect(resolved.enabled).toBe(false);
146
+ // The merge keeps the safe default for the (now-inert) layers.
147
+ expect(resolved.filesystem.mode).toBe("scratch-plus-ro");
148
+ });
149
+
150
+ it("rejects an invalid policy (bad bounds) instead of persisting garbage", async () => {
151
+ const store = new Map<string, unknown>();
152
+ const configService = fakeConfigService(store);
153
+ await expect(
154
+ writeGlobalSandboxDefault({
155
+ configService,
156
+ policy: { resources: { cpuSeconds: -1 } },
157
+ }),
158
+ ).rejects.toThrow();
159
+ expect(store.size).toBe(0);
160
+ });
161
+ });
@@ -0,0 +1,100 @@
1
+ import type { ConfigService } from "../config-service";
2
+ import {
3
+ mergeSandboxPolicy,
4
+ resolveDefaultSandboxProfile,
5
+ sandboxPolicySchema,
6
+ type SandboxPolicy,
7
+ type SandboxPolicyInput,
8
+ } from "./policy";
9
+
10
+ /**
11
+ * Durable, cluster-wide global default sandbox policy (plan §5.8).
12
+ *
13
+ * State-and-scale (`.claude/rules/state-and-scale.md`):
14
+ * 1. WHERE IT LIVES: the existing plugin-scoped {@link ConfigService}, which
15
+ * persists to the shared Postgres `plugin_configs` table. NOT pod-local,
16
+ * NOT an env var that could differ per pod.
17
+ * 2. SAME ANSWER ON EVERY POD: every pod/satellite reads the same row, so the
18
+ * resolved global default is identical everywhere it is read. The dedicated
19
+ * low-priv UID/GID seed (from `CHECKSTACK_SANDBOX_UID/GID`) is genuinely
20
+ * per-host infrastructure (a host without that user cannot drop to it), and
21
+ * that divergence is surfaced per run in the EffectiveSandbox report — it
22
+ * is not the queryable global policy.
23
+ * 3. NOT DUPLICATED: this is the single writer of the logical "global default
24
+ * sandbox policy"; the per-item `sandbox` config field is a separate,
25
+ * narrower override applied ON TOP of this value.
26
+ *
27
+ * The shipped, code-level safe default ({@link resolveDefaultSandboxProfile})
28
+ * is the value used when no row exists yet (a fresh install, or before an
29
+ * operator ever touches the setting), so on-by-default holds even with an empty
30
+ * settings table. An operator opts out globally by storing `{ enabled: false }`
31
+ * (or any partial override) here.
32
+ */
33
+
34
+ /**
35
+ * Config key under which the global default sandbox policy is stored. Scoped to
36
+ * the writing plugin by {@link ConfigService}, so each script plugin keeps its
37
+ * own row; the read-side {@link readGlobalSandboxDefault} resolves the same key.
38
+ */
39
+ export const GLOBAL_SANDBOX_DEFAULT_CONFIG_ID = "script-sandbox:global-default";
40
+
41
+ /** Schema version for the stored policy (bump + add a migration on change). */
42
+ export const GLOBAL_SANDBOX_DEFAULT_VERSION = 1;
43
+
44
+ /**
45
+ * Read the durable global default sandbox policy, falling back to the shipped
46
+ * safe default when nothing has been stored.
47
+ *
48
+ * Returns a fully-resolved {@link SandboxPolicy} suitable to pass straight to a
49
+ * runner's `sandbox` option (the runner merges it over its own default profile
50
+ * idempotently) or to merge a per-item override on top of via
51
+ * `mergeSandboxPolicy`.
52
+ */
53
+ export async function readGlobalSandboxDefault({
54
+ configService,
55
+ }: {
56
+ configService: ConfigService;
57
+ }): Promise<SandboxPolicy> {
58
+ const stored = await configService.get<SandboxPolicy>(
59
+ GLOBAL_SANDBOX_DEFAULT_CONFIG_ID,
60
+ sandboxPolicySchema,
61
+ GLOBAL_SANDBOX_DEFAULT_VERSION,
62
+ );
63
+ // No row yet → the shipped safe default (with the dedicated UID/GID seeded
64
+ // from this host's env). On-by-default holds even on an empty settings table.
65
+ return stored ?? resolveDefaultSandboxProfile();
66
+ }
67
+
68
+ /**
69
+ * Persist the global default sandbox policy.
70
+ *
71
+ * The input is treated as a PARTIAL override merged over the shipped safe
72
+ * default profile ({@link resolveDefaultSandboxProfile} via
73
+ * {@link mergeSandboxPolicy}), so an admin loosening a single layer (e.g. just
74
+ * `{ resources: { memoryBytes } }`) does NOT silently reset the unspecified
75
+ * layers (filesystem → off, privilege → inherit, ...) cluster-wide. Only the
76
+ * fields the admin actually set change; everything else keeps the safe default.
77
+ * Use `{ enabled: false }` to globally opt out.
78
+ */
79
+ export async function writeGlobalSandboxDefault({
80
+ configService,
81
+ policy,
82
+ }: {
83
+ configService: ConfigService;
84
+ policy: SandboxPolicyInput;
85
+ }): Promise<SandboxPolicy> {
86
+ // Validate the partial first (bounds + .strict), then overlay only the
87
+ // provided fields onto the safe default so unspecified layers are preserved.
88
+ sandboxPolicySchema.parse(policy);
89
+ const resolved = mergeSandboxPolicy({
90
+ base: resolveDefaultSandboxProfile(),
91
+ override: policy,
92
+ });
93
+ await configService.set<SandboxPolicy>(
94
+ GLOBAL_SANDBOX_DEFAULT_CONFIG_ID,
95
+ sandboxPolicySchema,
96
+ GLOBAL_SANDBOX_DEFAULT_VERSION,
97
+ resolved,
98
+ );
99
+ return resolved;
100
+ }
@@ -0,0 +1,14 @@
1
+ export * from "./policy";
2
+ export * from "./report";
3
+ export * from "./env-guard";
4
+ export * from "./capabilities";
5
+ export * from "./wrapper";
6
+ export * from "./output-truncation";
7
+ export * from "./capped-output";
8
+ export * from "./filesystem";
9
+ export * from "./network";
10
+ export * from "./rootless-egress";
11
+ export * from "./provider";
12
+ export * from "./global-default";
13
+ export * from "./observability";
14
+ export * from "./readiness";
@@ -0,0 +1,356 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type { SandboxCapabilities } from "./capabilities";
3
+ import {
4
+ ALWAYS_BLOCKED_CIDRS,
5
+ buildEgressNftRules,
6
+ buildNetworkLayer,
7
+ } from "./network";
8
+ import { networkPolicySchema } from "./policy";
9
+
10
+ // nsjail host that CAN plumb real egress (root + a usable uplink iface): the
11
+ // ONLY configuration where allowlist / the metadata block are deliverable.
12
+ const LINUX_NSJAIL_PLUMBED: SandboxCapabilities = {
13
+ platform: "linux",
14
+ euidIsRoot: true,
15
+ hasPrlimit: true,
16
+ rlimitNative: true,
17
+ wrapper: "nsjail",
18
+ userNamespaces: true,
19
+ userNsCreatable: true,
20
+ netNamespaces: true,
21
+ netEgressIface: "eth0",
22
+ netEgressAddressing: {
23
+ ip: "10.0.0.2",
24
+ netmask: "255.255.255.0",
25
+ gateway: "10.0.0.1",
26
+ },
27
+ netEgressRootless: false,
28
+ };
29
+
30
+ // nsjail present, namespace creatable, but egress CANNOT be plumbed (non-root /
31
+ // no uplink): deny still works (routeless), allowlist + metadata block degrade.
32
+ const LINUX_NSJAIL_NO_PLUMB: SandboxCapabilities = {
33
+ ...LINUX_NSJAIL_PLUMBED,
34
+ euidIsRoot: false,
35
+ netEgressIface: null,
36
+ netEgressAddressing: null,
37
+ };
38
+
39
+ // nsjail + root + uplink, but NO static macvlan addressing configured: the
40
+ // macvlan would be routeless, so allowlist / metadata block degrade-and-surface.
41
+ const LINUX_NSJAIL_NO_ADDRESSING: SandboxCapabilities = {
42
+ ...LINUX_NSJAIL_PLUMBED,
43
+ netEgressAddressing: null,
44
+ };
45
+
46
+ const LINUX_BWRAP: SandboxCapabilities = {
47
+ ...LINUX_NSJAIL_PLUMBED,
48
+ wrapper: "bwrap",
49
+ netEgressIface: null, // macvlan egress plumbing is nsjail-only
50
+ netEgressAddressing: null,
51
+ };
52
+
53
+ // bwrap host with the ROOTLESS slirp4netns path available (no privileged
54
+ // macvlan): the common rootless-container case. allowlist + metadata block are
55
+ // deliverable here without root / an uplink / operator addressing.
56
+ const LINUX_BWRAP_ROOTLESS: SandboxCapabilities = {
57
+ ...LINUX_BWRAP,
58
+ netEgressRootless: true,
59
+ };
60
+
61
+ // A host where BOTH paths are present: macvlan (nsjail+root+uplink+addressing)
62
+ // AND rootless. Used to assert the macvlan path is PREFERRED.
63
+ const LINUX_BOTH_PATHS: SandboxCapabilities = {
64
+ ...LINUX_NSJAIL_PLUMBED,
65
+ netEgressRootless: true,
66
+ };
67
+
68
+ const LINUX_NO_NETNS: SandboxCapabilities = {
69
+ ...LINUX_NSJAIL_PLUMBED,
70
+ wrapper: null,
71
+ netNamespaces: false,
72
+ netEgressIface: null,
73
+ netEgressAddressing: null,
74
+ };
75
+
76
+ const MACOS: SandboxCapabilities = {
77
+ platform: "darwin",
78
+ euidIsRoot: false,
79
+ hasPrlimit: false,
80
+ rlimitNative: false,
81
+ wrapper: null,
82
+ userNamespaces: false,
83
+ userNsCreatable: false,
84
+ netNamespaces: false,
85
+ netEgressIface: null,
86
+ netEgressAddressing: null,
87
+ netEgressRootless: false,
88
+ };
89
+
90
+ function net(input: unknown) {
91
+ return networkPolicySchema.parse(input);
92
+ }
93
+
94
+ describe("buildEgressNftRules — always-on metadata/link-local block", () => {
95
+ it("drops the metadata/link-local ranges in allowlist mode", () => {
96
+ const rules = buildEgressNftRules({
97
+ mode: "allowlist",
98
+ allow: ["10.0.0.0/8"],
99
+ denyMetadata: true,
100
+ });
101
+ // The block is emitted BEFORE any accept so it cannot be re-opened.
102
+ expect(rules).toContain("169.254.0.0/16");
103
+ expect(rules).toContain("fe80::/10");
104
+ expect(rules).toContain("fc00::/7");
105
+ const dropIdx = rules.indexOf("169.254.0.0/16");
106
+ const allowIdx = rules.indexOf("10.0.0.0/8");
107
+ expect(dropIdx).toBeLessThan(allowIdx);
108
+ // Allowed CIDR + a final default drop.
109
+ expect(rules).toContain("ip daddr { 10.0.0.0/8 } accept");
110
+ expect(rules.trimEnd().split("\n").some((l) => l.trim() === "drop")).toBe(
111
+ true,
112
+ );
113
+ });
114
+
115
+ it("separates IPv4 and IPv6 allow entries onto ip / ip6 rules", () => {
116
+ const rules = buildEgressNftRules({
117
+ mode: "allowlist",
118
+ allow: ["10.0.0.0/8", "2001:db8::/32"],
119
+ denyMetadata: true,
120
+ });
121
+ expect(rules).toContain("ip daddr { 10.0.0.0/8 } accept");
122
+ expect(rules).toContain("ip6 daddr { 2001:db8::/32 } accept");
123
+ });
124
+
125
+ it("metadata-only mode default-accepts but still drops the blocked ranges", () => {
126
+ const rules = buildEgressNftRules({
127
+ mode: "metadata-only",
128
+ allow: [],
129
+ denyMetadata: true,
130
+ });
131
+ expect(rules).toContain("policy accept");
132
+ expect(rules).toContain("169.254.0.0/16");
133
+ // No final default drop — everything except the blocked ranges is allowed.
134
+ expect(rules.trimEnd().split("\n").some((l) => l.trim() === "drop")).toBe(
135
+ false,
136
+ );
137
+ });
138
+
139
+ it("the always-blocked set covers IPv4 link-local + IPv6 link/unique-local", () => {
140
+ expect(ALWAYS_BLOCKED_CIDRS).toContain("169.254.0.0/16");
141
+ expect(ALWAYS_BLOCKED_CIDRS).toContain("fe80::/10");
142
+ expect(ALWAYS_BLOCKED_CIDRS).toContain("fc00::/7");
143
+ });
144
+ });
145
+
146
+ describe("buildNetworkLayer — deny (routeless netns is the goal)", () => {
147
+ it("takes a fresh routeless namespace, no ruleset, no iface (bwrap)", () => {
148
+ const d = buildNetworkLayer({ policy: net({ mode: "deny" }), caps: LINUX_BWRAP });
149
+ expect(d.kind).toBe("namespaced");
150
+ if (d.kind !== "namespaced") throw new Error("expected namespaced");
151
+ expect(d.mode).toBe("deny");
152
+ expect(d.nftRuleset).toBeUndefined();
153
+ expect(d.egressIface).toBeUndefined(); // routeless: no uplink
154
+ });
155
+
156
+ it("deny works on an nsjail host that cannot plumb egress (still routeless)", () => {
157
+ const d = buildNetworkLayer({
158
+ policy: net({ mode: "deny" }),
159
+ caps: LINUX_NSJAIL_NO_PLUMB,
160
+ });
161
+ expect(d.kind).toBe("namespaced");
162
+ if (d.kind !== "namespaced") throw new Error("expected namespaced");
163
+ expect(d.mode).toBe("deny");
164
+ expect(d.egressIface).toBeUndefined();
165
+ });
166
+
167
+ it("degrades deny on a host without net namespaces", () => {
168
+ const d = buildNetworkLayer({
169
+ policy: net({ mode: "deny" }),
170
+ caps: LINUX_NO_NETNS,
171
+ });
172
+ expect(d.kind).toBe("degrade");
173
+ });
174
+
175
+ it("degrades deny on macOS", () => {
176
+ const d = buildNetworkLayer({ policy: net({ mode: "deny" }), caps: MACOS });
177
+ expect(d.kind).toBe("degrade");
178
+ if (d.kind !== "degrade") throw new Error("expected degrade");
179
+ expect(d.reason).toContain("platform=darwin");
180
+ });
181
+ });
182
+
183
+ describe("buildNetworkLayer — EMPTY allowlist == deny (routeless, no plumbing)", () => {
184
+ it("resolves the secure-default empty allowlist to a routeless deny netns", () => {
185
+ // The shipped secure default: `allowlist` with an EMPTY allow list = no
186
+ // egress at all. It must take the routeless deny path (loopback only), which
187
+ // needs NO macvlan/slirp4netns plumbing and NO nftables ruleset, so it
188
+ // enforces on any netns-capable host - even where in-namespace nft is denied.
189
+ for (const caps of [LINUX_BWRAP, LINUX_BWRAP_ROOTLESS, LINUX_NSJAIL_PLUMBED]) {
190
+ const d = buildNetworkLayer({
191
+ policy: net({ mode: "allowlist", allow: [] }),
192
+ caps,
193
+ });
194
+ expect(d.kind).toBe("namespaced");
195
+ if (d.kind !== "namespaced") throw new Error("expected namespaced");
196
+ expect(d.mode).toBe("deny");
197
+ expect(d.nftRuleset).toBeUndefined();
198
+ expect(d.egressIface).toBeUndefined();
199
+ expect(d.egressPath).toBeUndefined();
200
+ }
201
+ });
202
+
203
+ it("still degrades an empty allowlist where no netns wrapper exists", () => {
204
+ const d = buildNetworkLayer({
205
+ policy: net({ mode: "allowlist", allow: [] }),
206
+ caps: MACOS,
207
+ });
208
+ expect(d.kind).toBe("degrade");
209
+ });
210
+ });
211
+
212
+ describe("buildNetworkLayer — allowlist (needs plumbed egress, never blackhole)", () => {
213
+ it("plumbs a macvlan uplink + nftables ruleset when egress is plumbable", () => {
214
+ const d = buildNetworkLayer({
215
+ policy: net({ mode: "allowlist", allow: ["10.0.0.0/8"] }),
216
+ caps: LINUX_NSJAIL_PLUMBED,
217
+ });
218
+ expect(d.kind).toBe("namespaced");
219
+ if (d.kind !== "namespaced") throw new Error("expected namespaced");
220
+ expect(d.mode).toBe("allowlist");
221
+ expect(d.egressIface).toBe("eth0"); // real uplink → allowed CIDR reachable
222
+ expect(d.egressAddressing).toEqual({
223
+ ip: "10.0.0.2",
224
+ netmask: "255.255.255.0",
225
+ gateway: "10.0.0.1",
226
+ });
227
+ expect(d.nftRuleset).toContain("10.0.0.0/8");
228
+ expect(d.nftRuleset).toContain("169.254.0.0/16"); // metadata still blocked
229
+ });
230
+
231
+ it("degrades (surfaced) when the macvlan endpoint has no static addressing", () => {
232
+ // The iface is plumbable but no CHECKSTACK_SANDBOX_MACVLAN_* addressing is
233
+ // configured → an unaddressed macvlan has no route, so the allowlist would
234
+ // blackhole. Must degrade-and-surface, never blackhole.
235
+ const d = buildNetworkLayer({
236
+ policy: net({ mode: "allowlist", allow: ["10.0.0.0/8"] }),
237
+ caps: LINUX_NSJAIL_NO_ADDRESSING,
238
+ });
239
+ expect(d.kind).toBe("degrade");
240
+ if (d.kind !== "degrade") throw new Error("expected degrade");
241
+ // Neither path is deliverable (macvlan lacks addressing; not rootless) →
242
+ // the unified degrade reason names the addressing requirement + blackhole.
243
+ expect(d.reason).toContain("macvlan");
244
+ expect(d.reason).toContain("addressing");
245
+ expect(d.reason).toContain("blackhole");
246
+ });
247
+
248
+ it("degrades (surfaced) when egress cannot be plumbed — NOT a blackhole", () => {
249
+ const d = buildNetworkLayer({
250
+ policy: net({ mode: "allowlist", allow: ["10.0.0.0/8"] }),
251
+ caps: LINUX_NSJAIL_NO_PLUMB,
252
+ });
253
+ expect(d.kind).toBe("degrade");
254
+ if (d.kind !== "degrade") throw new Error("expected degrade");
255
+ expect(d.reason).toContain("blackhole");
256
+ expect(d.reason).toContain("unrestricted");
257
+ });
258
+
259
+ it("degrades on bwrap with NEITHER macvlan NOR rootless egress available", () => {
260
+ const d = buildNetworkLayer({
261
+ policy: net({ mode: "allowlist", allow: ["10.0.0.0/8"] }),
262
+ caps: LINUX_BWRAP,
263
+ });
264
+ expect(d.kind).toBe("degrade");
265
+ if (d.kind !== "degrade") throw new Error("expected degrade");
266
+ expect(d.reason).toContain("rootless");
267
+ expect(d.reason).toContain("slirp4netns");
268
+ });
269
+
270
+ it("plumbs the ROOTLESS slirp4netns path on a rootless bwrap host", () => {
271
+ const d = buildNetworkLayer({
272
+ policy: net({ mode: "allowlist", allow: ["10.0.0.0/8"] }),
273
+ caps: LINUX_BWRAP_ROOTLESS,
274
+ });
275
+ expect(d.kind).toBe("namespaced");
276
+ if (d.kind !== "namespaced") throw new Error("expected namespaced");
277
+ expect(d.mode).toBe("allowlist");
278
+ expect(d.egressPath).toBe("rootless");
279
+ // No macvlan iface / addressing on the rootless path (slirp4netns supplies
280
+ // a deterministic userspace stack).
281
+ expect(d.egressIface).toBeUndefined();
282
+ expect(d.egressAddressing).toBeUndefined();
283
+ // The SAME nftables filter applies (allowed CIDR + metadata block).
284
+ expect(d.nftRuleset).toContain("10.0.0.0/8");
285
+ expect(d.nftRuleset).toContain("169.254.0.0/16");
286
+ });
287
+
288
+ it("PREFERS the privileged macvlan path when BOTH are available", () => {
289
+ const d = buildNetworkLayer({
290
+ policy: net({ mode: "allowlist", allow: ["10.0.0.0/8"] }),
291
+ caps: LINUX_BOTH_PATHS,
292
+ });
293
+ expect(d.kind).toBe("namespaced");
294
+ if (d.kind !== "namespaced") throw new Error("expected namespaced");
295
+ expect(d.egressPath).toBe("macvlan");
296
+ expect(d.egressIface).toBe("eth0");
297
+ expect(d.egressAddressing).not.toBeUndefined();
298
+ });
299
+ });
300
+
301
+ describe("buildNetworkLayer — unrestricted + metadata block (never sever egress)", () => {
302
+ it("enforces the block in a PLUMBED namespace when egress is available", () => {
303
+ const d = buildNetworkLayer({
304
+ policy: net({ mode: "unrestricted", denyLinkLocalAndMetadata: true }),
305
+ caps: LINUX_NSJAIL_PLUMBED,
306
+ });
307
+ expect(d.kind).toBe("namespaced");
308
+ if (d.kind !== "namespaced") throw new Error("expected namespaced");
309
+ expect(d.egressIface).toBe("eth0"); // ordinary traffic still flows
310
+ expect(d.nftRuleset).toContain("169.254.0.0/16");
311
+ });
312
+
313
+ it("does NOT engage a routeless netns when egress is unplumbable (nsjail no-plumb)", () => {
314
+ // BLOCKER guard: a routeless netns here would sever ALL egress. Must keep
315
+ // host net and surface the block as unenforceable, never blackhole.
316
+ const d = buildNetworkLayer({
317
+ policy: net({ mode: "unrestricted", denyLinkLocalAndMetadata: true }),
318
+ caps: LINUX_NSJAIL_NO_PLUMB,
319
+ });
320
+ expect(d.kind).toBe("host");
321
+ if (d.kind !== "host") throw new Error("expected host");
322
+ expect(d.metadataBlockUnenforceable).toBe(true);
323
+ });
324
+
325
+ it("reports the metadata block unenforceable on bwrap with no egress path", () => {
326
+ const d = buildNetworkLayer({
327
+ policy: net({ mode: "unrestricted", denyLinkLocalAndMetadata: true }),
328
+ caps: LINUX_BWRAP,
329
+ });
330
+ expect(d.kind).toBe("host");
331
+ if (d.kind !== "host") throw new Error("expected host");
332
+ expect(d.metadataBlockUnenforceable).toBe(true);
333
+ });
334
+
335
+ it("enforces the metadata block via the ROOTLESS path when available", () => {
336
+ const d = buildNetworkLayer({
337
+ policy: net({ mode: "unrestricted", denyLinkLocalAndMetadata: true }),
338
+ caps: LINUX_BWRAP_ROOTLESS,
339
+ });
340
+ expect(d.kind).toBe("namespaced");
341
+ if (d.kind !== "namespaced") throw new Error("expected namespaced");
342
+ expect(d.egressPath).toBe("rootless");
343
+ expect(d.egressIface).toBeUndefined(); // ordinary egress still flows via slirp4netns
344
+ expect(d.nftRuleset).toContain("169.254.0.0/16");
345
+ });
346
+
347
+ it("stays on the host net with nothing to do when the block is disabled", () => {
348
+ const d = buildNetworkLayer({
349
+ policy: net({ mode: "unrestricted", denyLinkLocalAndMetadata: false }),
350
+ caps: LINUX_NSJAIL_PLUMBED,
351
+ });
352
+ expect(d.kind).toBe("host");
353
+ if (d.kind !== "host") throw new Error("expected host");
354
+ expect(d.metadataBlockUnenforceable).toBe(false);
355
+ });
356
+ });