From 7178610c145079f93f0aaaf39684cf9584a81c80 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 3 Aug 2026 21:45:25 -0400 Subject: [PATCH 1/2] feat(runtime): define state mutation contract Signed-off-by: Julie Yaunches --- ...naged-workload-rebuild-transaction.test.ts | 1 + src/lib/onboard/runtime-provider/access.ts | 5 + src/lib/onboard/runtime-provider/contract.ts | 77 +++++++ src/lib/onboard/runtime-provider/docker.ts | 8 + src/lib/onboard/runtime-provider/registry.ts | 15 ++ .../runtime-provider-contract.test.ts | 41 +++- .../runtime-provider/state-mutation.test.ts | 158 +++++++++++++ .../runtime-provider/state-mutation.ts | 208 ++++++++++++++++++ test/helpers/runtime-provider-bundle.ts | 1 + test/runtime-provider-source-shape.test.ts | 6 + 10 files changed, 517 insertions(+), 3 deletions(-) create mode 100644 src/lib/onboard/runtime-provider/state-mutation.test.ts create mode 100644 src/lib/onboard/runtime-provider/state-mutation.ts diff --git a/src/lib/onboard/managed-workload-rebuild-transaction.test.ts b/src/lib/onboard/managed-workload-rebuild-transaction.test.ts index 659334f8fa7..5a03fe5fadd 100644 --- a/src/lib/onboard/managed-workload-rebuild-transaction.test.ts +++ b/src/lib/onboard/managed-workload-rebuild-transaction.test.ts @@ -232,6 +232,7 @@ function bundle(providerId: string): RuntimeProviderBundle { supported: true, operations: ["rebuild"], }, + stateMutation: unsupported(providerId), bootstrap: unsupported(providerId), snapshot: unsupported(providerId), recovery: unsupported(providerId), diff --git a/src/lib/onboard/runtime-provider/access.ts b/src/lib/onboard/runtime-provider/access.ts index 0a49abaa21f..e5bf7082081 100644 --- a/src/lib/onboard/runtime-provider/access.ts +++ b/src/lib/onboard/runtime-provider/access.ts @@ -7,6 +7,10 @@ export type { RuntimeProviderChannelStopTransport, RuntimeProviderGatewayLauncher, RuntimeProviderManagedImageSupport, + RuntimeProviderPreparedStateMutationPlan, + RuntimeProviderStateMutationPlan, + RuntimeProviderStateMutationSelector, + RuntimeProviderStateMutationSurface, RuntimeProviderWorkloadCleanupPlan, RuntimeProviderWorkloadCleanupResult, RuntimeProviderWorkloadProfile, @@ -26,3 +30,4 @@ export { resolveRuntimeProviderBundle, runtimeProviderContainerEngineIdentity, } from "./registry"; +export { prepareRuntimeProviderStateMutationPlan } from "./state-mutation"; diff --git a/src/lib/onboard/runtime-provider/contract.ts b/src/lib/onboard/runtime-provider/contract.ts index 5e806862404..2ba3c02f3f6 100644 --- a/src/lib/onboard/runtime-provider/contract.ts +++ b/src/lib/onboard/runtime-provider/contract.ts @@ -13,6 +13,8 @@ import type { ManagedImageSelectionPolicy } from "../workload/source"; export const RUNTIME_PROVIDER_BUNDLE_CONTRACT_VERSION = 1 as const; export const RUNTIME_PROVIDER_SNAPSHOT_CONTRACT_VERSION = 1 as const; export const RUNTIME_PROVIDER_SNAPSHOT_PREFLIGHT_SCHEMA_VERSION = 1 as const; +export const RUNTIME_PROVIDER_STATE_MUTATION_CONTRACT_VERSION = 1 as const; +export const RUNTIME_PROVIDER_STATE_MUTATION_PLAN_SCHEMA_VERSION = 1 as const; export type RuntimeProviderGatewayLauncher = "nemoclaw" | "openshell"; export type RuntimeProviderLifecycleAction = "start" | "stop"; @@ -201,6 +203,56 @@ export interface RuntimeProviderManagedProfileRestoreAuthority { readonly profileFingerprint: string; } +export type RuntimeProviderStateMutationSelector = + | { readonly kind: "path"; readonly path: string } + | { readonly kind: "prefix"; readonly prefix: string }; + +/** One bounded state scope. Providers never accept commands or callbacks here. */ +export interface RuntimeProviderStateMutationPlan { + readonly schemaVersion: typeof RUNTIME_PROVIDER_STATE_MUTATION_PLAN_SCHEMA_VERSION; + readonly intent: "protection-transition" | "restore"; + readonly stateRoot: string; + readonly selectors: readonly RuntimeProviderStateMutationSelector[]; + /** Digest of the complete projection produced by the selected AgentDefinition. */ + readonly projectionSha256: string; +} + +export interface RuntimeProviderPreparedStateMutationPlan { + readonly plan: RuntimeProviderStateMutationPlan; + readonly planSha256: string; + readonly projectionSha256: string; +} + +export interface RuntimeProviderStateMutationContext { + readonly environment: NodeJS.ProcessEnv; + readonly sandbox: SandboxEntry; + readonly sandboxName: string; +} + +/** Opaque provider proof for one durable, exact-runtime active fence. */ +export interface RuntimeProviderStateMutationFence { + readonly schemaVersion: 1; + readonly providerId: string; + readonly sandboxName: string; + readonly lifecycleGeneration: string; + readonly stateRoot: string; + readonly planSha256: string; + readonly projectionSha256: string; + readonly nonce: string; + readonly providerHandle: string; +} + +/** Fresh service evidence required before an active fence may be retired. */ +export interface RuntimeProviderStateMutationActivationProof { + readonly schemaVersion: 1; + readonly providerId: string; + readonly sandboxName: string; + readonly lifecycleGeneration: string; + readonly configurationGeneration: string; + readonly listenerIdentity: string; + readonly healthSha256: string; +} + /** * Complete normalized source state supplied to the owning restore facet. * `providerHandle` binds the lifecycle generation and full runtime receipt. @@ -265,6 +317,30 @@ export type RuntimeProviderMutationAuthoritySurface = }> | RuntimeProviderUnsupportedSurface; +export type RuntimeProviderStateMutationSurface = + | RuntimeProviderSupportedSurface<{ + readonly contractVersion: typeof RUNTIME_PROVIDER_STATE_MUTATION_CONTRACT_VERSION; + acquire( + input: RuntimeProviderStateMutationContext & { + /** Frozen, digested output of prepareRuntimeProviderStateMutationPlan. */ + readonly plan: RuntimeProviderPreparedStateMutationPlan; + }, + ): Promise; + assertFenced( + input: RuntimeProviderStateMutationContext, + fence: RuntimeProviderStateMutationFence, + ): Promise; + activate( + input: RuntimeProviderStateMutationContext, + fence: RuntimeProviderStateMutationFence, + proof: RuntimeProviderStateMutationActivationProof, + ): Promise; + recover( + input: RuntimeProviderStateMutationContext, + ): Promise; + }> + | RuntimeProviderUnsupportedSurface; + export type RuntimeProviderBootstrapSurface = | RuntimeProviderSupportedSurface<{ createLifecycle( @@ -360,6 +436,7 @@ export interface RuntimeProviderBundle { readonly workload: RuntimeProviderWorkloadSurface; readonly lifecycle: RuntimeProviderLifecycleSurface; readonly mutationAuthority: RuntimeProviderMutationAuthoritySurface; + readonly stateMutation: RuntimeProviderStateMutationSurface; readonly bootstrap: RuntimeProviderBootstrapSurface; readonly snapshot: RuntimeProviderSnapshotSurface; readonly recovery: RuntimeProviderRecoverySurface; diff --git a/src/lib/onboard/runtime-provider/docker.ts b/src/lib/onboard/runtime-provider/docker.ts index 63945928895..24c43b65f80 100644 --- a/src/lib/onboard/runtime-provider/docker.ts +++ b/src/lib/onboard/runtime-provider/docker.ts @@ -353,6 +353,10 @@ export function createDockerRuntimeProviderBundle( "workload-cleanup", ], }, + stateMutation: unsupported( + providerId, + "Exact-runtime state mutation requires durable writer exclusion and fresh activation proof.", + ), bootstrap: unsupported(providerId, futureReason), snapshot: createDockerRuntimeProviderSnapshotSurface(providerId, { captureHostCommand: deps.captureHostCommand, @@ -440,6 +444,10 @@ export function createKubernetesRuntimeProviderBundle( "workload-cleanup", ], }, + stateMutation: unsupported( + providerId, + "Exact-runtime state mutation is unavailable for the Kubernetes provider.", + ), bootstrap: unsupported(providerId, futureReason), snapshot: unsupported(providerId, futureReason), recovery: unsupported(providerId, futureReason), diff --git a/src/lib/onboard/runtime-provider/registry.ts b/src/lib/onboard/runtime-provider/registry.ts index 1d0a31ae6aa..95667d37152 100644 --- a/src/lib/onboard/runtime-provider/registry.ts +++ b/src/lib/onboard/runtime-provider/registry.ts @@ -6,6 +6,7 @@ import { RUNTIME_PROVIDER_BUNDLE_CONTRACT_VERSION, RUNTIME_PROVIDER_SNAPSHOT_CONTRACT_VERSION, RUNTIME_PROVIDER_SNAPSHOT_PREFLIGHT_SCHEMA_VERSION, + RUNTIME_PROVIDER_STATE_MUTATION_CONTRACT_VERSION, type RuntimeProviderBundle, type RuntimeProviderBundleRegistry, type RuntimeProviderChannelStopTransport, @@ -29,6 +30,7 @@ const BUNDLE_SURFACES = [ "workload", "lifecycle", "mutationAuthority", + "stateMutation", "bootstrap", "snapshot", "recovery", @@ -335,6 +337,18 @@ function validateMutationAuthoritySurface( } } +function validateStateMutationSurface(providerId: string, surface: Record): void { + if (surface.supported !== true) return; + if (surface.contractVersion !== RUNTIME_PROVIDER_STATE_MUTATION_CONTRACT_VERSION) { + throw new RuntimeProviderRegistrationError( + `stateMutation for '${providerId}' has an unsupported contract version`, + ); + } + for (const operation of ["acquire", "assertFenced", "activate", "recover"] as const) { + requireFunction(surface, operation, "stateMutation"); + } +} + function validateBootstrapSurface(surface: Record): void { if (surface.supported === true) { requireFunction(surface, "createLifecycle", "bootstrap"); @@ -429,6 +443,7 @@ function validateSupportedSurfaceSchemas( validateWorkloadSurface(providerId, surfaces.workload); validateLifecycleSurface(providerId, surfaces.lifecycle); validateMutationAuthoritySurface(providerId, surfaces.mutationAuthority); + validateStateMutationSurface(providerId, surfaces.stateMutation); validateBootstrapSurface(surfaces.bootstrap); validateSnapshotSurface(providerId, surfaces.snapshot); validateRecoverySurface(surfaces.recovery); diff --git a/src/lib/onboard/runtime-provider/runtime-provider-contract.test.ts b/src/lib/onboard/runtime-provider/runtime-provider-contract.test.ts index 818ae30eb30..1c94a6d1e10 100644 --- a/src/lib/onboard/runtime-provider/runtime-provider-contract.test.ts +++ b/src/lib/onboard/runtime-provider/runtime-provider-contract.test.ts @@ -121,6 +121,7 @@ describe("RuntimeProviderBundle registry contract", () => { "workload", "lifecycle", "mutationAuthority", + "stateMutation", "bootstrap", "snapshot", "recovery", @@ -130,6 +131,7 @@ describe("RuntimeProviderBundle registry contract", () => { expect(bundle[surface].providerId, `${providerId}.${surface}`).toBe(providerId); } expect(bundle.bootstrap).toMatchObject({ supported: false }); + expect(bundle.stateMutation).toMatchObject({ supported: false }); expect(bundle.snapshot).toMatchObject( providerId === "docker" ? { @@ -287,10 +289,10 @@ describe("RuntimeProviderBundle registry contract", () => { it("rejects a missing surface and every surface identity mismatch", () => { const bundle = mxcBundle(); - const { cleanup: _cleanup, ...missingCleanup } = bundle; + const { stateMutation: _stateMutation, ...missingStateMutation } = bundle; expect(() => - createRuntimeProviderBundleRegistry([["mxc", missingCleanup as RuntimeProviderBundle]]), - ).toThrow(/missing cleanup surface/u); + createRuntimeProviderBundleRegistry([["mxc", missingStateMutation as RuntimeProviderBundle]]), + ).toThrow(/missing stateMutation surface/u); for (const surface of [ "plan", @@ -300,6 +302,7 @@ describe("RuntimeProviderBundle registry contract", () => { "workload", "lifecycle", "mutationAuthority", + "stateMutation", "bootstrap", "snapshot", "recovery", @@ -370,6 +373,19 @@ describe("RuntimeProviderBundle registry contract", () => { operations: ["not-an-operation"], }), ], + [ + "stateMutation", + (bundle: RuntimeProviderBundle) => ({ + ...bundle.stateMutation, + supported: true, + reason: undefined, + contractVersion: 2, + acquire: vi.fn(), + assertFenced: vi.fn(), + activate: vi.fn(), + recover: vi.fn(), + }), + ], [ "bootstrap", (bundle: RuntimeProviderBundle) => ({ @@ -434,6 +450,25 @@ describe("RuntimeProviderBundle registry contract", () => { ).toThrow(/lifecycle\.verifyStarted must be a function/u); }); + it("rejects supported state mutation without the complete durable-fence protocol", () => { + const bundle = mxcBundle(); + expect(() => + createRuntimeProviderBundleRegistry([ + [ + "mxc", + replaceSurface(bundle, "stateMutation", { + providerId: "mxc", + supported: true, + contractVersion: 1, + acquire: vi.fn(), + assertFenced: vi.fn(), + activate: vi.fn(), + }), + ], + ]), + ).toThrow(/stateMutation\.recover must be a function/u); + }); + it("rejects cleanup without a side-effect-free ownership plan", () => { const bundle = mxcBundle(); expectSupportedSurface(bundle.cleanup); diff --git a/src/lib/onboard/runtime-provider/state-mutation.test.ts b/src/lib/onboard/runtime-provider/state-mutation.test.ts new file mode 100644 index 00000000000..e6508772fee --- /dev/null +++ b/src/lib/onboard/runtime-provider/state-mutation.test.ts @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { prepareRuntimeProviderStateMutationPlan } from "./state-mutation"; + +const PROJECTION_SHA256 = "a".repeat(64); + +function plan() { + return { + schemaVersion: 1, + intent: "restore", + stateRoot: "/sandbox/.hermes", + selectors: [ + { kind: "path", path: "scripts" }, + { kind: "path", path: "cron" }, + { kind: "prefix", prefix: "workspace-" }, + ], + projectionSha256: PROJECTION_SHA256, + }; +} + +describe("runtime provider state-mutation plan", () => { + it("binds a bounded scope to the AgentDefinition projection without copying it (#7744)", () => { + const source = plan(); + const prepared = prepareRuntimeProviderStateMutationPlan(source); + + expect(prepared.plan).toEqual(source); + expect(prepared.planSha256).toMatch(/^[a-f0-9]{64}$/u); + expect(prepared.projectionSha256).toBe(PROJECTION_SHA256); + expect(Object.isFrozen(prepared)).toBe(true); + expect(Object.isFrozen(prepared.plan)).toBe(true); + expect(Object.isFrozen(prepared.plan.selectors)).toBe(true); + expect(Object.isFrozen(prepared.plan.selectors[0])).toBe(true); + expect(prepared.plan).not.toBe(source); + }); + + it("keeps the plan digest sensitive to intent, scope, and projection authority (#7744)", () => { + const restore = prepareRuntimeProviderStateMutationPlan(plan()); + const protectionTransition = prepareRuntimeProviderStateMutationPlan({ + ...plan(), + intent: "protection-transition", + }); + const changedProjection = prepareRuntimeProviderStateMutationPlan({ + ...plan(), + projectionSha256: "b".repeat(64), + }); + + expect(protectionTransition.planSha256).not.toBe(restore.planSha256); + expect(changedProjection.planSha256).not.toBe(restore.planSha256); + expect(changedProjection.projectionSha256).toBe("b".repeat(64)); + }); + + it("rejects accessor-backed values before validation can drift (#7744)", () => { + const accessorPlan = plan(); + Object.defineProperty(accessorPlan, "projectionSha256", { + enumerable: true, + get: () => PROJECTION_SHA256, + }); + expect(() => prepareRuntimeProviderStateMutationPlan(accessorPlan)).toThrow( + /fixed data properties/u, + ); + + const accessorSelectors = plan(); + Object.defineProperty(accessorSelectors.selectors, "0", { + enumerable: true, + get: () => ({ kind: "path", path: "scripts" }), + }); + expect(() => prepareRuntimeProviderStateMutationPlan(accessorSelectors)).toThrow( + /fixed data properties/u, + ); + }); + + it.each([ + ["a callback", () => ({ ...plan(), callback: () => undefined })], + ["a command", () => ({ ...plan(), command: ["sh", "-c", "true"] })], + [ + "an unknown selector field", + () => ({ + ...plan(), + selectors: [{ kind: "path", path: "scripts", source: "/tmp/staged" }], + }), + ], + ])("rejects %s instead of expanding the declarative boundary (#7744)", (_label, value) => { + expect(() => prepareRuntimeProviderStateMutationPlan(value())).toThrow( + /fields are unsupported/u, + ); + }); + + it.each([ + ["relative state root", () => ({ ...plan(), stateRoot: "sandbox/.hermes" })], + ["filesystem root", () => ({ ...plan(), stateRoot: "/" })], + ["system state root", () => ({ ...plan(), stateRoot: "/etc/nemoclaw" })], + ["state-root traversal", () => ({ ...plan(), stateRoot: "/sandbox/../etc" })], + [ + "relative-path traversal", + () => ({ + ...plan(), + selectors: [{ kind: "path", path: "scripts/../../etc" }], + }), + ], + [ + "control characters", + () => ({ + ...plan(), + selectors: [{ kind: "path", path: "scripts\u0000escape" }], + }), + ], + [ + "uppercase projection digest", + () => ({ + ...plan(), + projectionSha256: "A".repeat(64), + }), + ], + ])("rejects %s (#7744)", (_label, value) => { + expect(() => prepareRuntimeProviderStateMutationPlan(value())).toThrow( + /state-mutation plan is invalid/u, + ); + }); + + it("rejects duplicate and oversized selector sets (#7744)", () => { + expect(() => prepareRuntimeProviderStateMutationPlan({ ...plan(), selectors: [] })).toThrow( + /non-empty bounded array/u, + ); + + expect(() => + prepareRuntimeProviderStateMutationPlan({ + ...plan(), + selectors: [ + { kind: "path", path: "scripts" }, + { kind: "path", path: "scripts" }, + ], + }), + ).toThrow(/must not repeat/u); + + expect(() => + prepareRuntimeProviderStateMutationPlan({ + ...plan(), + selectors: Array.from({ length: 257 }, (_, index) => ({ + kind: "path", + path: `state-${String(index)}`, + })), + }), + ).toThrow(/bounded array/u); + + expect(() => + prepareRuntimeProviderStateMutationPlan({ + ...plan(), + selectors: Array.from({ length: 256 }, (_, index) => ({ + kind: "path", + path: `${String(index)}-${"a".repeat(300)}`, + })), + }), + ).toThrow(/bounded transport/u); + }); +}); diff --git a/src/lib/onboard/runtime-provider/state-mutation.ts b/src/lib/onboard/runtime-provider/state-mutation.ts new file mode 100644 index 00000000000..685f12e112b --- /dev/null +++ b/src/lib/onboard/runtime-provider/state-mutation.ts @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; +import path from "node:path"; + +import { + RUNTIME_PROVIDER_STATE_MUTATION_PLAN_SCHEMA_VERSION, + type RuntimeProviderPreparedStateMutationPlan, + type RuntimeProviderStateMutationPlan, + type RuntimeProviderStateMutationSelector, +} from "./contract"; + +const MAX_PLAN_BYTES = 64 * 1024; +const MAX_STATE_ROOT_BYTES = 4096; +const MAX_SELECTORS = 256; +const MAX_RELATIVE_PATH_BYTES = 512; +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/u; +const PREFIX_PATTERN = /^[A-Za-z0-9._-]{1,128}$/u; +const SHA256_PATTERN = /^[a-f0-9]{64}$/u; +const INTENTS = new Set(["protection-transition", "restore"]); + +function fail(message: string): never { + throw new Error(`Runtime provider state-mutation plan is invalid: ${message}`); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function ownPropertyDescriptors(value: object, label: string): PropertyDescriptorMap { + try { + return Object.getOwnPropertyDescriptors(value); + } catch { + fail(`${label} must expose fixed data properties`); + } +} + +function snapshotRecord(value: unknown, label: string): Record { + if (!isRecord(value)) fail(`${label} must be an object`); + const descriptors = ownPropertyDescriptors(value, label); + const snapshot: Record = Object.create(null); + for (const key of Reflect.ownKeys(descriptors)) { + if (typeof key !== "string") fail(`${label} fields are unsupported`); + const descriptor = descriptors[key]; + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, "value")) { + fail(`${label} must expose fixed data properties`); + } + snapshot[key] = descriptor.value; + } + return snapshot; +} + +function snapshotBoundedArray(value: unknown, label: string, maxLength: number): unknown[] { + if (!Array.isArray(value)) fail(`${label} must be one non-empty bounded array`); + const descriptors = ownPropertyDescriptors(value, label); + const lengthDescriptor = descriptors.length; + if ( + !lengthDescriptor || + !Object.hasOwn(lengthDescriptor, "value") || + !Number.isSafeInteger(lengthDescriptor.value) || + lengthDescriptor.value < 0 || + lengthDescriptor.value > maxLength + ) { + fail(`${label} must be one non-empty bounded array`); + } + const length = lengthDescriptor.value as number; + const ownKeys = Reflect.ownKeys(descriptors); + if ( + ownKeys.some((key) => typeof key !== "string") || + ownKeys.length !== length + 1 || + ownKeys.some((key) => key !== "length" && !/^(0|[1-9][0-9]*)$/u.test(String(key))) + ) { + fail(`${label} must be one dense bounded array`); + } + const snapshot: unknown[] = []; + for (let index = 0; index < length; index += 1) { + const descriptor = descriptors[String(index)]; + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, "value")) { + fail(`${label} must expose fixed data properties`); + } + snapshot.push(descriptor.value); + } + return snapshot; +} + +function requireExactKeys( + value: Record, + expected: readonly string[], + label: string, +): void { + const actual = Object.keys(value).sort(); + const canonical = [...expected].sort(); + if ( + actual.length !== canonical.length || + actual.some((field, index) => field !== canonical[index]) + ) { + fail(`${label} fields are unsupported`); + } +} + +function boundedString(value: unknown, label: string, maxBytes: number): string { + if ( + typeof value !== "string" || + value.length === 0 || + value !== value.trim() || + CONTROL_CHARACTERS.test(value) || + Buffer.byteLength(value, "utf8") > maxBytes + ) { + fail(`${label} must be one bounded exact string`); + } + return value; +} + +function canonicalRelativePath(value: unknown, label: string): string { + const candidate = boundedString(value, label, MAX_RELATIVE_PATH_BYTES); + if ( + candidate.startsWith("/") || + candidate.includes("\\") || + path.posix.normalize(candidate) !== candidate || + candidate.split("/").some((segment) => segment === "" || segment === "." || segment === "..") + ) { + fail(`${label} must be a canonical relative path`); + } + return candidate; +} + +function canonicalStateRoot(value: unknown): string { + const candidate = boundedString(value, "state root", MAX_STATE_ROOT_BYTES); + if ( + !candidate.startsWith("/") || + candidate === "/" || + candidate.endsWith("/") || + candidate.includes("\\") || + path.posix.normalize(candidate) !== candidate || + !candidate.startsWith("/sandbox/") + ) { + fail("state root must be one canonical absolute path below /sandbox"); + } + return candidate; +} + +function normalizeSelector(value: unknown, index: number): RuntimeProviderStateMutationSelector { + const selector = snapshotRecord(value, `selector ${String(index)}`); + if (selector.kind === "path") { + requireExactKeys(selector, ["kind", "path"], `selector ${String(index)}`); + return Object.freeze({ + kind: "path", + path: canonicalRelativePath(selector.path, `selector ${String(index)} path`), + }); + } + if (selector.kind === "prefix") { + requireExactKeys(selector, ["kind", "prefix"], `selector ${String(index)}`); + const prefix = boundedString(selector.prefix, `selector ${String(index)} prefix`, 128); + if (!PREFIX_PATTERN.test(prefix)) fail(`selector ${String(index)} prefix is not canonical`); + return Object.freeze({ kind: "prefix", prefix }); + } + fail(`selector ${String(index)} kind is unsupported`); +} + +function sha256(value: unknown): string { + return createHash("sha256").update(JSON.stringify(value), "utf8").digest("hex"); +} + +/** Validate, canonicalize, clone, freeze, and digest an untrusted state plan. */ +export function prepareRuntimeProviderStateMutationPlan( + value: unknown, +): RuntimeProviderPreparedStateMutationPlan { + const input = snapshotRecord(value, "plan"); + requireExactKeys( + input, + ["intent", "projectionSha256", "schemaVersion", "selectors", "stateRoot"], + "plan", + ); + if (input.schemaVersion !== RUNTIME_PROVIDER_STATE_MUTATION_PLAN_SCHEMA_VERSION) { + fail("plan schema version is unsupported"); + } + if (!INTENTS.has(input.intent as string)) fail("plan intent is unsupported"); + const selectorInputs = snapshotBoundedArray(input.selectors, "selectors", MAX_SELECTORS); + if (selectorInputs.length === 0) { + fail("selectors must be one non-empty bounded array"); + } + const selectors = selectorInputs.map(normalizeSelector); + const identities = selectors.map((selector) => + selector.kind === "path" ? `path:${selector.path}` : `prefix:${selector.prefix}`, + ); + if (new Set(identities).size !== identities.length) { + fail("selectors must not repeat a path or prefix"); + } + if (typeof input.projectionSha256 !== "string" || !SHA256_PATTERN.test(input.projectionSha256)) { + fail("AgentDefinition projection digest must be lowercase SHA-256"); + } + const plan = Object.freeze({ + schemaVersion: RUNTIME_PROVIDER_STATE_MUTATION_PLAN_SCHEMA_VERSION, + intent: input.intent as RuntimeProviderStateMutationPlan["intent"], + stateRoot: canonicalStateRoot(input.stateRoot), + selectors: Object.freeze(selectors), + projectionSha256: input.projectionSha256, + }); + if (Buffer.byteLength(JSON.stringify(plan), "utf8") > MAX_PLAN_BYTES) { + fail("canonical plan exceeds its bounded transport"); + } + return Object.freeze({ + plan, + planSha256: sha256(plan), + projectionSha256: plan.projectionSha256, + }); +} diff --git a/test/helpers/runtime-provider-bundle.ts b/test/helpers/runtime-provider-bundle.ts index 512b544cdbc..d8655320f65 100644 --- a/test/helpers/runtime-provider-bundle.ts +++ b/test/helpers/runtime-provider-bundle.ts @@ -156,6 +156,7 @@ export function createInMemoryRuntimeProviderBundle({ "workload-cleanup", ], }, + stateMutation: unsupported(providerId, futureReason), bootstrap: unsupported(providerId, futureReason), snapshot: unsupported(providerId, futureReason), recovery: unsupported(providerId, futureReason), diff --git a/test/runtime-provider-source-shape.test.ts b/test/runtime-provider-source-shape.test.ts index d4f3f74de5b..a60d8629de2 100644 --- a/test/runtime-provider-source-shape.test.ts +++ b/test/runtime-provider-source-shape.test.ts @@ -100,6 +100,7 @@ describe("runtime provider central source boundary", () => { current: read("src/lib/onboard/runtime-provider/current.ts"), docker: read("src/lib/onboard/runtime-provider/docker.ts"), registry: read("src/lib/onboard/runtime-provider/registry.ts"), + stateMutation: read("src/lib/onboard/runtime-provider/state-mutation.ts"), }; for (const [name, source] of Object.entries(driverNeutralActions)) { @@ -129,6 +130,10 @@ describe("runtime provider central source boundary", () => { [providerContract.current, providerContract.docker, providerContract.registry].join("\n"), ).not.toMatch(/managed-bootstrap/u); expect(providerContract.current).not.toMatch(/\b(?:podman|mxc)\b/iu); + expect(providerContract.stateMutation).not.toMatch(/\b(?:docker|podman|hermes|mxc)\b/iu); + expect(providerContract.stateMutation).not.toMatch( + /(?:child_process|execFile|spawn|shell|command|callback)/iu, + ); }); it("inventories every dormant managed-bootstrap protocol source", () => { @@ -154,6 +159,7 @@ describe("runtime provider central source boundary", () => { "src/lib/onboard/runtime-provider/docker.ts", "src/lib/onboard/runtime-provider/registry.ts", "src/lib/onboard/runtime-provider/snapshot.ts", + "src/lib/onboard/runtime-provider/state-mutation.ts", ]); }); From eb1cd41bcc392b7b03e02e867ebc8a8985894d8b Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 4 Aug 2026 08:23:59 -0400 Subject: [PATCH 2/2] fix(runtime): harden state mutation plan validation Signed-off-by: Julie Yaunches --- .../runtime-provider/state-mutation.test.ts | 121 ++++++++++++++++-- .../runtime-provider/state-mutation.ts | 35 ++++- test/runtime-provider-source-shape.test.ts | 6 +- 3 files changed, 146 insertions(+), 16 deletions(-) diff --git a/src/lib/onboard/runtime-provider/state-mutation.test.ts b/src/lib/onboard/runtime-provider/state-mutation.test.ts index e6508772fee..0fd19cb75b1 100644 --- a/src/lib/onboard/runtime-provider/state-mutation.test.ts +++ b/src/lib/onboard/runtime-provider/state-mutation.test.ts @@ -46,12 +46,59 @@ describe("runtime provider state-mutation plan", () => { ...plan(), projectionSha256: "b".repeat(64), }); + const changedScope = prepareRuntimeProviderStateMutationPlan({ + ...plan(), + selectors: [{ kind: "path", path: "scripts" }], + }); expect(protectionTransition.planSha256).not.toBe(restore.planSha256); expect(changedProjection.planSha256).not.toBe(restore.planSha256); + expect(changedScope.planSha256).not.toBe(restore.planSha256); expect(changedProjection.projectionSha256).toBe("b".repeat(64)); }); + it("does not let inherited JSON hooks change the digest or size limit (#7744)", () => { + const baseline = prepareRuntimeProviderStateMutationPlan(plan()); + const objectToJson = Object.getOwnPropertyDescriptor(Object.prototype, "toJSON"); + const arrayToJson = Object.getOwnPropertyDescriptor(Array.prototype, "toJSON"); + let pollutedDigest = ""; + let oversizedRejected = false; + + try { + Object.defineProperty(Object.prototype, "toJSON", { + configurable: true, + value: () => "polluted-object", + }); + Object.defineProperty(Array.prototype, "toJSON", { + configurable: true, + value: () => ["polluted-array"], + }); + + pollutedDigest = prepareRuntimeProviderStateMutationPlan(plan()).planSha256; + try { + prepareRuntimeProviderStateMutationPlan({ + ...plan(), + selectors: Array.from({ length: 256 }, (_, index) => ({ + kind: "path", + path: `${String(index)}-${"a".repeat(300)}`, + })), + }); + } catch (error) { + oversizedRejected = + error instanceof Error && + /canonical plan exceeds its bounded transport/u.test(error.message); + } + } finally { + if (objectToJson) Object.defineProperty(Object.prototype, "toJSON", objectToJson); + else Reflect.deleteProperty(Object.prototype, "toJSON"); + if (arrayToJson) Object.defineProperty(Array.prototype, "toJSON", arrayToJson); + else Reflect.deleteProperty(Array.prototype, "toJSON"); + } + + expect(pollutedDigest).toBe(baseline.planSha256); + expect(oversizedRejected).toBe(true); + }); + it("rejects accessor-backed values before validation can drift (#7744)", () => { const accessorPlan = plan(); Object.defineProperty(accessorPlan, "projectionSha256", { @@ -89,16 +136,33 @@ describe("runtime provider state-mutation plan", () => { }); it.each([ - ["relative state root", () => ({ ...plan(), stateRoot: "sandbox/.hermes" })], - ["filesystem root", () => ({ ...plan(), stateRoot: "/" })], - ["system state root", () => ({ ...plan(), stateRoot: "/etc/nemoclaw" })], - ["state-root traversal", () => ({ ...plan(), stateRoot: "/sandbox/../etc" })], + [ + "relative state root", + () => ({ ...plan(), stateRoot: "sandbox/.hermes" }), + /canonical absolute path below \/sandbox/u, + ], + [ + "filesystem root", + () => ({ ...plan(), stateRoot: "/" }), + /canonical absolute path below \/sandbox/u, + ], + [ + "system state root", + () => ({ ...plan(), stateRoot: "/etc/nemoclaw" }), + /canonical absolute path below \/sandbox/u, + ], + [ + "state-root traversal", + () => ({ ...plan(), stateRoot: "/sandbox/../etc" }), + /canonical absolute path below \/sandbox/u, + ], [ "relative-path traversal", () => ({ ...plan(), selectors: [{ kind: "path", path: "scripts/../../etc" }], }), + /canonical relative path/u, ], [ "control characters", @@ -106,6 +170,20 @@ describe("runtime provider state-mutation plan", () => { ...plan(), selectors: [{ kind: "path", path: "scripts\u0000escape" }], }), + /bounded exact string/u, + ], + [ + "an unpaired surrogate in the state root", + () => ({ ...plan(), stateRoot: "/sandbox/state-\ud800" }), + /Unicode scalar values/u, + ], + [ + "an unpaired surrogate in a selector path", + () => ({ + ...plan(), + selectors: [{ kind: "path", path: "state-\ud800" }], + }), + /Unicode scalar values/u, ], [ "uppercase projection digest", @@ -113,14 +191,29 @@ describe("runtime provider state-mutation plan", () => { ...plan(), projectionSha256: "A".repeat(64), }), + /lowercase SHA-256/u, ], - ])("rejects %s (#7744)", (_label, value) => { - expect(() => prepareRuntimeProviderStateMutationPlan(value())).toThrow( - /state-mutation plan is invalid/u, - ); + [ + "dot prefix", + () => ({ + ...plan(), + selectors: [{ kind: "prefix", prefix: "." }], + }), + /prefix is not canonical/u, + ], + [ + "dot-dot prefix", + () => ({ + ...plan(), + selectors: [{ kind: "prefix", prefix: ".." }], + }), + /prefix is not canonical/u, + ], + ])("rejects %s (#7744)", (_label, value, expected) => { + expect(() => prepareRuntimeProviderStateMutationPlan(value())).toThrow(expected); }); - it("rejects duplicate and oversized selector sets (#7744)", () => { + it("rejects duplicate, oversized, and UTF-8-aliasing selector sets (#7744)", () => { expect(() => prepareRuntimeProviderStateMutationPlan({ ...plan(), selectors: [] })).toThrow( /non-empty bounded array/u, ); @@ -154,5 +247,15 @@ describe("runtime provider state-mutation plan", () => { })), }), ).toThrow(/bounded transport/u); + + expect(() => + prepareRuntimeProviderStateMutationPlan({ + ...plan(), + selectors: [ + { kind: "path", path: "state-\ud800" }, + { kind: "path", path: "state-\ufffd" }, + ], + }), + ).toThrow(/Unicode scalar values/u); }); }); diff --git a/src/lib/onboard/runtime-provider/state-mutation.ts b/src/lib/onboard/runtime-provider/state-mutation.ts index 685f12e112b..e2e4d280142 100644 --- a/src/lib/onboard/runtime-provider/state-mutation.ts +++ b/src/lib/onboard/runtime-provider/state-mutation.ts @@ -109,6 +109,9 @@ function boundedString(value: unknown, label: string, maxBytes: number): string ) { fail(`${label} must be one bounded exact string`); } + if (Buffer.from(value, "utf8").toString("utf8") !== value) { + fail(`${label} must contain only Unicode scalar values`); + } return value; } @@ -152,14 +155,35 @@ function normalizeSelector(value: unknown, index: number): RuntimeProviderStateM if (selector.kind === "prefix") { requireExactKeys(selector, ["kind", "prefix"], `selector ${String(index)}`); const prefix = boundedString(selector.prefix, `selector ${String(index)} prefix`, 128); - if (!PREFIX_PATTERN.test(prefix)) fail(`selector ${String(index)} prefix is not canonical`); + if (prefix === "." || prefix === ".." || !PREFIX_PATTERN.test(prefix)) { + fail(`selector ${String(index)} prefix is not canonical`); + } return Object.freeze({ kind: "prefix", prefix }); } fail(`selector ${String(index)} kind is unsupported`); } -function sha256(value: unknown): string { - return createHash("sha256").update(JSON.stringify(value), "utf8").digest("hex"); +function serializePlan(plan: RuntimeProviderStateMutationPlan): string { + const selectors: Array> = plan.selectors.map((selector) => { + const transport: Record = Object.create(null); + transport.kind = selector.kind; + if (selector.kind === "path") transport.path = selector.path; + else transport.prefix = selector.prefix; + return transport; + }); + Object.setPrototypeOf(selectors, null); + + const transport: Record = Object.create(null); + transport.schemaVersion = plan.schemaVersion; + transport.intent = plan.intent; + transport.stateRoot = plan.stateRoot; + transport.selectors = selectors; + transport.projectionSha256 = plan.projectionSha256; + return JSON.stringify(transport); +} + +function sha256(value: string): string { + return createHash("sha256").update(value, "utf8").digest("hex"); } /** Validate, canonicalize, clone, freeze, and digest an untrusted state plan. */ @@ -197,12 +221,13 @@ export function prepareRuntimeProviderStateMutationPlan( selectors: Object.freeze(selectors), projectionSha256: input.projectionSha256, }); - if (Buffer.byteLength(JSON.stringify(plan), "utf8") > MAX_PLAN_BYTES) { + const serializedPlan = serializePlan(plan); + if (Buffer.byteLength(serializedPlan, "utf8") > MAX_PLAN_BYTES) { fail("canonical plan exceeds its bounded transport"); } return Object.freeze({ plan, - planSha256: sha256(plan), + planSha256: sha256(serializedPlan), projectionSha256: plan.projectionSha256, }); } diff --git a/test/runtime-provider-source-shape.test.ts b/test/runtime-provider-source-shape.test.ts index a60d8629de2..12a111c10b2 100644 --- a/test/runtime-provider-source-shape.test.ts +++ b/test/runtime-provider-source-shape.test.ts @@ -130,9 +130,11 @@ describe("runtime provider central source boundary", () => { [providerContract.current, providerContract.docker, providerContract.registry].join("\n"), ).not.toMatch(/managed-bootstrap/u); expect(providerContract.current).not.toMatch(/\b(?:podman|mxc)\b/iu); - expect(providerContract.stateMutation).not.toMatch(/\b(?:docker|podman|hermes|mxc)\b/iu); expect(providerContract.stateMutation).not.toMatch( - /(?:child_process|execFile|spawn|shell|command|callback)/iu, + /\b(?:docker|podman|kubernetes|k8s|hermes|mxc)\b/iu, + ); + expect(providerContract.stateMutation).not.toMatch( + /\b(?:child_process|exec|execFile|execSync|fork|spawn|shell|command|callback)\b/iu, ); });