From 4d090f31444e657251ad2b2fed833360a7ecdbab Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Wed, 21 Oct 2026 16:50:04 -0400 Subject: [PATCH] fix(native): refuse non-content-ID images at native plan and native validate The bounded native authored consumer runs only `sha256:` image content IDs. A `repository@sha256:` digest (or a tag) passed `graph native plan`, so `hack up` reserved its startup intent and source before `graph native serve` refused `native_graph_admission` during lowering. Native selection now applies the lowering's image check and returns the same closed `native_graph_admission` refusal. `plan`, `frontend-plan`, `run` and `serve` refuse before any state, and authored `up` stops at its `native-plan` stage with nothing reserved. With `HACK_RUNTIME_BACKEND=native`, `config validate` reports the same code and message. Without it, validation stays backend-neutral because Compose runs tags and digests. Refs HACK-1300 Co-Authored-By: Claude Opus 5.5 --- docs/reference/cli.md | 2 +- docs/reference/native-authored-commands.md | 15 +- docs/reference/native-config-compiler.md | 8 + packages/runtime-core/README.md | 3 + .../src/provider/graph/native/selection.rs | 10 + .../provider/graph/native/selection/tests.rs | 57 +++++ src/commands/config-validate.ts | 19 +- src/lib/native-authored-image-admission.ts | 52 ++++ tests/native-authored-project-start.test.ts | 42 +++- ...ve-config-validate-image-admission.test.ts | 234 ++++++++++++++++++ 10 files changed, 434 insertions(+), 8 deletions(-) create mode 100644 src/lib/native-authored-image-admission.ts create mode 100644 tests/native-config-validate-image-admission.test.ts diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 58828fa38..f107b5e3e 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -613,7 +613,7 @@ Validate native configuration and selected local overlays without starting workl hack config validate [options] ``` -Uses the matching bundled Rust compiler. Without --file, discovers a native project and resolves permitted worktree-local overlay and routing settings. --file validates only the explicit document. Neither mode reads env values, writes state, or starts workloads. +Uses the matching bundled Rust compiler. Without --file, discovers a native project and resolves permitted worktree-local overlay and routing settings. --file validates only the explicit document. With HACK_RUNTIME_BACKEND=native, a selected workload image that is not a sha256: content ID refuses with the native consumer's native_graph_admission code. Neither mode reads env values, writes state, or starts workloads. ### Options diff --git a/docs/reference/native-authored-commands.md b/docs/reference/native-authored-commands.md index 7125c388c..2f95844bc 100644 --- a/docs/reference/native-authored-commands.md +++ b/docs/reference/native-authored-commands.md @@ -15,9 +15,13 @@ This bounded native consumer requires each service's `image` to be an immutable `sha256:` image **content ID**, such as the `image_id` returned by a successful [`runtime ensure-image`](../guides/native-candidate.md#public-image-acquisition-without-docker) acquisition. Keep that acquisition's pinned-reference and archive evidence. A -registry tag or `repository@sha256:manifest-digest` is not the content ID: it can -pass configuration planning while execution refuses `native_graph_admission`. -Do not substitute a mutable image or bypass the image identity guard. +registry tag or `repository@sha256:manifest-digest` is not the content ID. The +native planner (`graph native plan` and `frontend-plan`) refuses it with the same +closed `native_graph_admission` code that execution uses, so `up` fails at its +`native-plan` stage before it reserves a startup intent. `config validate` reports the +same refusal when `HACK_RUNTIME_BACKEND=native` is set; without it, validation stays +backend-neutral because Compose runs tags and digests. Do not substitute a mutable +image or bypass the image identity guard. ```sh HACK_RUNTIME_BACKEND=native \ @@ -92,8 +96,9 @@ prove cleanup. Retained bindings and recovery requirements stay in force. These diagnostics do not grant retry or cleanup authority. A native consumer can refuse after the startup intent is reserved but before it -reserves any runtime journal. For example, a `repository@sha256:` image reaches -execution and refuses `native_graph_admission`. Such a run has no receipt, so the +reserves any runtime journal, for example when its review changes, the engine +cannot be reached or startup is canceled. (A `repository@sha256:` image used to +reach execution this way; the planner now refuses it first.) Such a run has no receipt, so the Removed retirement inspection can never succeed. After the consumer process has settled, startup asks the native runtime to inspect the exact run. Startup retires the startup intent and its source envelope only when the runtime refuses diff --git a/docs/reference/native-config-compiler.md b/docs/reference/native-config-compiler.md index cfb112212..4f1e80946 100644 --- a/docs/reference/native-config-compiler.md +++ b/docs/reference/native-config-compiler.md @@ -496,6 +496,14 @@ not inspected, and no env values are read. Runtime/adoption commands remain fenc `--file` validates only that document, ignores local/global policy files and performs no project discovery or origin resolution. It cannot be combined with `--path`, `--env` or `--domain`. + +Image references are backend-neutral: a registry tag or `repository@sha256:` digest +is valid authored configuration, and the default Compose backend runs it. With an +explicit `HACK_RUNTIME_BACKEND=native`, both modes also refuse a selected service or +job image that is not a `sha256:` image content ID. The refusal is the native +consumer's own closed `native_graph_admission` code and message, so validation +predicts the refusal that `graph native plan` returns before startup reserves +anything. Only image identity is checked here, not the rest of the native subset. `--json` returns the authored normalized plan, including authored public literals and commands. Those values are intentionally visible; diagnostic redaction does not turn the plan into a secret-safe storage format. diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index 874608f2d..d284b19b4 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -264,6 +264,9 @@ ordinary frontend selection yet and does not implement reactive health or hooks. The optional feature exposes this bounded consumer through a distinct public CLI: `graph native plan --source-file FILE --json`, then `graph native run --source-file FILE --expect-review SHA --json`. +Selection, and so `plan`, `frontend-plan`, `run` and `serve`, refuses a selected +workload image that is not a `sha256:` content ID (a tag or `repository@sha256:` +digest) with the lowering's closed `native_graph_admission` code, before any state. `graph native inspect|cleanup --run-id RUN --json` use its versioned native journal. On macOS, `inspect` refuses `native_graph_unreserved` before guest access only when the run's journal directory is exactly absent under private state directories and no foreground owner is diff --git a/packages/runtime-core/src/provider/graph/native/selection.rs b/packages/runtime-core/src/provider/graph/native/selection.rs index 9955d2d34..b424fcc44 100644 --- a/packages/runtime-core/src/provider/graph/native/selection.rs +++ b/packages/runtime-core/src/provider/graph/native/selection.rs @@ -537,6 +537,16 @@ fn select_frontend( if matches!(git, GitMarker::Linked(_)) && inputs.local_resolution.inherit_local { return Err(refused()); } + // The consumer runs only immutable `sha256:` content IDs. A tag or a + // `repository@sha256:` digest is not one, so plan/frontend-plan refuse it with + // the lowering's own closed code, before startup reserves any intent or source. + if inputs + .workloads + .values() + .any(|workload| !super::image_id(&workload.image)) + { + return Err(super::refused()); + } let review = native_input::Review::new( native_input::Scope { namespace: &workspace.namespace, diff --git a/packages/runtime-core/src/provider/graph/native/selection/tests.rs b/packages/runtime-core/src/provider/graph/native/selection/tests.rs index dd5c968d3..e2e278b08 100644 --- a/packages/runtime-core/src/provider/graph/native/selection/tests.rs +++ b/packages/runtime-core/src/provider/graph/native/selection/tests.rs @@ -204,6 +204,63 @@ fn branch_profile_and_checkout_local_selection_are_compiler_owned() { ); } +/// HACK-1300: plan/frontend-plan refuse what serve's lowering refuses, with its +/// closed `native_graph_admission` code, before startup can reserve anything. +#[test] +fn repository_digest_and_tag_images_refuse_plan_with_the_lowering_code_and_no_state() { + let digest = format!("docker.io/library/busybox@sha256:{}", "e".repeat(64)); + for (field, image) in [ + ("services", digest.as_str()), + ("services", "busybox@sha256:not-a-content-id"), + ("services", "busybox:1.37"), + ("jobs", digest.as_str()), + ] { + let fixture = Fixture::new(); + let mut value = basic(); + if field == "jobs" { + value["jobs"] = json!({"seed":{"image":image,"command":{"exec":["/bin/true"]}}}); + } else { + value["services"]["web"]["image"] = json!(image); + } + fixture.write_project(value); + let mut options = fixture.options(); + options.metadata = metadata(if field == "jobs" { + json!({"web":{},"seed":{}}) + } else { + json!({"web":{}}) + }); + let error = select(&fixture.candidate, options).err().unwrap(); + assert_eq!(error.code, "native_graph_admission"); + // Byte-identical to the serve-time lowering refusal; never the authored value. + assert_eq!( + serde_json::to_string(&error).unwrap(), + serde_json::to_string(&super::super::refused()).unwrap() + ); + assert!(!serde_json::to_string(&error).unwrap().contains("busybox")); + assert!(!fixture.candidate.state_root.exists()); + } + // Control: the same digest on a workload outside the selected profiles is not + // lowered by serve, so plan admits it; selecting the profile refuses. + let fixture = Fixture::new(); + let mut value = basic(); + value["profiles"] = json!(["debug"]); + value["jobs"] = + json!({"debug":{"image":digest,"profiles":["debug"],"command":{"exec":["/bin/true"]}}}); + fixture.write_project(value); + let mut plain = fixture.options(); + plain.metadata = metadata(json!({"web":{},"debug":{}})); + select(&fixture.candidate, plain).unwrap(); + let profiles = ["debug".into()]; + let mut active = fixture.options(); + active.profiles = &profiles; + active.metadata = metadata(json!({"web":{},"debug":{}})); + assert_eq!( + select(&fixture.candidate, active).err().unwrap().code, + "native_graph_admission" + ); + assert!(!fixture.candidate.state_root.exists()); +} + #[test] fn inherited_linked_worktree_inputs_refuse_without_interpreting_git_or_primary_paths() { let fixture = Fixture::new(); diff --git a/src/commands/config-validate.ts b/src/commands/config-validate.ts index 3fe36e1df..25e067064 100644 --- a/src/commands/config-validate.ts +++ b/src/commands/config-validate.ts @@ -8,6 +8,11 @@ import { import { resolveRequestedEnvName } from "../cli/env-selection.ts"; import { optEnv, optJson, optPath } from "../cli/options.ts"; import { HackCliError } from "../lib/cli-result.ts"; +import { + NATIVE_GRAPH_ADMISSION_CODE, + NATIVE_GRAPH_ADMISSION_MESSAGE, + nativeConsumerAdmitsImages, +} from "../lib/native-authored-image-admission.ts"; import { compileNativeConfig, type NativeConfigCompileResult, @@ -23,7 +28,7 @@ const spec = defineCommand({ "Validate native configuration and selected local overlays without starting workloads", group: "Project", description: - "Uses the matching bundled Rust compiler. Without --file, discovers a native project and resolves permitted worktree-local overlay and routing settings. --file validates only the explicit document. Neither mode reads env values, writes state, or starts workloads.", + "Uses the matching bundled Rust compiler. Without --file, discovers a native project and resolves permitted worktree-local overlay and routing settings. --file validates only the explicit document. With HACK_RUNTIME_BACKEND=native, a selected workload image that is not a sha256: content ID refuses with the native consumer's native_graph_admission code. Neither mode reads env values, writes state, or starts workloads.", options: [ defineOption({ name: "file", @@ -102,6 +107,18 @@ export const configValidateCommand = withHandler( profiles, signal: controller.signal, }); + // A tag or repository digest is valid authored configuration for Compose, + // but the explicitly selected native consumer refuses it at plan and serve. + if ( + result.ok && + process.env.HACK_RUNTIME_BACKEND === "native" && + !nativeConsumerAdmitsImages(result.plan) + ) { + throw new NativeConfigCompilerError( + NATIVE_GRAPH_ADMISSION_CODE, + NATIVE_GRAPH_ADMISSION_MESSAGE + ); + } renderValidationResult({ result, json: args.options.json === true }); return result.ok ? 0 : 1; } catch (error: unknown) { diff --git a/src/lib/native-authored-image-admission.ts b/src/lib/native-authored-image-admission.ts new file mode 100644 index 000000000..6714acf91 --- /dev/null +++ b/src/lib/native-authored-image-admission.ts @@ -0,0 +1,52 @@ +import { isRecord } from "./guards.ts"; + +/** + * Closed refusal of the bounded native authored consumer. The code and wording + * are byte-identical to runtime-core's `native_graph_admission` + * (`provider/graph/native/mod.rs`), which `graph native plan`, `frontend-plan` + * and `serve` all return for a non-content-ID image. + */ +export const NATIVE_GRAPH_ADMISSION_CODE = "native_graph_admission"; +export const NATIVE_GRAPH_ADMISSION_MESSAGE = + "Native consumption requires its exact compiler review, immutable images, bounded process/readiness and a separately admitted live-source or persistent-storage contract; values omitted."; + +const CONTENT_ID = /^sha256:[a-f0-9]{64}$/; + +/** + * Whether every selected workload in a compiled plan names an immutable + * `sha256:` image content ID, the only image form the native consumer runs. + * + * A registry tag or `repository@sha256:` manifest digest is valid authored + * configuration (the Compose backend runs it) but is not a content ID. Only the + * compiler's selected `services` and `jobs` are checked, matching the workloads + * native lowering admits. A build-only workload has no image and is left to the + * native planner's own subset refusal; a non-string image or a malformed plan + * fails closed. This checks only image identity, not the rest of the native + * subset. Pure: no runtime, registry or state access. + */ +export function nativeConsumerAdmitsImages(plan: unknown): boolean { + if (!isRecord(plan)) { + return false; + } + for (const field of ["services", "jobs"] as const) { + const workloads = plan[field]; + if (workloads === undefined) { + continue; + } + if (!isRecord(workloads)) { + return false; + } + for (const workload of Object.values(workloads)) { + if (!isRecord(workload)) { + return false; + } + if ( + workload.image !== undefined && + !(typeof workload.image === "string" && CONTENT_ID.test(workload.image)) + ) { + return false; + } + } + } + return true; +} diff --git a/tests/native-authored-project-start.test.ts b/tests/native-authored-project-start.test.ts index cbb6b7ff6..34439bd3b 100644 --- a/tests/native-authored-project-start.test.ts +++ b/tests/native-authored-project-start.test.ts @@ -208,6 +208,8 @@ type FixtureOptions = { readonly liveStop?: boolean; readonly semanticAfterHook?: boolean; readonly planFailure?: boolean; + /** Closed native code for `planFailure` (default `native_graph_compile`). */ + readonly planFailureCode?: string; readonly failBeforeReceipt?: boolean; /** * Like `failBeforeReceipt`, but serve fails the way authored foreground @@ -470,7 +472,7 @@ if(['plan','serve','frontend-plan','frontend-serve'].includes(action)){ review.review_id=createHash('sha256').update('hack.native-graph-review/v1\\0').update(JSON.stringify(provenance)).digest('hex'); } if(planning){ - if(${options.planFailure === true}){console.error(JSON.stringify({code:'native_graph_compile',message:'private-rejected-plan-detail'}));process.exit(2)} + if(${options.planFailure === true}){console.error(JSON.stringify({code:${JSON.stringify(options.planFailureCode ?? "native_graph_compile")},message:'private-rejected-plan-detail'}));process.exit(2)} if(${options.changeDuringPlan === true})await appendFile(${JSON.stringify(envFile)},'\\n'); console.log(JSON.stringify(review));process.exit(0); } @@ -1010,6 +1012,44 @@ for (const option of [ ); } +macTest( + "HACK-1300: a planner image refusal retains nothing and never reaches serve", + async () => { + // The native planner now refuses a repository@digest or tag image with the + // lowering's own native_graph_admission code, before any reservation. + const selected = await fixture({ + planFailure: true, + planFailureCode: "native_graph_admission", + }); + const error = await failure( + serveNativeAuthoredProject({ + ...selected, + run, + startupTimeoutMs: 10_000, + }), + "not-started" + ); + expect(error).toHaveProperty("nativeCode", "native_graph_admission"); + expect(error).toHaveProperty("stage", "native-plan"); + expect(String(error)).not.toContain("private-rejected-plan-detail"); + expect(await artifacts(selected.scope)).toEqual([]); + expect(await Bun.file(selected.delivery).exists()).toBe(false); + const calls = (await readFile(selected.calls, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)[4]); + expect(calls).toEqual(["plan"]); + const status = await nativeAuthoredProjectPs({ + runtime: selected.runtime, + scope: selected.scope, + invoke: () => { + throw new Error("No runtime request for an absent attempt."); + }, + }); + expect(status.status).toBe("not_started"); + } +); + macTest( "unscoped native frontend binds the canonical project namespace", async () => { diff --git a/tests/native-config-validate-image-admission.test.ts b/tests/native-config-validate-image-admission.test.ts new file mode 100644 index 000000000..1bcbd0042 --- /dev/null +++ b/tests/native-config-validate-image-admission.test.ts @@ -0,0 +1,234 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { + NATIVE_GRAPH_ADMISSION_CODE, + NATIVE_GRAPH_ADMISSION_MESSAGE, + nativeConsumerAdmitsImages, +} from "../src/lib/native-authored-image-admission.ts"; + +/** + * HACK-1300: with the native backend selected, `config validate` refuses a + * selected image that the native consumer would refuse at plan and serve. + * Every CLI case uses a stub compiler and a temporary HOME; nothing reaches a + * runtime, registry, Docker socket or the real home directory. + */ +const root = resolve(import.meta.dir, ".."); +/** Each case spawns the source CLI; its cold start can exceed bun's default. */ +const CLI_TIMEOUT_MS = 30_000; +const CONTENT_ID = `sha256:${"d".repeat(64)}`; +const DIGEST = `docker.io/library/busybox@sha256:${"e".repeat(64)}`; +let directory = ""; + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "hack-native-validate-image-")); +}); +afterEach(async () => { + await rm(directory, { recursive: true, force: true }); +}); + +function plan(images: Readonly>, jobs = {}) { + return { + plan_version: 1, + worktree: { auto_branch: true, inherit_local: true }, + services: Object.fromEntries( + Object.entries(images).map(([name, image]) => [name, { image }]) + ), + jobs, + }; +} + +test("only sha256 content IDs of selected workloads are admitted", () => { + expect(nativeConsumerAdmitsImages(plan({ web: CONTENT_ID }))).toBe(true); + expect( + nativeConsumerAdmitsImages(plan({}, { seed: { image: CONTENT_ID } })) + ).toBe(true); + // Build-only workloads are the native planner's own subset refusal. + expect( + nativeConsumerAdmitsImages(plan({}, { seed: { build: { context: "." } } })) + ).toBe(true); + for (const image of [ + DIGEST, + `busybox@sha256:${"e".repeat(64)}`, + "busybox:1.37", + "busybox", + `sha256:${"D".repeat(64)}`, + `sha256:${"d".repeat(63)}`, + ` ${CONTENT_ID}`, + ]) { + expect(nativeConsumerAdmitsImages(plan({ web: image }))).toBe(false); + expect( + nativeConsumerAdmitsImages(plan({ web: CONTENT_ID }, { seed: { image } })) + ).toBe(false); + } + for (const malformed of [ + null, + [], + { services: [] }, + { services: { web: null } }, + { services: { web: { image: 1 } } }, + ]) { + expect(nativeConsumerAdmitsImages(malformed)).toBe(false); + } +}); + +test("the TypeScript refusal is byte-identical to the native lowering refusal", async () => { + const source = await Bun.file( + join(root, "packages/runtime-core/src/provider/graph/native/mod.rs") + ).text(); + expect(source).toContain( + `"${NATIVE_GRAPH_ADMISSION_CODE}",\n "${NATIVE_GRAPH_ADMISSION_MESSAGE}",` + ); +}); + +async function runValidate(opts: { + readonly backend?: string; + readonly images: Readonly>; + readonly project?: boolean; + readonly json?: boolean; +}) { + const compiler = join(directory, "compiler"); + const file = join(directory, "input.json"); + const home = join(directory, "home"); + await mkdir(home); + await Bun.write(file, '{"schema_version":1,"name":"example"}'); + if (opts.project) { + await mkdir(join(directory, ".hack")); + await Bun.write( + join(directory, ".hack/hack.project.json"), + '{"schema_version":1,"name":"example"}' + ); + } + const response = { + transport_version: 1, + ok: true, + plan: plan(opts.images), + semantic_hash: "a".repeat(64), + ...(opts.project + ? { + declared_workloads: Object.fromEntries( + Object.keys(opts.images).map((name) => [name, "service"]) + ), + local_resolution: { + overlay: null, + origin: "project", + auto_branch: true, + inherit_local: true, + resolution_hash: "b".repeat(64), + }, + } + : {}), + }; + await Bun.write( + compiler, + `#!${process.execPath}\nif (process.argv[2] === '--protocol') { console.log('{"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_plan_version":1}'); } else { console.log(${JSON.stringify(JSON.stringify(response))}); }` + ); + await chmod(compiler, 0o755); + const child = Bun.spawn( + [ + process.execPath, + join(root, "index.ts"), + "config", + "validate", + ...(opts.project ? [] : ["--file", file]), + ...(opts.json === false ? [] : ["--json"]), + ], + { + cwd: directory, + env: { + PATH: "/usr/bin:/bin", + HOME: home, + HACK_LOGGER: "console", + HACK_CONFIG_COMPILER_BINARY: compiler, + ...(opts.backend === undefined + ? {} + : { HACK_RUNTIME_BACKEND: opts.backend }), + }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + } + ); + const [stdout, stderr, exit] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { stdout, stderr, exit, home }; +} + +test.each([ + ["repository digest, explicit file", DIGEST, false], + ["repository digest, discovered project", DIGEST, true], + ["registry tag, explicit file", "busybox:1.37", false], +])( + "native backend refuses a %s closed, before any state", + async (_name, image, project) => { + const result = await runValidate({ + backend: "native", + images: { web: image }, + project, + }); + expect(result.exit).toBe(1); + expect(JSON.parse(result.stdout)).toEqual({ + transport_version: 1, + ok: false, + error: { + code: NATIVE_GRAPH_ADMISSION_CODE, + message: NATIVE_GRAPH_ADMISSION_MESSAGE, + }, + }); + expect(result.stdout + result.stderr).not.toContain("busybox"); + // No runtime, registry or project state was created: only the fixture remains. + expect((await readdir(directory)).sort()).toEqual( + [...(project ? [".hack"] : []), "compiler", "home", "input.json"].sort() + ); + if (project) { + expect(await readdir(join(directory, ".hack"))).toEqual([ + "hack.project.json", + ]); + } + // The temporary HOME holds at most Bun's own transpiler cache, never Hack state. + expect( + (await readdir(result.home, { recursive: true })).every((name) => + name.startsWith("Library") + ) + ).toBe(true); + }, + CLI_TIMEOUT_MS +); + +test( + "native human refusal is one closed line without help or stack", + async () => { + const result = await runValidate({ + backend: "native", + images: { web: DIGEST }, + json: false, + }); + expect(result.exit).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe( + `${NATIVE_GRAPH_ADMISSION_CODE}: ${NATIVE_GRAPH_ADMISSION_MESSAGE}\n` + ); + }, + CLI_TIMEOUT_MS +); + +test.each([ + ["unset backend, repository digest", undefined, DIGEST], + ["compose backend, repository digest", "compose", DIGEST], + ["native backend, content ID", "native", CONTENT_ID], +])( + "control: %s stays valid", + async (_name, backend, image) => { + const result = await runValidate({ backend, images: { web: image } }); + expect(result.exit).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + plan: { services: { web: { image } } }, + }); + }, + CLI_TIMEOUT_MS +);