@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,88 @@
1
+ import type { SandboxPolicy } from "./policy";
2
+
3
+ /**
4
+ * The set of sandbox layers. Used as keys in the enforced/downgrade maps so
5
+ * the surfaced report names layers consistently.
6
+ */
7
+ export type SandboxLayer =
8
+ | "resources"
9
+ | "filesystem"
10
+ | "network"
11
+ | "privilege";
12
+
13
+ /** Which layers are actually enforced for a given run. */
14
+ export interface EnforcedLayers {
15
+ resources: boolean;
16
+ filesystem: boolean;
17
+ network: boolean;
18
+ privilege: boolean;
19
+ }
20
+
21
+ /** A single layer that could not be enforced as requested. */
22
+ export interface SandboxDowngrade {
23
+ layer: SandboxLayer;
24
+ reason: string;
25
+ }
26
+
27
+ /**
28
+ * A NON-FATAL informational note about how a layer is enforced for this run.
29
+ *
30
+ * Unlike {@link SandboxDowngrade}, a note NEVER triggers the fail-closed gate
31
+ * (`onUnavailable: "fail"`). It records an accepted, expected enforcement
32
+ * characteristic that an operator should be aware of but that is NOT a failure
33
+ * to enforce the requested policy.
34
+ *
35
+ * The motivating case: SHELL scripts have no per-run memory enforcement. The
36
+ * ESM JS-heap cap (`NODE_OPTIONS=--max-old-space-size`) is consumed only by the
37
+ * ESM runner; the shell runner never applies it, so a shell run's memory
38
+ * ceiling is purely the container cgroup (Docker `--memory` / k8s
39
+ * `resources.limits.memory`). That is a legitimate ceiling, not a missing
40
+ * layer: refusing every shell run under fail-closed because of it would break
41
+ * all shell health-checks and automation. So it is surfaced as a note rather
42
+ * than overloaded onto `downgrades` (which fail-close).
43
+ */
44
+ export interface SandboxNote {
45
+ layer: SandboxLayer;
46
+ note: string;
47
+ }
48
+
49
+ /**
50
+ * What the sandbox actually did for a run, attached to the run result so call
51
+ * sites can log/surface it. `downgrades` is empty when every requested layer
52
+ * was fully enforced; otherwise it names each dropped layer and why.
53
+ */
54
+ export interface EffectiveSandbox {
55
+ /** The policy that was requested (fully resolved, post-merge). */
56
+ requested: SandboxPolicy;
57
+ /** Which layers are actually enforced on this host. */
58
+ enforced: EnforcedLayers;
59
+ /** Layers that were requested but degraded to the portable subset. */
60
+ downgrades: SandboxDowngrade[];
61
+ /**
62
+ * Non-fatal informational notes about HOW a layer is enforced (e.g. shell
63
+ * memory bounded by the cgroup rather than per-run). Never triggers
64
+ * fail-closed; surfaced alongside downgrades so operators see the full
65
+ * picture. Empty when there is nothing extra to surface.
66
+ */
67
+ notes: SandboxNote[];
68
+ /** Host platform the run executed on. */
69
+ platform: string;
70
+ }
71
+
72
+ /**
73
+ * Thrown by the hardening builder when a requested layer cannot be enforced
74
+ * and the policy's `onUnavailable` is `"fail"`. The runner catches this and
75
+ * returns a clean failure result WITHOUT spawning an unsandboxed child.
76
+ */
77
+ export class SandboxUnavailableError extends Error {
78
+ readonly downgrades: SandboxDowngrade[];
79
+
80
+ constructor(downgrades: SandboxDowngrade[]) {
81
+ const summary = downgrades
82
+ .map((d) => `${d.layer}: ${d.reason}`)
83
+ .join("; ");
84
+ super(`sandbox unavailable: ${summary}`);
85
+ this.name = "SandboxUnavailableError";
86
+ this.downgrades = downgrades;
87
+ }
88
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Integration test for the ROOTLESS egress path (slirp4netns + fail-closed
3
+ * nftables), exercising the REAL launcher against a real `bwrap` + `slirp4netns`
4
+ * + `nft`. Pure/argv-level coverage lives in `rootless-egress.test.ts`,
5
+ * `network.test.ts`, and `wrapper.test.ts`; this pins the one thing those
6
+ * cannot: that the generated launcher actually plumbs filtered egress and that
7
+ * the filter is genuinely enforced (the allowed dest is reachable, a blocked
8
+ * dest is not), so `enforced.network = true` is truthful.
9
+ *
10
+ * Gated behind `CHECKSTACK_IT=1` AND auto-skipped when the host lacks the
11
+ * primitives (the detected capability says no rootless path), so the default
12
+ * `bun test` and non-Linux/non-rootless CI never run it. It needs a host where
13
+ * `unshare --user --net` works, `slirp4netns` + `bwrap` + `nft` are on PATH.
14
+ */
15
+ import { describe, expect, it } from "bun:test";
16
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
17
+ import { tmpdir } from "node:os";
18
+ import path from "node:path";
19
+ import { detectSandboxCapabilities } from "./capabilities";
20
+ import { buildNetworkLayer } from "./network";
21
+ import { buildSpawnHardening } from "./wrapper";
22
+ import { pickSafeEnv } from "./env-guard";
23
+ import { sandboxPolicySchema } from "./policy";
24
+
25
+ const caps = detectSandboxCapabilities();
26
+ const enabled = Boolean(process.env.CHECKSTACK_IT) && caps.netEgressRootless;
27
+
28
+ describe.skipIf(!enabled)("rootless egress (real slirp4netns)", () => {
29
+ it("delivers filtered egress: blocks a non-allowlisted destination", async () => {
30
+ const dir = await mkdtemp(path.join(tmpdir(), "checkstack-rl-it-"));
31
+ try {
32
+ const nftRulesetPath = path.join(dir, "egress.nft");
33
+ const rootlessLauncherPath = path.join(dir, "rootless-egress.sh");
34
+ // Allowlist a single private CIDR that is NOT the public target below.
35
+ const policy = sandboxPolicySchema.parse({
36
+ filesystem: { mode: "off" },
37
+ network: { mode: "allowlist", allow: ["10.255.255.0/24"] },
38
+ privilege: { mode: "inherit" },
39
+ resources: { maxOutputBytes: 64 * 1024 },
40
+ });
41
+ const hardening = buildSpawnHardening({
42
+ policy,
43
+ caps,
44
+ baseEnv: pickSafeEnv(),
45
+ nftRulesetPath,
46
+ rootlessLauncherPath,
47
+ });
48
+ expect(hardening.effective.enforced.network).toBe(true);
49
+ expect(hardening.rootlessLauncher).toBeDefined();
50
+ await writeFile(nftRulesetPath, hardening.nftRuleset ?? "", "utf8");
51
+ await writeFile(rootlessLauncherPath, hardening.rootlessLauncher ?? "", {
52
+ encoding: "utf8",
53
+ mode: 0o700,
54
+ });
55
+
56
+ // A blocked destination (1.1.1.1, not in the allowlist) must NOT connect.
57
+ // We use a short-timeout TCP connect; a filtered egress yields failure.
58
+ const cmd = hardening.wrapCmd([
59
+ "sh",
60
+ "-c",
61
+ // `nc`/`/dev/tcp` may be unavailable; use the busybox-ish `timeout` +
62
+ // a connect attempt that returns non-zero when blocked.
63
+ "timeout 3 sh -c 'exec 3<>/dev/tcp/1.1.1.1/443' && echo CONNECTED || echo BLOCKED",
64
+ ]);
65
+ const proc = Bun.spawn({ cmd, env: hardening.env, stdout: "pipe", stderr: "pipe" });
66
+ const out = await new Response(proc.stdout).text();
67
+ await proc.exited;
68
+ // The allowlist does not include 1.1.1.1, so egress to it is dropped.
69
+ expect(out).toContain("BLOCKED");
70
+ } finally {
71
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
72
+ }
73
+ }, 30_000);
74
+
75
+ it("the network decision picks the rootless path on this host", () => {
76
+ const d = buildNetworkLayer({
77
+ policy: sandboxPolicySchema.parse({
78
+ network: { mode: "allowlist", allow: ["10.0.0.0/8"] },
79
+ }).network,
80
+ caps,
81
+ });
82
+ expect(d.kind).toBe("namespaced");
83
+ if (d.kind !== "namespaced") throw new Error("expected namespaced");
84
+ expect(d.egressPath).toBe("rootless");
85
+ });
86
+ });
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ buildRootlessInnerScript,
4
+ buildRootlessLauncherScript,
5
+ SLIRP4NETNS_CHILD_IP,
6
+ SLIRP4NETNS_GATEWAY,
7
+ SLIRP4NETNS_TAP_DEVICE,
8
+ } from "./rootless-egress";
9
+
10
+ describe("buildRootlessInnerScript — fail-closed filter ordering", () => {
11
+ const inner = buildRootlessInnerScript({ nftRulesetPath: "/run/egress.nft" });
12
+
13
+ it("loads the nft filter BEFORE signalling readiness or exec'ing", () => {
14
+ const nftIdx = inner.indexOf("nft -f");
15
+ const readyIdx = inner.indexOf("printf ready >&9");
16
+ const execIdx = inner.indexOf('exec "$@"');
17
+ // Filter loads first, THEN we announce the namespace, THEN exec the command.
18
+ expect(nftIdx).toBeGreaterThanOrEqual(0);
19
+ expect(nftIdx).toBeLessThan(readyIdx);
20
+ expect(readyIdx).toBeLessThan(execIdx);
21
+ });
22
+
23
+ it("is `set -e` so a failed nft load aborts before exec (never unfiltered)", () => {
24
+ expect(inner.startsWith("set -e")).toBe(true);
25
+ });
26
+
27
+ it("waits for the launcher's network-ready signal before exec", () => {
28
+ const readyIdx = inner.indexOf("printf ready >&9");
29
+ const waitIdx = inner.indexOf("IFS= read -r _ <&8");
30
+ const execIdx = inner.indexOf('exec "$@"');
31
+ expect(readyIdx).toBeLessThan(waitIdx);
32
+ expect(waitIdx).toBeLessThan(execIdx);
33
+ });
34
+
35
+ it("execs the real command via positional args (no string splicing)", () => {
36
+ expect(inner).toContain('exec "$@"');
37
+ });
38
+ });
39
+
40
+ describe("buildRootlessLauncherScript — slirp4netns orchestration", () => {
41
+ const launcher = buildRootlessLauncherScript({
42
+ bwrapArgv: ["bwrap", "--unshare-all", "--unshare-net", "--die-with-parent"],
43
+ nftRulesetPath: "/run/egress.nft",
44
+ });
45
+
46
+ it("invokes bwrap with --info-fd for a race-free child PID handshake", () => {
47
+ expect(launcher).toContain("--info-fd 7");
48
+ });
49
+
50
+ it("threads the real command through bwrap's inner sh as positional args", () => {
51
+ // `sh -c <inner> checkstack-rootless-egress "$@"` forwards the launcher's
52
+ // own "$@" (the real command) verbatim into the namespace.
53
+ expect(launcher).toContain('checkstack-rootless-egress "$@"');
54
+ });
55
+
56
+ it("configures slirp4netns with the deterministic tap device + ready-fd", () => {
57
+ expect(launcher).toContain("slirp4netns --configure");
58
+ expect(launcher).toContain("--ready-fd=3");
59
+ expect(launcher).toContain(SLIRP4NETNS_TAP_DEVICE);
60
+ });
61
+
62
+ it("signals the child only AFTER slirp4netns readies tap0 (no unfiltered window)", () => {
63
+ const slirpReadIdx = launcher.indexOf('IFS= read -r _ <"$slirp_ready"');
64
+ const netReadyIdx = launcher.indexOf('printf ready > "$net_ready"');
65
+ expect(slirpReadIdx).toBeGreaterThanOrEqual(0);
66
+ expect(netReadyIdx).toBeGreaterThanOrEqual(0);
67
+ // The launcher waits for slirp4netns readiness, THEN releases the child.
68
+ expect(slirpReadIdx).toBeLessThan(netReadyIdx);
69
+ });
70
+
71
+ it("fails closed (non-zero exit) when the child PID cannot be determined", () => {
72
+ expect(launcher).toContain("exit 70");
73
+ });
74
+
75
+ it("fails closed (non-zero exit) when slirp4netns never readies", () => {
76
+ expect(launcher).toContain("exit 71");
77
+ });
78
+
79
+ it("propagates the child's exit code", () => {
80
+ expect(launcher).toContain('wait "$bwrap_pid"');
81
+ expect(launcher).toContain("exit $?");
82
+ });
83
+
84
+ it("tears down slirp4netns + temp FIFOs on every exit path", () => {
85
+ expect(launcher).toContain("trap cleanup EXIT INT TERM");
86
+ expect(launcher).toContain('kill "$slirp_pid"');
87
+ expect(launcher).toContain('rm -rf "$work"');
88
+ });
89
+
90
+ it("embeds the provided bwrap argv verbatim (quoted)", () => {
91
+ expect(launcher).toContain("'bwrap' '--unshare-all' '--unshare-net'");
92
+ });
93
+
94
+ it("documents the deterministic addressing constants", () => {
95
+ // Sanity: the deterministic slirp4netns plan is stable (no operator input).
96
+ expect(SLIRP4NETNS_CHILD_IP).toBe("10.0.2.100");
97
+ expect(SLIRP4NETNS_GATEWAY).toBe("10.0.2.2");
98
+ });
99
+ });
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Rootless network egress via `slirp4netns` (the unprivileged fallback for the
3
+ * privileged macvlan path in `network.ts` / `filesystem.ts`).
4
+ *
5
+ * Why this exists: the macvlan path needs CAP_NET_ADMIN (euid root) + a usable
6
+ * host uplink + operator-supplied static addressing. The common rootless
7
+ * container case (rootless Podman/Docker) has NONE of those but DOES have
8
+ * unprivileged user namespaces. `slirp4netns` is the standard rootless plumbing:
9
+ * it runs in the PARENT network namespace and creates a `tap0` device with a
10
+ * userspace TCP/IP stack INSIDE the child's net namespace, NAT'ing egress out
11
+ * through the parent. So the child gets real, addressed, routed egress with no
12
+ * privilege — and we can filter THAT egress with nftables inside the same netns,
13
+ * exactly like the macvlan path.
14
+ *
15
+ * Determinism (no TOCTOU): slirp4netns uses a fixed, built-in addressing plan —
16
+ * subnet `10.0.2.0/24`, child endpoint `10.0.2.100`, gateway `10.0.2.2`. Unlike
17
+ * macvlan there is nothing to pick from the host, so there is no collision/race
18
+ * footgun and nothing to defer to operator env.
19
+ *
20
+ * Correctness of `enforced.network` (the load-bearing invariant): the egress
21
+ * filter is loaded FAIL-CLOSED. The inner command, the first thing it does
22
+ * inside the namespace, installs the nftables ruleset (default-drop egress +
23
+ * the allowlist accepts + the always-on metadata block) BEFORE `tap0` even
24
+ * exists. So the ordering is:
25
+ * 1. child enters userns+netns (loopback only, routeless — no egress yet),
26
+ * 2. child loads the default-drop nft ruleset (still no egress),
27
+ * 3. child signals "namespace ready" to the launcher,
28
+ * 4. launcher runs `slirp4netns --configure` which brings up the ADDRESSED
29
+ * `tap0` — now the ONLY reachable destinations are the ones the already-
30
+ * loaded ruleset permits,
31
+ * 5. launcher signals "network ready" back,
32
+ * 6. child execs the real command.
33
+ * If slirp4netns never readies, or nft fails to load, the child has NO filtered
34
+ * egress path — never an UNFILTERED one. There is no window in which traffic
35
+ * flows past an un-loaded filter, so when this path is engaged `enforced.network`
36
+ * is truthful.
37
+ *
38
+ * Mechanism: a single launcher SHELL SCRIPT (staged on disk by the runner, like
39
+ * the nft ruleset) is the wrapper prelude. It uses `bwrap`'s `--info-fd` to
40
+ * learn the child PID race-free, and a pair of FIFOs for the two-way ready
41
+ * handshake. `bwrap` is the only wrapper used here because it cleanly exposes
42
+ * the child PID; nsjail's macvlan path is preferred when privileged anyway.
43
+ *
44
+ * Everything below is PURE string/argv construction so it is fully testable
45
+ * without a real `slirp4netns` (the end-to-end behaviour is gated behind
46
+ * `CHECKSTACK_IT`).
47
+ */
48
+
49
+ import { shellQuote } from "./shell-quote";
50
+
51
+ /** slirp4netns built-in deterministic addressing (CIDR/gateway/endpoint). */
52
+ export const SLIRP4NETNS_TAP_DEVICE = "tap0";
53
+ export const SLIRP4NETNS_SUBNET_CIDR = "10.0.2.0/24";
54
+ export const SLIRP4NETNS_GATEWAY = "10.0.2.2";
55
+ export const SLIRP4NETNS_CHILD_IP = "10.0.2.100";
56
+
57
+ /**
58
+ * Inputs for the rootless launcher script. All paths are absolute and supplied
59
+ * by the runner (it owns the per-run scratch dir where these are staged).
60
+ */
61
+ export interface RootlessLauncherInputs {
62
+ /**
63
+ * The `bwrap` argv that creates the child user+net namespace and execs the
64
+ * inner command. Built by the filesystem layer with `--unshare-net` (WITHOUT
65
+ * the trailing `--`; the launcher appends `--info-fd N --`).
66
+ */
67
+ bwrapArgv: string[];
68
+ /** Path to the staged nftables ruleset file (loaded fail-closed inside). */
69
+ nftRulesetPath: string;
70
+ }
71
+
72
+ /**
73
+ * Build the inner `sh -c` script run INSIDE the namespace by bwrap. It:
74
+ * 1. loads the nft ruleset (default-drop egress + allowlist + metadata block)
75
+ * FAIL-CLOSED — exits non-zero if it cannot, so we never run unfiltered,
76
+ * 2. signals "namespace ready" so the launcher can run slirp4netns,
77
+ * 3. waits for "network ready",
78
+ * 4. exec's the real command (passed as the inner shell's positional args).
79
+ *
80
+ * The real command is forwarded as the inner shell's `"$@"` (the launcher
81
+ * receives it as ITS positional args and threads it through bwrap into the
82
+ * inner `sh -c ... <name> "$@"`), so the command argv is NEVER string-spliced
83
+ * into the script body — no quoting of caller-controlled argv is needed.
84
+ *
85
+ * `nft` is loaded with the child as (mapped) root inside its own userns, which
86
+ * is sufficient to populate an `inet` table in the child's own net namespace.
87
+ * If a rule class cannot be loaded rootless, `nft -f` exits non-zero and the
88
+ * whole run fails closed (degrade-and-surface upstream), never silently allows.
89
+ */
90
+ export function buildRootlessInnerScript({
91
+ nftRulesetPath,
92
+ }: {
93
+ nftRulesetPath: string;
94
+ }): string {
95
+ const quotedRuleset = shellQuote(nftRulesetPath);
96
+ // The launcher wires the two ready FIFOs onto fd 9 (write: "namespace ready")
97
+ // and fd 8 (read: "network ready") of this inner shell via bwrap redirection.
98
+ return [
99
+ "set -e",
100
+ // Fail-closed egress filter: default-drop is installed BEFORE tap0 exists,
101
+ // so there is no window where traffic flows past an un-loaded filter.
102
+ `nft -f ${quotedRuleset}`,
103
+ // Tell the launcher the namespace exists + the filter is loaded, so it can
104
+ // attach slirp4netns to our PID.
105
+ "printf ready >&9",
106
+ // Block until the launcher reports tap0 is up + addressed.
107
+ "IFS= read -r _ <&8",
108
+ // exec the real command, forwarded verbatim as positional args.
109
+ 'exec "$@"',
110
+ ].join("\n");
111
+ }
112
+
113
+ /**
114
+ * Build the full launcher shell script. This is what the wrapper prelude
115
+ * resolves to: the runner stages it and the spawned argv is `["sh", path]`.
116
+ *
117
+ * The launcher orchestrates the race-free rootless setup:
118
+ * - creates the two ready FIFOs + a bwrap info pipe,
119
+ * - spawns bwrap (which forks the namespaced child running the inner script),
120
+ * - reads the child PID from bwrap's `--info-fd` JSON,
121
+ * - waits for the child's "namespace ready",
122
+ * - runs `slirp4netns --configure --ready-fd <fd> <pid> tap0`,
123
+ * - signals "network ready" to the child,
124
+ * - waits for the child + propagates its exit code.
125
+ *
126
+ * Any failure (bwrap, slirp4netns, missing PID) aborts the launcher non-zero
127
+ * AFTER ensuring the child never proceeds past its filter load — fail-closed.
128
+ */
129
+ export function buildRootlessLauncherScript({
130
+ bwrapArgv,
131
+ nftRulesetPath,
132
+ }: RootlessLauncherInputs): string {
133
+ const inner = buildRootlessInnerScript({ nftRulesetPath });
134
+ // bwrap prefix WITHOUT the trailing `--`; the launcher appends `--info-fd`,
135
+ // the `--`, and the inner `sh -c`. `--unshare-net` must already be present
136
+ // (the FS layer adds it); we compose argv purely as strings here.
137
+ const quotedBwrap = bwrapArgv.map((arg) => shellQuote(arg)).join(" ");
138
+ const quotedInner = shellQuote(inner);
139
+ return `#!/bin/sh
140
+ # Rootless egress launcher (slirp4netns + fail-closed nftables). Generated by
141
+ # @checkstack/backend-api script sandbox. Do not edit.
142
+ #
143
+ # fd map:
144
+ # 7 bwrap --info-fd -> $info file (child PID as JSON)
145
+ # 8 child "network ready" <- net_ready FIFO (launcher writes when tap0 up)
146
+ # 9 child "namespace ready"-> ns_ready FIFO (child writes after nft load)
147
+ # 3 slirp4netns --ready-fd -> slirp_ready FIFO (slirp writes when tap0 up)
148
+ set -eu
149
+
150
+ work="$(mktemp -d "\${TMPDIR:-/tmp}/checkstack-slirp.XXXXXX")"
151
+ ns_ready="$work/ns_ready"
152
+ net_ready="$work/net_ready"
153
+ slirp_ready="$work/slirp_ready"
154
+ info="$work/bwrap_info"
155
+ mkfifo "$ns_ready" "$net_ready" "$slirp_ready"
156
+
157
+ slirp_pid=""
158
+ bwrap_pid=""
159
+ cleanup() {
160
+ status=$?
161
+ if [ -n "$slirp_pid" ]; then kill "$slirp_pid" 2>/dev/null || true; fi
162
+ rm -rf "$work" 2>/dev/null || true
163
+ exit "$status"
164
+ }
165
+ trap cleanup EXIT INT TERM
166
+
167
+ # Spawn bwrap (which forks the namespaced child running the inner script). The
168
+ # real command argv arrives as the launcher's own "$@" and is threaded verbatim
169
+ # into the inner shell's positional args (no string splicing of caller argv).
170
+ # fd 7 -> info file; child's fd 9 -> ns_ready (write); child's fd 8 <- net_ready.
171
+ # bwrap passes inherited fds >=3 through to the sandboxed process (it only
172
+ # consumes the ones named by its own options, e.g. --info-fd 7), so fds 8 and 9
173
+ # survive into the inner shell for the ready handshake.
174
+ ${quotedBwrap} --info-fd 7 -- \\
175
+ sh -c ${quotedInner} checkstack-rootless-egress "$@" \\
176
+ 7>"$info" 9>"$ns_ready" 8<"$net_ready" &
177
+ bwrap_pid=$!
178
+
179
+ # Wait for the child to announce "namespace ready + filter loaded". By this
180
+ # point bwrap has written the child PID to the info file.
181
+ IFS= read -r _ <"$ns_ready" || true
182
+ child_pid=""
183
+ if [ -s "$info" ]; then
184
+ child_pid="$(sed -n 's/.*"child-pid"[: ]*\\([0-9][0-9]*\\).*/\\1/p' "$info")"
185
+ fi
186
+ if [ -z "$child_pid" ]; then
187
+ echo "rootless-egress: could not determine namespaced child PID" >&2
188
+ kill "$bwrap_pid" 2>/dev/null || true
189
+ wait "$bwrap_pid" 2>/dev/null || true
190
+ exit 70
191
+ fi
192
+
193
+ # Bring up tap0 inside the child's netns with slirp4netns' deterministic
194
+ # addressing. --ready-fd writes one byte once tap0 is configured. The child's
195
+ # nft filter is ALREADY loaded (default-drop), so egress opens ONLY to the
196
+ # permitted destinations — never an unfiltered window.
197
+ slirp4netns --configure --mtu=65520 --disable-host-loopback \\
198
+ --ready-fd=3 "$child_pid" ${SLIRP4NETNS_TAP_DEVICE} \\
199
+ 3>"$slirp_ready" >/dev/null 2>"$work/slirp_err" &
200
+ slirp_pid=$!
201
+ if ! IFS= read -r _ <"$slirp_ready"; then
202
+ echo "rootless-egress: slirp4netns failed to ready tap0" >&2
203
+ cat "$work/slirp_err" >&2 2>/dev/null || true
204
+ kill "$bwrap_pid" 2>/dev/null || true
205
+ wait "$bwrap_pid" 2>/dev/null || true
206
+ exit 71
207
+ fi
208
+
209
+ # Tell the child tap0 is up; it then exec's the real command.
210
+ printf ready > "$net_ready"
211
+
212
+ # Propagate the child's exit code. Under set -e a non-zero child makes wait
213
+ # itself fail, which fires the EXIT trap with that status (the trap is what
214
+ # actually propagates the code); the explicit "|| exit STATUS" makes the
215
+ # propagation path obvious to a reader rather than relying on the trap alone.
216
+ wait "$bwrap_pid" || exit $?
217
+ `;
218
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { shellQuote } from "./shell-quote";
3
+
4
+ describe("shellQuote", () => {
5
+ it("wraps a plain value in single quotes", () => {
6
+ expect(shellQuote("/tmp/x.nft")).toBe("'/tmp/x.nft'");
7
+ });
8
+
9
+ it("escapes embedded single quotes via the '\\'' idiom", () => {
10
+ // O'Brien -> 'O'\''Brien'
11
+ expect(shellQuote("O'Brien")).toBe(`'O'\\''Brien'`);
12
+ });
13
+
14
+ it("neutralizes shell metacharacters (spaces, ;, $, backticks)", () => {
15
+ const quoted = shellQuote("a b; rm -rf / $(whoami) `id`");
16
+ // Everything is inside one single-quoted span, so nothing expands.
17
+ expect(quoted.startsWith("'")).toBe(true);
18
+ expect(quoted.endsWith("'")).toBe(true);
19
+ expect(quoted).toContain("rm -rf / $(whoami)");
20
+ });
21
+
22
+ it("is idempotently round-trippable through sh", async () => {
23
+ const value = "weird 'value' with $pace; and `ticks`";
24
+ const proc = Bun.spawn({
25
+ cmd: ["sh", "-c", `printf %s ${shellQuote(value)}`],
26
+ stdout: "pipe",
27
+ });
28
+ const out = await new Response(proc.stdout).text();
29
+ await proc.exited;
30
+ expect(out).toBe(value);
31
+ });
32
+ });
@@ -0,0 +1,10 @@
1
+ /**
2
+ * POSIX single-quote shell escaping for argv fragments embedded into a
3
+ * generated `sh` script (the rootless egress launcher). Wraps the value in
4
+ * single quotes and escapes any embedded single quote via the standard
5
+ * `'\''` idiom, so the result is safe to splice into a `sh -c` body or an
6
+ * `exec` line regardless of its contents.
7
+ */
8
+ export function shellQuote(value: string): string {
9
+ return `'${value.replaceAll("'", String.raw`'\''`)}'`;
10
+ }