The 16 sandboxed provider tests in `tests/unit/ci-artifact-contract.test.ts` fail wherever unprivileged user namespaces are denied. bubblewrap is installed and answers `--version`, but `bwrap --unshare-net … -- /bin/true` exits 1 with `bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted`, and it fails the same way with no netns flag at all (`setting up uid map: Permission denied`), so this is the whole nested-userns capability and not one option. Sixteen assertion errors on every local run buried whatever else the file had to say. `scripts/lib/provider-sandbox-probe.ts` now runs a trivial command under the real isolation options — `runProviderInSandbox` spreads the same `PROVIDER_SANDBOX_ISOLATION_ARGUMENTS`, and a test fails if an isolation option is added to the run without the probe having to clear it. Capability is measured, never inferred from the binary existing or from a version string; both would pass here. An unusable sandbox means two different things in two places, so the decision is explicit. Locally it is an environment fact: the affected tests skip and carry the bwrap diagnostic as their skip note, visible as `↓ … [reason]`. In CI it is a regression — a security gate that silently stopped running is exactly what these tests exist to catch — so the same probe result fails the run through one guard test that says "the provider sandbox is unavailable" instead of sixteen assertion errors. CI is detected with `CI === "true"` via a new `isCiRun`, sharing the predicate that already gates `ciBuildEnvironmentFailures`. The workflow sets it at the top-level `env:` block, so it holds in every job; `CI_RUN_ID` and its `GITEA_`/`GITHUB_` fallbacks are declared only by the release-tier provider jobs and are absent from the merge gates that run this file, so keying on them would have left the CI branch permanently dead. The three `it.each` groups become `it.for` because only `.for` passes the test context, which is what carries the skip note. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
110 lines
4.1 KiB
TypeScript
110 lines
4.1 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import { existsSync } from "node:fs";
|
|
|
|
import { isCiRun } from "./build-environment.ts";
|
|
|
|
export const PROVIDER_SANDBOX_BINARY = "/usr/bin/bwrap";
|
|
|
|
/**
|
|
* The isolation options `runProviderInSandbox` opens its bubblewrap argument
|
|
* vector with. That function spreads this exact list, so the probe below can
|
|
* never clear a weaker sandbox than the one the provider actually runs in — and
|
|
* `tests/unit/provider-sandbox-probe.test.ts` fails if an isolation flag is
|
|
* added to the run without being added here.
|
|
*/
|
|
export const PROVIDER_SANDBOX_ISOLATION_ARGUMENTS: readonly string[] = Object.freeze([
|
|
"--die-with-parent", "--new-session", "--as-pid-1",
|
|
"--unshare-pid", "--unshare-ipc", "--unshare-uts", "--unshare-net",
|
|
"--dev", "/dev", "--remount-ro", "/dev",
|
|
"--proc", "/proc", "--remount-ro", "/proc",
|
|
]);
|
|
|
|
/**
|
|
* The read-only roots the real run binds before it can execute anything. The
|
|
* probe binds the same set so `/bin/true` resolves the way a provider command
|
|
* would.
|
|
*/
|
|
const PROVIDER_SANDBOX_PROBE_BINDINGS: readonly string[] = Object.freeze([
|
|
"/usr", "/bin", "/lib", "/lib64",
|
|
]);
|
|
|
|
export const PROVIDER_SANDBOX_UNAVAILABLE = "the provider sandbox is unavailable";
|
|
|
|
export type ProviderSandboxProbe =
|
|
| Readonly<{ usable: true }>
|
|
| Readonly<{ usable: false; reason: string }>;
|
|
|
|
export type ProviderSandboxDecision =
|
|
| Readonly<{ outcome: "run" }>
|
|
| Readonly<{ outcome: "skip"; reason: string }>
|
|
| Readonly<{ outcome: "fail"; reason: string }>;
|
|
|
|
export function providerSandboxProbeArguments(): string[] {
|
|
return [
|
|
...PROVIDER_SANDBOX_ISOLATION_ARGUMENTS,
|
|
...PROVIDER_SANDBOX_PROBE_BINDINGS.flatMap((source) =>
|
|
existsSync(source) ? ["--ro-bind", source, source] : []),
|
|
"--", "/bin/true",
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Runs a trivial command under the real isolation options and reports what
|
|
* happened. Capability is never inferred from the binary being installed or
|
|
* from a kernel/version string: on a host with
|
|
* `kernel.apparmor_restrict_unprivileged_userns=1` the binary exists, reports a
|
|
* version, and still cannot create the user namespace it needs.
|
|
*/
|
|
export function probeProviderSandbox(
|
|
binary: string = PROVIDER_SANDBOX_BINARY,
|
|
): ProviderSandboxProbe {
|
|
const result = spawnSync(binary, providerSandboxProbeArguments(), {
|
|
encoding: "utf8",
|
|
timeout: 20_000,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
if (result.error) {
|
|
const code = (result.error as NodeJS.ErrnoException).code;
|
|
return {
|
|
usable: false,
|
|
reason: code === "ENOENT"
|
|
? `${binary} is not installed`
|
|
: `${binary} could not be executed: ${result.error.message}`,
|
|
};
|
|
}
|
|
if (result.status === 0) return { usable: true };
|
|
return { usable: false, reason: sandboxFailureReason(binary, result) };
|
|
}
|
|
|
|
function sandboxFailureReason(
|
|
binary: string,
|
|
result: Readonly<{ status: number | null; signal: NodeJS.Signals | null; stderr: string }>,
|
|
): string {
|
|
const diagnostics = [...String(result.stderr ?? "")
|
|
.matchAll(/^(?:bwrap|prlimit):\s.*$/gmu)].map(([line]) => line);
|
|
const detail = diagnostics.length > 0
|
|
? diagnostics.join("; ")
|
|
: String(result.stderr ?? "").trim() || "no diagnostic output";
|
|
const exit = result.signal ? `signal=${result.signal}` : `exit=${result.status}`;
|
|
return `${binary} could not start an isolated sandbox (${exit}): ${detail}`;
|
|
}
|
|
|
|
/**
|
|
* Locally an unusable sandbox is an environment fact and the sandboxed suites
|
|
* are skipped with the reason attached. In CI it is a regression — a security
|
|
* gate that silently stopped running is exactly what those suites exist to
|
|
* catch — so the same probe result fails the run instead.
|
|
*/
|
|
export function providerSandboxDecision(
|
|
probe: ProviderSandboxProbe,
|
|
environment: Readonly<Record<string, string | undefined>>,
|
|
): ProviderSandboxDecision {
|
|
if (probe.usable) return { outcome: "run" };
|
|
const reason = `${PROVIDER_SANDBOX_UNAVAILABLE}: ${probe.reason}`;
|
|
return isCiRun(environment) ? { outcome: "fail", reason } : { outcome: "skip", reason };
|
|
}
|
|
|
|
export function assertProviderSandboxUsable(decision: ProviderSandboxDecision): void {
|
|
if (decision.outcome === "fail") throw new Error(decision.reason);
|
|
}
|