@checkstack/backend-api 0.20.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.
- package/CHANGELOG.md +151 -0
- package/package.json +12 -11
- package/src/auth-strategy.ts +6 -3
- package/src/bearer-token.ts +13 -0
- package/src/collector-strategy.ts +9 -0
- package/src/config-versioning.test.ts +227 -0
- package/src/config-versioning.ts +172 -0
- package/src/core-services.ts +14 -0
- package/src/esm-script-runner.test.ts +55 -16
- package/src/esm-script-runner.ts +212 -55
- package/src/index.ts +3 -0
- package/src/render-templatable-config.test.ts +168 -0
- package/src/render-templatable-config.ts +193 -0
- package/src/schema-utils.ts +3 -0
- package/src/script-sandbox/capabilities.test.ts +122 -0
- package/src/script-sandbox/capabilities.ts +372 -0
- package/src/script-sandbox/capped-output.test.ts +116 -0
- package/src/script-sandbox/capped-output.ts +172 -0
- package/src/script-sandbox/env-guard.test.ts +105 -0
- package/src/script-sandbox/env-guard.ts +129 -0
- package/src/script-sandbox/filesystem.test.ts +437 -0
- package/src/script-sandbox/filesystem.ts +514 -0
- package/src/script-sandbox/forkbomb.it.test.ts +121 -0
- package/src/script-sandbox/global-default.test.ts +161 -0
- package/src/script-sandbox/global-default.ts +100 -0
- package/src/script-sandbox/index.ts +14 -0
- package/src/script-sandbox/network.test.ts +356 -0
- package/src/script-sandbox/network.ts +373 -0
- package/src/script-sandbox/observability.test.ts +210 -0
- package/src/script-sandbox/observability.ts +168 -0
- package/src/script-sandbox/output-truncation.test.ts +53 -0
- package/src/script-sandbox/output-truncation.ts +69 -0
- package/src/script-sandbox/policy.test.ts +189 -0
- package/src/script-sandbox/policy.ts +220 -0
- package/src/script-sandbox/provider.test.ts +61 -0
- package/src/script-sandbox/provider.ts +134 -0
- package/src/script-sandbox/readiness.test.ts +80 -0
- package/src/script-sandbox/readiness.ts +117 -0
- package/src/script-sandbox/report.ts +88 -0
- package/src/script-sandbox/rootless-egress.it.test.ts +86 -0
- package/src/script-sandbox/rootless-egress.test.ts +99 -0
- package/src/script-sandbox/rootless-egress.ts +218 -0
- package/src/script-sandbox/shell-quote.test.ts +32 -0
- package/src/script-sandbox/shell-quote.ts +10 -0
- package/src/script-sandbox/wrapper.test.ts +1194 -0
- package/src/script-sandbox/wrapper.ts +714 -0
- package/src/shell-script-runner.test.ts +243 -0
- package/src/shell-script-runner.ts +210 -45
- package/src/zod-config.test.ts +60 -0
- package/src/zod-config.ts +38 -14
- 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
|
+
}
|