From ba00cd31f6ca75cb7883735d9c8775a884ce449b Mon Sep 17 00:00:00 2001 From: Emmanuel Preiss Date: Tue, 6 Oct 2026 14:17:03 -0300 Subject: [PATCH] fix(sandbox): reject missing configured shell images in preflight --- docs/sandbox-hosting.md | 15 ++++++++ scripts/sandbox-doctor.test.ts | 70 +++++++++++++++++++++++++++++++++- scripts/sandbox-doctor.ts | 42 +++++++++++++++++--- 3 files changed, 119 insertions(+), 8 deletions(-) diff --git a/docs/sandbox-hosting.md b/docs/sandbox-hosting.md index 8cee5c9..8b047d1 100644 --- a/docs/sandbox-hosting.md +++ b/docs/sandbox-hosting.md @@ -131,6 +131,21 @@ container. Configure the Host with an authenticated `OPENGUI_SHELL_SANDBOX_ENDPO - `OPENGUI_SHELL_RESOLV_CONF`: trusted resolver configuration mounted read-only; - optional `OPENGUI_SHELL_DEPLOY_CREDENTIALS`: repository-root to deploy-key mappings. +Run `sandbox-doctor.ts` with the broker's `OPENGUI_SHELL_IMAGE` configuration and the same +rootless Docker context used by the broker before deployment, restart, upgrade, or rollback. +The doctor inspects the configured image locally and fails if it is missing or Docker cannot +inspect it; it never pulls or restores an image. Without `OPENGUI_SHELL_IMAGE`, the image check +is skipped for deployments that do not use the separate broker. A broker on another machine +must be checked on that machine, not against the web Host's Docker daemon. + +A successful preflight or web `/api/health` response does not establish shell readiness. Verify +an actual authenticated restricted-Account shell call in a disposable granted Project, including +a read-only grant and denied access outside the grant. Repeat after cleanup and upgrades. This +doctor does not install image retention, recurring probes, alerts, or recovery automation. +Immutable image IDs/digests do not prevent image deletion; restore and verify the required image +before retrying deployment or rolling back. Do not use broad image/container/system pruning as +a readiness test. + The Host re-resolves the actor's policy immediately before every tool effect. Restricted shell is enabled only when the actor has grants and the broker is configured. Every invocation gets a new gVisor container with only those canonical grant roots mounted; `read` grants are mounted read-only diff --git a/scripts/sandbox-doctor.test.ts b/scripts/sandbox-doctor.test.ts index d30826a..2b34e41 100644 --- a/scripts/sandbox-doctor.test.ts +++ b/scripts/sandbox-doctor.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, test } from "vitest"; -import { sandboxHostFindings } from "./sandbox-doctor.ts"; +import { describe, expect, test, vi } from "vitest"; +import { inspectSandboxHost, sandboxHostFindings, type DockerCommand } from "./sandbox-doctor.ts"; describe("sandbox Host doctor", () => { test("accepts a rootless Docker daemon with runsc and private deployment files", () => { @@ -14,6 +14,72 @@ describe("sandbox Host doctor", () => { ).toEqual([]); }); + test("rejects an unavailable configured shell image even when Docker and files are healthy", () => { + expect( + sandboxHostFindings({ + securityOptions: ["name=rootless"], + runtimes: ["runc", "runsc"], + workspace: { exists: true, directory: true, mode: 0o750 }, + deployKey: { exists: true, directory: false, mode: 0o600 }, + knownHosts: { exists: true, directory: false, mode: 0o644 }, + shellImageAvailable: false, + }), + ).toContain("OPENGUI_SHELL_IMAGE is not available in the selected Docker daemon"); + }); + + test.each([ + { label: "present image", status: 0, stdout: `sha256:${"a".repeat(64)}\n`, available: true }, + { label: "missing image", status: 1, stdout: "", available: false }, + { label: "Docker error", status: null, stdout: "", available: false }, + { label: "invalid inspection output", status: 0, stdout: "null\n", available: false }, + ])( + "inspects the configured image without pulling it: $label", + ({ status, stdout, available }) => { + const docker = vi.fn((arguments_) => { + if (arguments_[0] === "image") { + return { status, stdout, stderr: "fixture diagnostic with private content" }; + } + return { + status: 0, + stdout: arguments_[2]?.includes("SecurityOptions") ? '["name=rootless"]' : "runsc\n", + stderr: "", + }; + }); + const inspection = inspectSandboxHost( + { + OPENGUI_SHELL_IMAGE: "fixture/image:release", + OPENGUI_SHELL_BROKER_TOKEN: "fixture-secret", + }, + docker, + ); + expect(inspection.shellImageAvailable).toBe(available); + expect(docker).toHaveBeenCalledWith([ + "image", + "inspect", + "--format", + "{{.Id}}", + "--", + "fixture/image:release", + ]); + expect(docker).toHaveBeenCalledTimes(3); + const diagnostics = JSON.stringify(sandboxHostFindings(inspection)); + expect(diagnostics).not.toContain("fixture-secret"); + expect(diagnostics).not.toContain("private content"); + expect(diagnostics).not.toContain("fixture/image"); + }, + ); + + test("does not require or inspect an image for broker-free deployments", () => { + const docker = vi.fn((arguments_) => ({ + status: 0, + stdout: arguments_[2]?.includes("SecurityOptions") ? '["name=rootless"]' : "runsc\n", + stderr: "", + })); + expect(inspectSandboxHost({}, docker).shellImageAvailable).toBeUndefined(); + expect(docker).toHaveBeenCalledTimes(2); + expect(docker.mock.calls.every(([arguments_]) => arguments_[0] === "info")).toBe(true); + }); + test("reports unsafe or incomplete hosts without exposing credential contents", () => { expect( sandboxHostFindings({ diff --git a/scripts/sandbox-doctor.ts b/scripts/sandbox-doctor.ts index 21ff2d1..be00b4e 100644 --- a/scripts/sandbox-doctor.ts +++ b/scripts/sandbox-doctor.ts @@ -11,6 +11,14 @@ export type SandboxHostInspection = { workspace: PathStatus; deployKey: PathStatus; knownHosts: PathStatus; + /** Unset for deployments that do not configure the separate shell broker. */ + shellImageAvailable?: boolean; +}; + +export type DockerCommand = (arguments_: string[]) => { + status: number | null; + stdout: string; + stderr: string; }; export function sandboxHostFindings(inspection: SandboxHostInspection) { @@ -32,6 +40,9 @@ export function sandboxHostFindings(inspection: SandboxHostInspection) { if (!inspection.knownHosts.exists) { findings.push("GitHub known-hosts file does not exist"); } + if (inspection.shellImageAvailable === false) { + findings.push("OPENGUI_SHELL_IMAGE is not available in the selected Docker daemon"); + } return findings; } @@ -45,28 +56,44 @@ function pathStatus(path: string | undefined): PathStatus { } } -function dockerOutput(format: string) { - const result = spawnSync("docker", ["info", "--format", format], { +const runDocker: DockerCommand = (arguments_) => { + const result = spawnSync("docker", arguments_, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); + return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" }; +}; + +function dockerOutput(format: string, docker: DockerCommand) { + const result = docker(["info", "--format", format]); if (result.status !== 0) { throw new Error(result.stderr.trim() || "docker info failed"); } return result.stdout.trim(); } -export function inspectSandboxHost(environment: NodeJS.ProcessEnv = process.env) { - const securityOptions = JSON.parse(dockerOutput("{{json .SecurityOptions}}")) as string[]; - const runtimes = dockerOutput("{{range $name, $_ := .Runtimes}}{{println $name}}{{end}}") +export function inspectSandboxHost( + environment: NodeJS.ProcessEnv = process.env, + docker: DockerCommand = runDocker, +) { + const securityOptions = JSON.parse(dockerOutput("{{json .SecurityOptions}}", docker)) as string[]; + const runtimes = dockerOutput("{{range $name, $_ := .Runtimes}}{{println $name}}{{end}}", docker) .split(/\s+/u) .filter(Boolean); + const image = environment.OPENGUI_SHELL_IMAGE; + let shellImageAvailable: boolean | undefined; + if (image !== undefined) { + const result = docker(["image", "inspect", "--format", "{{.Id}}", "--", image]); + shellImageAvailable = + result.status === 0 && /^sha256:[a-f0-9]{64}$/u.test(result.stdout.trim()); + } return { securityOptions, runtimes, workspace: pathStatus(environment.OPENGUI_WORKSPACE), deployKey: pathStatus(environment.OPENGUI_GITHUB_DEPLOY_KEY_FILE), knownHosts: pathStatus(environment.OPENGUI_GITHUB_KNOWN_HOSTS_FILE), + shellImageAvailable, } satisfies SandboxHostInspection; } @@ -79,7 +106,10 @@ function main() { process.exitCode = 1; return; } - console.info("Sandbox Host is ready: rootless Docker, runsc, workspace, and GitHub files OK"); + console.info( + "Sandbox Host preflight passed: Docker, runsc, files, and any configured shell image OK. " + + "This is not proof of usable shell execution; run an authenticated restricted-grant probe.", + ); } catch (error) { console.error( `Sandbox Host inspection failed: ${error instanceof Error ? error.message : String(error)}`,