@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,220 @@
1
+ import {
2
+ sandboxPolicySchema,
3
+ type SandboxPolicy,
4
+ type SandboxPolicyInput,
5
+ } from "@checkstack/common";
6
+
7
+ /**
8
+ * OS-level sandbox policy for the shared user-script runners.
9
+ *
10
+ * The policy SHAPE (the zod `sandboxPolicySchema` and its sub-schemas) is the
11
+ * single source of truth in `@checkstack/common` so it can be shared by the
12
+ * RPC contract (admin read/write endpoints) and the satellite WS protocol
13
+ * (policy relay) without a backward dependency on this backend package. This
14
+ * module re-exports that schema and layers the runtime-only
15
+ * helpers on top: the shipped default profile, the env-seeded low-priv UID/GID
16
+ * resolution, and `mergeSandboxPolicy`.
17
+ *
18
+ * The two runners (`shell-script-runner.ts`, `esm-script-runner.ts`) parse a
19
+ * `SandboxPolicyInput` against {@link sandboxPolicySchema}, reconcile it against
20
+ * detected host capabilities, and build the extra `Bun.spawn` options from the
21
+ * result.
22
+ */
23
+
24
+ export {
25
+ onUnavailableSchema,
26
+ resourceLimitsSchema,
27
+ filesystemPolicySchema,
28
+ networkPolicySchema,
29
+ privilegePolicySchema,
30
+ sandboxPolicySchema,
31
+ type OnUnavailable,
32
+ type ResourceLimits,
33
+ type FilesystemPolicy,
34
+ type NetworkPolicy,
35
+ type PrivilegePolicy,
36
+ type SandboxPolicy,
37
+ type SandboxPolicyInput,
38
+ } from "@checkstack/common";
39
+
40
+ /**
41
+ * The shipped global default profile (DP) - plan §6.1.
42
+ *
43
+ * This is the runner's base default, so every script run is hardened out of the
44
+ * box. It is SECURE-by-default: egress is denied until an operator adds
45
+ * allowlist entries (network `allowlist` with an empty `allow` list), temp-file
46
+ * writes are confined to the per-run scratch dir, the reconciled managed-package
47
+ * tree is bound read-only, and fork-bombs / OOM / disk-fill plus reads of
48
+ * arbitrary host paths (on a wrapper host) are blocked. Ordinary outbound
49
+ * `fetch` does NOT work by default - the operator must allowlist the
50
+ * destinations a script may reach.
51
+ *
52
+ * FAIL-CLOSED by default (`onUnavailable: "fail"`): if any requested layer
53
+ * cannot be enforced on the host, the run is REFUSED
54
+ * ({@link SandboxUnavailableError} -> clean `exitCode: -1`, NO unsandboxed
55
+ * spawn) rather than silently degrading to a weaker subset. This makes the
56
+ * shipped global default fail safe: a malicious script never slips through on a
57
+ * host that is missing a sandbox primitive. The official container images are
58
+ * built to support every layer (bubblewrap + unprivileged user namespaces +
59
+ * slirp4netns rootless egress + util-linux rlimits + a dedicated non-root UID),
60
+ * so the secure default WORKS out of the box there - see the container
61
+ * verification in `docs/.../script-sandboxing` and the in-container probe. An
62
+ * operator running on a host that genuinely cannot enforce a layer can switch
63
+ * the global policy to `degrade` (drop to the portable subset and surface it)
64
+ * via the admin settings page; that is an explicit, audited opt-out, never a
65
+ * silent one. Each run's actual enforcement is reported via the
66
+ * {@link EffectiveSandbox} report.
67
+ *
68
+ * The `privilege.uid`/`gid` are left UNSET here; the dedicated low-priv target
69
+ * is resolved at runtime by {@link resolveDefaultSandboxProfile} (from
70
+ * `CHECKSTACK_SANDBOX_UID` / `CHECKSTACK_SANDBOX_GID`), so the constant stays a
71
+ * pure value and the env read happens once per process where it is used.
72
+ */
73
+ export const DEFAULT_SANDBOX_PROFILE: SandboxPolicy = sandboxPolicySchema.parse({
74
+ enabled: true,
75
+ // Fail-closed: refuse the run if a layer can't be enforced, rather than
76
+ // silently dropping to a weaker subset. The shipped containers support every
77
+ // layer so this is a working default there; weak hosts can opt into
78
+ // "degrade" explicitly via the admin settings page.
79
+ onUnavailable: "fail",
80
+ resources: {
81
+ cpuSeconds: 60,
82
+ memoryBytes: 512 * 1024 * 1024,
83
+ maxOpenFiles: 1024,
84
+ maxProcesses: 256,
85
+ maxOutputBytes: 5 * 1024 * 1024,
86
+ maxFileSizeBytes: 256 * 1024 * 1024,
87
+ },
88
+ filesystem: {
89
+ mode: "scratch-plus-ro",
90
+ },
91
+ network: {
92
+ // Secure-by-default: allowlist with an EMPTY allow list = deny egress until
93
+ // an operator adds entries. Link-local / cloud-metadata IPs stay blocked.
94
+ mode: "allowlist",
95
+ allow: [],
96
+ denyLinkLocalAndMetadata: true,
97
+ },
98
+ privilege: {
99
+ mode: "drop-to-uid",
100
+ },
101
+ });
102
+
103
+ /** Env var naming the dedicated low-privilege UID to drop script runs to. */
104
+ export const SANDBOX_UID_ENV = "CHECKSTACK_SANDBOX_UID";
105
+ /** Env var naming the dedicated low-privilege GID to drop script runs to. */
106
+ export const SANDBOX_GID_ENV = "CHECKSTACK_SANDBOX_GID";
107
+
108
+ /**
109
+ * Parse a non-negative integer from an env value, or `undefined` when unset /
110
+ * malformed. A malformed value is treated as "not configured" (the privilege
111
+ * drop then degrades to `inherit` and surfaces it) rather than throwing - a
112
+ * typo in an operator env var must not crash every script run.
113
+ */
114
+ function readNonNegativeIntEnv(name: string): number | undefined {
115
+ const raw = process.env[name];
116
+ if (raw === undefined || raw.trim() === "") {
117
+ return undefined;
118
+ }
119
+ const parsed = Number(raw);
120
+ if (!Number.isInteger(parsed) || parsed < 0) {
121
+ return undefined;
122
+ }
123
+ return parsed;
124
+ }
125
+
126
+ /**
127
+ * Resolve the shipped {@link DEFAULT_SANDBOX_PROFILE} with the dedicated
128
+ * low-privilege UID/GID seeded from the environment
129
+ * (`CHECKSTACK_SANDBOX_UID` / `CHECKSTACK_SANDBOX_GID`).
130
+ *
131
+ * This is the value the runner uses as its base default. When no UID is
132
+ * configured (the common dev / macOS case) the privilege layer keeps
133
+ * `drop-to-uid` but, with no target, the wrapper degrades it to `inherit` and
134
+ * surfaces it - never a hard failure. A configured GID without a UID is
135
+ * ignored (a GID drop without a UID drop is not meaningful here).
136
+ *
137
+ * Pure read of `process.env`; safe to call per run (no spawning, no I/O).
138
+ */
139
+ export function resolveDefaultSandboxProfile(): SandboxPolicy {
140
+ const uid = readNonNegativeIntEnv(SANDBOX_UID_ENV);
141
+ if (uid === undefined) {
142
+ return DEFAULT_SANDBOX_PROFILE;
143
+ }
144
+ const gid = readNonNegativeIntEnv(SANDBOX_GID_ENV);
145
+ return {
146
+ ...DEFAULT_SANDBOX_PROFILE,
147
+ privilege: {
148
+ ...DEFAULT_SANDBOX_PROFILE.privilege,
149
+ uid,
150
+ ...(gid === undefined ? {} : { gid }),
151
+ },
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Deep-merge a partial per-item override on top of a base policy. The base is
157
+ * the fully-resolved global default (e.g. {@link DEFAULT_SANDBOX_PROFILE}, or
158
+ * the durable global default read from settings); the override is a partial
159
+ * `SandboxPolicyInput` parsed from a per-check /
160
+ * per-action `sandbox` config field. Per-item values win per-field, and only
161
+ * the fields the override actually sets are touched - an override that only
162
+ * sets `{ network: { mode: "deny" } }` must not re-widen the other layers
163
+ * back to the bare zod field defaults.
164
+ *
165
+ * Returns a validated {@link SandboxPolicy}.
166
+ */
167
+ export function mergeSandboxPolicy({
168
+ base,
169
+ override,
170
+ }: {
171
+ base: SandboxPolicy;
172
+ override?: SandboxPolicyInput;
173
+ }): SandboxPolicy {
174
+ if (override === undefined) {
175
+ return base;
176
+ }
177
+ // Validate the override against the schema FIRST so its values and bounds
178
+ // are enforced, then overlay only the keys the caller actually provided
179
+ // (defined keys only) so an unset layer keeps the base value rather than
180
+ // being re-widened to the bare zod field default.
181
+ sandboxPolicySchema.parse(override);
182
+
183
+ const merged: SandboxPolicy = {
184
+ enabled: pick(override.enabled, base.enabled),
185
+ onUnavailable: pick(override.onUnavailable, base.onUnavailable),
186
+ resources: overlayDefined(base.resources, override.resources),
187
+ filesystem: overlayDefined(base.filesystem, override.filesystem),
188
+ network: overlayDefined(base.network, override.network),
189
+ privilege: overlayDefined(base.privilege, override.privilege),
190
+ };
191
+
192
+ // Re-validate the merged shape (cheap; keeps bounds + strict enforcement).
193
+ return sandboxPolicySchema.parse(merged);
194
+ }
195
+
196
+ /** Return `value` when defined, otherwise `fallback`. */
197
+ function pick<T>(value: T | undefined, fallback: T): T {
198
+ return value === undefined ? fallback : value;
199
+ }
200
+
201
+ /**
202
+ * Overlay only the *defined* keys of `over` onto `base`. An explicit
203
+ * `undefined` in `over` does not clobber the base value.
204
+ */
205
+ function overlayDefined<T extends Record<string, unknown>>(
206
+ base: T,
207
+ over: Partial<T> | undefined,
208
+ ): T {
209
+ if (over === undefined) {
210
+ return base;
211
+ }
212
+ const result: T = { ...base };
213
+ for (const key of Object.keys(over) as Array<keyof T>) {
214
+ const value = over[key];
215
+ if (value !== undefined) {
216
+ result[key] = value as T[keyof T];
217
+ }
218
+ }
219
+ return result;
220
+ }
@@ -0,0 +1,61 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import {
3
+ FAIL_CLOSED_SANDBOX_PROFILE,
4
+ registerSandboxPolicyProvider,
5
+ resetSandboxPolicyProvider,
6
+ resolveActiveSandboxPolicy,
7
+ } from "./provider";
8
+ import { resolveDefaultSandboxProfile, type SandboxPolicy } from "./policy";
9
+
10
+ describe("script-sandbox policy provider", () => {
11
+ afterEach(() => {
12
+ resetSandboxPolicyProvider();
13
+ });
14
+
15
+ it("fails closed (deny egress, scratch-only) when no provider is registered", async () => {
16
+ resetSandboxPolicyProvider();
17
+ const { policy, failedClosed } = await resolveActiveSandboxPolicy();
18
+ expect(failedClosed).toBe(true);
19
+ expect(policy).toEqual(FAIL_CLOSED_SANDBOX_PROFILE);
20
+ expect(policy.network.mode).toBe("deny");
21
+ expect(policy.filesystem.mode).toBe("scratch-plus-ro");
22
+ expect(policy.privilege.mode).toBe("drop-to-uid");
23
+ expect(policy.enabled).toBe(true);
24
+ });
25
+
26
+ it("returns the registered provider's policy", async () => {
27
+ const provided: SandboxPolicy = resolveDefaultSandboxProfile();
28
+ registerSandboxPolicyProvider(async () => provided);
29
+ const { policy, failedClosed } = await resolveActiveSandboxPolicy();
30
+ expect(failedClosed).toBe(false);
31
+ expect(policy).toEqual(provided);
32
+ });
33
+
34
+ it("fails closed when the provider throws (does not widen the sandbox)", async () => {
35
+ registerSandboxPolicyProvider(async () => {
36
+ throw new Error("transient db error");
37
+ });
38
+ const { policy, failedClosed } = await resolveActiveSandboxPolicy();
39
+ expect(failedClosed).toBe(true);
40
+ expect(policy).toEqual(FAIL_CLOSED_SANDBOX_PROFILE);
41
+ });
42
+
43
+ it("fail-closed network is never unrestricted", async () => {
44
+ expect(FAIL_CLOSED_SANDBOX_PROFILE.network.mode).not.toBe("unrestricted");
45
+ expect(FAIL_CLOSED_SANDBOX_PROFILE.network.allow).toEqual([]);
46
+ expect(FAIL_CLOSED_SANDBOX_PROFILE.network.denyLinkLocalAndMetadata).toBe(
47
+ true,
48
+ );
49
+ });
50
+
51
+ it("last registered provider wins (single cluster-wide value)", async () => {
52
+ registerSandboxPolicyProvider(async () => resolveDefaultSandboxProfile());
53
+ const second: SandboxPolicy = {
54
+ ...resolveDefaultSandboxProfile(),
55
+ network: { mode: "deny", allow: [], denyLinkLocalAndMetadata: true },
56
+ };
57
+ registerSandboxPolicyProvider(async () => second);
58
+ const { policy } = await resolveActiveSandboxPolicy();
59
+ expect(policy.network.mode).toBe("deny");
60
+ });
61
+ });
@@ -0,0 +1,134 @@
1
+ import { sandboxPolicySchema, type SandboxPolicy } from "./policy";
2
+
3
+ /**
4
+ * Process-wide active sandbox policy provider.
5
+ *
6
+ * The script runners (`shell-script-runner.ts`, `esm-script-runner.ts`) no
7
+ * longer accept a per-run `sandbox` argument: policy is GLOBAL-only. Each run
8
+ * resolves the active policy through {@link resolveActiveSandboxPolicy}, which
9
+ * awaits the provider registered once at platform startup
10
+ * (see the script plugins' `init` and the satellite runtime).
11
+ *
12
+ * Why a provider (not a constant): the durable global default lives in the
13
+ * shared Postgres `plugin_configs` table, reachable only where a
14
+ * `ConfigService` is wired (the core pod). The runner module itself has no DB
15
+ * handle, so startup hands it a closure that reads the durable value. This keeps
16
+ * the global policy a single cluster-wide value (state-and-scale rule 2: the
17
+ * same answer on every pod that has wired the same `ConfigService`-backed
18
+ * provider), while the runner stays dependency-free.
19
+ *
20
+ * Fail-closed (security core): if NO provider is registered, or the registered
21
+ * provider throws, {@link resolveActiveSandboxPolicy} returns the
22
+ * {@link FAIL_CLOSED_SANDBOX_PROFILE} — the most restrictive safe policy (no
23
+ * egress, scratch filesystem + read-only managed packages, privilege drop) —
24
+ * NOT the permissive shipped
25
+ * default. A misconfigured or un-wired runtime therefore denies, it does not
26
+ * silently run unsandboxed.
27
+ */
28
+
29
+ /** Reason string attached to the synthetic downgrade when failing closed. */
30
+ export const FAIL_CLOSED_DOWNGRADE_REASON =
31
+ "no global sandbox policy provider was registered (or it failed); fell back " +
32
+ "to the most restrictive fail-closed policy (deny egress, scratch filesystem " +
33
+ "with read-only managed packages, privilege drop) instead of running " +
34
+ "unsandboxed";
35
+
36
+ /**
37
+ * The fail-closed policy. Derived from the shipped safe default's resource caps
38
+ * and privilege drop, but with the network locked to `deny` (no egress at all)
39
+ * and the filesystem locked to `scratch-plus-ro` — the per-run scratch dir is
40
+ * writable and the reconciled managed-package tree
41
+ * (`resolutionRoot/node_modules`) is bound READ-ONLY so a script can still
42
+ * `import` its allowlisted packages, while the rest of the host FS stays
43
+ * hidden. `onUnavailable` stays `degrade` so an unenforceable layer on a weak
44
+ * host still RUNS the most restrictive subset it can (and surfaces the gap)
45
+ * rather than refusing every run on a host that lacks the strong primitives —
46
+ * refusing all runs would be a worse, silent-everything-breaks failure mode
47
+ * than enforcing the portable subset.
48
+ */
49
+ export const FAIL_CLOSED_SANDBOX_PROFILE: SandboxPolicy = sandboxPolicySchema.parse({
50
+ enabled: true,
51
+ onUnavailable: "degrade",
52
+ resources: {
53
+ cpuSeconds: 60,
54
+ memoryBytes: 512 * 1024 * 1024,
55
+ maxOpenFiles: 1024,
56
+ maxProcesses: 256,
57
+ maxOutputBytes: 5 * 1024 * 1024,
58
+ maxFileSizeBytes: 256 * 1024 * 1024,
59
+ },
60
+ filesystem: {
61
+ mode: "scratch-plus-ro",
62
+ },
63
+ network: {
64
+ mode: "deny",
65
+ allow: [],
66
+ denyLinkLocalAndMetadata: true,
67
+ },
68
+ privilege: {
69
+ mode: "drop-to-uid",
70
+ },
71
+ });
72
+
73
+ /** A registered provider for the active global sandbox policy. */
74
+ export type SandboxPolicyProvider = () => Promise<SandboxPolicy>;
75
+
76
+ /**
77
+ * The result of resolving the active policy: the policy plus whether the
78
+ * resolution fell back to {@link FAIL_CLOSED_SANDBOX_PROFILE}. The runners use
79
+ * `failedClosed` to inject a synthetic downgrade into the run's
80
+ * EffectiveSandbox report so the fallback is never silent.
81
+ */
82
+ export interface ResolvedActiveSandboxPolicy {
83
+ policy: SandboxPolicy;
84
+ /** True when no provider was registered or the provider threw. */
85
+ failedClosed: boolean;
86
+ }
87
+
88
+ // Module-level singleton. The active global policy is a single cluster-wide
89
+ // value, NOT per-run state — every run on this process wants the same answer —
90
+ // so a module-level provider closure is scale-correct. (Per-run state would be
91
+ // a bug; there is none here.)
92
+ let activeProvider: SandboxPolicyProvider | undefined;
93
+
94
+ /**
95
+ * Register the process-wide active sandbox policy provider. Called ONCE at
96
+ * platform startup where a `ConfigService` is available (the core pod) or with
97
+ * the relayed cluster policy (the satellite runtime). Re-registering replaces
98
+ * the previous provider (idempotent for the same closure; last writer wins),
99
+ * which is harmless because the value is a single cluster-wide global.
100
+ */
101
+ export function registerSandboxPolicyProvider(
102
+ provider: SandboxPolicyProvider,
103
+ ): void {
104
+ activeProvider = provider;
105
+ }
106
+
107
+ /**
108
+ * Clear the registered provider. Test-only helper so a test can assert the
109
+ * fail-closed path and reset between cases. Not part of the production flow.
110
+ */
111
+ export function resetSandboxPolicyProvider(): void {
112
+ activeProvider = undefined;
113
+ }
114
+
115
+ /**
116
+ * Resolve the active global sandbox policy for a run.
117
+ *
118
+ * - Provider registered and succeeds -> its policy, `failedClosed: false`.
119
+ * - No provider, or the provider throws -> {@link FAIL_CLOSED_SANDBOX_PROFILE},
120
+ * `failedClosed: true`. NEVER the permissive shipped default.
121
+ */
122
+ export async function resolveActiveSandboxPolicy(): Promise<ResolvedActiveSandboxPolicy> {
123
+ if (activeProvider === undefined) {
124
+ return { policy: FAIL_CLOSED_SANDBOX_PROFILE, failedClosed: true };
125
+ }
126
+ try {
127
+ const policy = await activeProvider();
128
+ return { policy, failedClosed: false };
129
+ } catch {
130
+ // A provider that throws (e.g. a transient DB error reading the durable
131
+ // default) must NOT widen the sandbox: fail closed, not open.
132
+ return { policy: FAIL_CLOSED_SANDBOX_PROFILE, failedClosed: true };
133
+ }
134
+ }
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ assessSandboxHostReadiness,
4
+ formatSandboxReadinessBanner,
5
+ } from "./readiness";
6
+ import type { SandboxCapabilities } from "./capabilities";
7
+
8
+ /** A fully-capable Linux host (every OS layer enforceable). */
9
+ const LINUX_READY: SandboxCapabilities = {
10
+ platform: "linux",
11
+ euidIsRoot: false,
12
+ hasPrlimit: true,
13
+ rlimitNative: true,
14
+ wrapper: "bwrap",
15
+ userNamespaces: true,
16
+ netNamespaces: true,
17
+ userNsCreatable: true,
18
+ netEgressIface: null,
19
+ netEgressAddressing: null,
20
+ netEgressRootless: true,
21
+ };
22
+
23
+ describe("assessSandboxHostReadiness", () => {
24
+ it("reports enforceable with no guidance on a fully-capable Linux host", () => {
25
+ const r = assessSandboxHostReadiness({ caps: LINUX_READY });
26
+ expect(r.enforceable).toBe(true);
27
+ expect(r.reasons).toEqual([]);
28
+ expect(r.guidance).toBe("");
29
+ });
30
+
31
+ it("is NOT enforceable on darwin and guides to Docker + degrade", () => {
32
+ const r = assessSandboxHostReadiness({
33
+ caps: { ...LINUX_READY, platform: "darwin" },
34
+ });
35
+ expect(r.enforceable).toBe(false);
36
+ expect(r.platform).toBe("darwin");
37
+ expect(r.reasons.join(" ")).toContain("requires Linux");
38
+ // Both supported dev paths must be surfaced.
39
+ expect(r.guidance).toContain("Docker");
40
+ expect(r.guidance).toContain('"degrade"');
41
+ // It must NOT claim to relax enforcement on its own.
42
+ expect(r.guidance).toContain("REFUSED");
43
+ });
44
+
45
+ it("is NOT enforceable on a Linux host where the live userns probe fails", () => {
46
+ const r = assessSandboxHostReadiness({
47
+ caps: { ...LINUX_READY, userNsCreatable: false, netNamespaces: false },
48
+ });
49
+ expect(r.enforceable).toBe(false);
50
+ expect(r.reasons.join(" ")).toContain("user + net namespaces");
51
+ expect(r.guidance).toContain("degrade");
52
+ });
53
+
54
+ it("flags a missing wrapper and missing prlimit on Linux", () => {
55
+ const r = assessSandboxHostReadiness({
56
+ caps: { ...LINUX_READY, wrapper: null, rlimitNative: false },
57
+ });
58
+ expect(r.enforceable).toBe(false);
59
+ expect(r.reasons.join(" ")).toContain("namespace-capable wrapper");
60
+ expect(r.reasons.join(" ")).toContain("prlimit");
61
+ });
62
+ });
63
+
64
+ describe("formatSandboxReadinessBanner", () => {
65
+ it("returns an empty string when the host is enforceable", () => {
66
+ const readiness = assessSandboxHostReadiness({ caps: LINUX_READY });
67
+ expect(formatSandboxReadinessBanner({ readiness })).toBe("");
68
+ });
69
+
70
+ it("frames the guidance with rules + a header when not enforceable", () => {
71
+ const readiness = assessSandboxHostReadiness({
72
+ caps: { ...LINUX_READY, platform: "darwin" },
73
+ });
74
+ const banner = formatSandboxReadinessBanner({ readiness });
75
+ expect(banner).toContain("SCRIPT SANDBOX: OS-LEVEL ISOLATION UNAVAILABLE");
76
+ expect(banner).toContain("=".repeat(78));
77
+ // The full guidance is still embedded in the banner.
78
+ expect(banner).toContain(readiness.guidance);
79
+ });
80
+ });
@@ -0,0 +1,117 @@
1
+ import {
2
+ detectSandboxCapabilities,
3
+ type SandboxCapabilities,
4
+ } from "./capabilities";
5
+
6
+ /**
7
+ * Whether THIS host can actually enforce the OS-level sandbox layers, plus
8
+ * developer-facing guidance when it cannot.
9
+ *
10
+ * The OS-level isolation (filesystem / network / privilege / resources) is
11
+ * built on LINUX kernel primitives - unprivileged user + net namespaces via
12
+ * `bubblewrap`, `prlimit` rlimits, `nftables` / `slirp4netns` egress. On
13
+ * macOS / Windows (and on a Linux host missing the primitives or with
14
+ * unprivileged user namespaces blocked) none of that is available, so under
15
+ * the secure fail-closed default policy (`onUnavailable: "fail"`) the runners
16
+ * correctly REFUSE every run rather than execute it unsandboxed.
17
+ *
18
+ * This assessment lets a process surface that situation ONCE at startup with
19
+ * actionable guidance, instead of every script run failing with an opaque
20
+ * "sandbox unavailable" error. It does NOT change the policy or relax
21
+ * enforcement - the durable global policy remains the single source of truth.
22
+ */
23
+ export interface SandboxHostReadiness {
24
+ /**
25
+ * True when the host can enforce the OS-level sandbox layers (Linux with a
26
+ * namespace-capable wrapper, creatable user+net namespaces, and `prlimit`).
27
+ */
28
+ enforceable: boolean;
29
+ /** The detected host platform. */
30
+ platform: SandboxCapabilities["platform"];
31
+ /**
32
+ * Human-readable reasons the OS layer cannot be enforced. Empty when
33
+ * {@link enforceable} is true.
34
+ */
35
+ reasons: string[];
36
+ /**
37
+ * Multi-line developer/operator guidance: why runs are refused under the
38
+ * fail-closed default, and the two supported local-development paths
39
+ * (Docker prod-parity, or an explicit `degrade` policy). Empty string when
40
+ * {@link enforceable} is true.
41
+ */
42
+ guidance: string;
43
+ }
44
+
45
+ /**
46
+ * Assess whether the host can enforce the OS-level sandbox, and build the
47
+ * developer guidance shown when it cannot. Pure over the detected (cached)
48
+ * capabilities; pass `caps` to test specific hosts.
49
+ */
50
+ export function assessSandboxHostReadiness({
51
+ caps = detectSandboxCapabilities(),
52
+ }: { caps?: SandboxCapabilities } = {}): SandboxHostReadiness {
53
+ const reasons: string[] = [];
54
+
55
+ if (caps.platform === "linux") {
56
+ if (caps.wrapper === null) {
57
+ reasons.push(
58
+ "no namespace-capable wrapper found on PATH (install bubblewrap, or nsjail)",
59
+ );
60
+ }
61
+ if (!caps.userNsCreatable) {
62
+ reasons.push(
63
+ "unprivileged user + net namespaces cannot be created on this host (kernel toggle off, or blocked by the container seccomp profile - see the bundled seccomp profile + /proc unmask requirement)",
64
+ );
65
+ }
66
+ if (!caps.rlimitNative) {
67
+ reasons.push("prlimit (util-linux) is not available for resource limits");
68
+ }
69
+ } else {
70
+ reasons.push(
71
+ `OS-level isolation requires Linux kernel primitives (bubblewrap namespaces, prlimit, nftables); this host is ${caps.platform}`,
72
+ );
73
+ }
74
+
75
+ const enforceable = reasons.length === 0;
76
+ if (enforceable) {
77
+ return { enforceable, platform: caps.platform, reasons, guidance: "" };
78
+ }
79
+
80
+ const guidance = [
81
+ "Script sandbox: OS-level isolation is UNAVAILABLE on this host:",
82
+ ...reasons.map((reason) => ` - ${reason}`),
83
+ 'Under the secure fail-closed default policy (onUnavailable: "fail") script runs are REFUSED rather than run unsandboxed. For local development you have two supported options:',
84
+ " 1. Prod-parity enforcement: run the runtime inside the Linux sandbox container (docker-compose.yml ships the required seccomp profile + /proc unmask). On macOS / Windows this uses Docker Desktop's Linux VM and enforces exactly as production does.",
85
+ ' 2. Fast native dev: set the global Script Sandbox policy to "degrade" in Admin -> Settings -> Script Sandbox. Scripts then run with the PORTABLE SUBSET (wall-clock timeout + output truncation, NO OS isolation) - acceptable for your own dev scripts, never a security boundary.',
86
+ "Production MUST run on Linux. See docs: developer-guide/security/script-sandbox (Local development).",
87
+ ].join("\n");
88
+
89
+ return { enforceable, platform: caps.platform, reasons, guidance };
90
+ }
91
+
92
+ /**
93
+ * Frame the readiness guidance in a high-visibility banner so it stands out in
94
+ * terminal scrollback even when a multi-process dev runner (e.g. Bun's
95
+ * `--filter` view, which elides per-task output) interleaves or collapses logs.
96
+ * Returns the empty string when the host can enforce the sandbox (nothing to
97
+ * surface).
98
+ */
99
+ export function formatSandboxReadinessBanner({
100
+ readiness,
101
+ }: {
102
+ readiness: SandboxHostReadiness;
103
+ }): string {
104
+ if (readiness.enforceable) return "";
105
+ const rule = "=".repeat(78);
106
+ // The headline is the FIRST line so it carries the logger's level prefix
107
+ // (e.g. winston `warn:`) and therefore surfaces as a real entry in a log
108
+ // viewer's alerts/warnings, instead of an empty leading line. The rules +
109
+ // detailed guidance follow as continuation lines.
110
+ return [
111
+ "SCRIPT SANDBOX: OS-LEVEL ISOLATION UNAVAILABLE ON THIS HOST",
112
+ rule,
113
+ readiness.guidance,
114
+ rule,
115
+ "",
116
+ ].join("\n");
117
+ }