diff --git a/CONTEXT.md b/CONTEXT.md index 0dffbeb0a..03dc36913 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -56,6 +56,10 @@ _Avoid_: reasoning text, inferred edge, model explanation An unresolved, citation-backed Investigation Assertion ranked for follow-up after accounting for objective relevance, missing evidence, expected information gain, target importance, cost, risk, and authorization readiness. _Avoid_: autonomous plan, agent hunch, task queue +**Target Recipe**: +A versioned, portable contract for reproducing one authorized research target configuration, including immutable upstream identity, fixtures, lifecycle, isolation, evidence, provenance, and required authorization intent. +_Avoid_: benchmark task, Compose file, target manifest, deployment script + **Shared Terminal Session**: A project/thread-scoped interactive shell session whose input, output, resize events, interrupts, approvals, and actor attribution are visible to both the researcher and approved agent automation. _Avoid_: generic shell bridge, hidden agent shell, human terminal takeover diff --git a/docs/architecture.md b/docs/architecture.md index 411374ef9..21db92a4a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -126,6 +126,12 @@ Workspace tools are conservative by default: - generic workspace command execution is disabled; - approved commands go through app-owned lab or SSH command tools where target mode, approvals, and artifacts can be enforced. +## Target Recipes And Research Campaigns + +A Target Recipe is the product-owned, portable contract for one reproducible research target configuration. It pins source and image revisions, fixture identity, script-backed lifecycle steps, loopback or internal-only exposure, resource and isolation limits, reset and teardown verification, evidence paths, provenance, and the authorization intent a later campaign must satisfy. Recipe admission produces a stable digest and target locator, but never creates target authorization or approval. Benchmark discovery remains a separate registry so hidden scoring material and replay controls cannot enter ordinary project memory. + +Harness adapters consume admitted recipes rather than embedding Compose or target-specific lifecycle knowledge. Current supported revisions are the genuine-discovery lane; historical vulnerable revisions remain explicitly labeled controls. The first tracer recipe and campaign persistence are added only after their decision tickets settle the remaining provisioning and autonomy details. + ## Evidence Path Generated evidence and uploads should flow through `src/server/evidence/artifact-service.ts`. diff --git a/src/server/targets/index.ts b/src/server/targets/index.ts index f3949a52b..20cb1fe1b 100644 --- a/src/server/targets/index.ts +++ b/src/server/targets/index.ts @@ -27,6 +27,7 @@ export { TargetScopeError, type TargetScopeInput, } from "./require-target"; +export * from "./target-recipe"; export type AuthorizationRecord = { id: string; diff --git a/src/server/targets/target-recipe.ts b/src/server/targets/target-recipe.ts new file mode 100644 index 000000000..79be30540 --- /dev/null +++ b/src/server/targets/target-recipe.ts @@ -0,0 +1,305 @@ +import { createHash } from "node:crypto"; + +import { z } from "zod"; + +export const TARGET_RECIPE_SCHEMA_VERSION = 1 as const; + +export const TARGET_RECIPE_ACTION_CLASSES = [ + "passive-review", + "active-probe", + "credential-test", + "browser-mutation", + "download", + "write", + "shell-command", + "exploit-validation", + "patch", +] as const; + +const nonEmptyString = z.string().trim().min(1); +const sha256Digest = z.string().regex(/^sha256:[0-9a-f]{64}$/); +const gitCommit = z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/); +const relativeScript = nonEmptyString.refine( + (value) => + !value.startsWith("/") && + !value.startsWith("\\") && + !value.startsWith("~") && + !/^[a-z]:[\\/]/i.test(value) && + !value.split(/[\\/]/).some((segment) => segment === ".."), + "must be a relative path that stays inside the recipe directory", +); + +const lifecycleStepSchema = z + .object({ + script: relativeScript, + expectedMaxDurationMs: z.number().int().positive(), + }) + .strict(); + +const targetRecipeSchema = z + .object({ + version: z.literal(TARGET_RECIPE_SCHEMA_VERSION), + identity: z + .object({ + id: nonEmptyString.regex(/^[a-z0-9][a-z0-9._/-]*$/), + displayName: nonEmptyString, + family: nonEmptyString, + variant: nonEmptyString, + upstreamUrl: z.string().url(), + licenseDecision: nonEmptyString, + }) + .strict(), + revision: z + .object({ + lane: z.enum(["current-supported", "historical-control"]), + sourceRef: nonEmptyString, + sourceCommit: gitCommit, + releaseDate: nonEmptyString, + releaseArtifactDigest: sha256Digest.optional(), + imageDigests: z.array(sha256Digest).min(1), + dependencyLockDigest: sha256Digest.optional(), + }) + .strict(), + configuration: z + .object({ + profileId: nonEmptyString, + fixtureVersion: nonEmptyString, + components: z + .array( + z + .object({ + name: nonEmptyString, + version: nonEmptyString, + role: z.enum([ + "application", + "database", + "cache", + "proxy", + "local-fake", + ]), + }) + .strict(), + ) + .min(1), + syntheticIdentities: z + .array( + z.object({ id: nonEmptyString, role: nonEmptyString }).strict(), + ) + .min(1), + externalServiceFakes: z + .array( + z + .object({ + service: nonEmptyString, + implementation: nonEmptyString, + }) + .strict(), + ) + .optional(), + }) + .strict(), + lifecycle: z + .object({ + acquire: lifecycleStepSchema.optional(), + build: lifecycleStepSchema.optional(), + initialize: lifecycleStepSchema, + start: lifecycleStepSchema, + readiness: lifecycleStepSchema, + reset: lifecycleStepSchema, + verifyReset: lifecycleStepSchema, + stop: lifecycleStepSchema, + destroy: lifecycleStepSchema, + verifyDestroy: lifecycleStepSchema, + }) + .strict(), + runtime: z + .object({ + exposure: z.enum(["internal-only", "loopback"]), + hostBind: z.literal("127.0.0.1").optional(), + setupNetworkProfile: z.enum(["offline", "package-egress"]), + researchNetworkProfile: z.enum(["offline", "approved-targets"]), + isolationClass: z.enum(["container", "microvm"]), + dockerSocket: z.literal("none"), + privileged: z.literal(false), + resourceClass: z.enum(["micro", "small", "medium"]), + limits: z + .object({ + cpus: z.number().positive(), + memoryMb: z.number().int().positive(), + pids: z.number().int().positive(), + writableMb: z.number().int().positive(), + }) + .strict(), + }) + .strict() + .superRefine((runtime, context) => { + if ( + runtime.exposure === "loopback" && + runtime.hostBind !== "127.0.0.1" + ) { + context.addIssue({ + code: "custom", + path: ["hostBind"], + message: "loopback exposure requires hostBind 127.0.0.1", + }); + } + if ( + runtime.exposure === "internal-only" && + runtime.hostBind !== undefined + ) { + context.addIssue({ + code: "custom", + path: ["hostBind"], + message: "internal-only exposure must not publish a host binding", + }); + } + }), + parallelism: z + .object({ + instanceNameTemplate: nonEmptyString.refine( + (value) => value.includes("{instanceId}"), + "must include {instanceId}", + ), + composeProjectNameTemplate: nonEmptyString.refine( + (value) => value.includes("{instanceId}"), + "must include {instanceId}", + ), + dynamicPortPolicy: z.enum(["none", "loopback-allocated"]), + capacityWeight: z.number().int().positive(), + }) + .strict(), + provenance: z + .object({ + recipeRevision: nonEmptyString, + createdAt: z.string().datetime(), + reviewedAt: z.string().datetime(), + buildArtifactIds: z.array(nonEmptyString), + intelligenceSnapshotId: nonEmptyString.optional(), + }) + .strict(), + evidence: z + .object({ + paths: z + .array( + z + .object({ + kind: z.enum([ + "log", + "http-capture", + "database-snapshot", + "filesystem-diff", + "trace", + ]), + path: relativeScript, + redactionPolicy: nonEmptyString, + }) + .strict(), + ) + .min(1), + }) + .strict(), + safety: z + .object({ + authorizationMode: z.literal("durable-ledger-required"), + permittedActionClasses: z + .array(z.enum(TARGET_RECIPE_ACTION_CLASSES)) + .min(1), + prohibitedActionClasses: z.array(z.enum(TARGET_RECIPE_ACTION_CLASSES)), + stopConditions: z.array(nonEmptyString).min(1), + syntheticSecretPolicy: z.literal("per-run-canaries"), + disclosureContact: nonEmptyString.optional(), + }) + .strict() + .superRefine((safety, context) => { + const prohibited = new Set(safety.prohibitedActionClasses); + for (const actionClass of safety.permittedActionClasses) { + if (prohibited.has(actionClass)) { + context.addIssue({ + code: "custom", + path: ["permittedActionClasses"], + message: `${actionClass} cannot be both permitted and prohibited`, + }); + } + } + if (!safety.permittedActionClasses.includes("passive-review")) { + context.addIssue({ + code: "custom", + path: ["permittedActionClasses"], + message: "every research recipe must permit passive-review", + }); + } + }), + }) + .strict() + .superRefine((recipe, context) => { + if ( + recipe.runtime.exposure === "internal-only" && + recipe.parallelism.dynamicPortPolicy !== "none" + ) { + context.addIssue({ + code: "custom", + path: ["parallelism", "dynamicPortPolicy"], + message: "internal-only recipes cannot allocate a published host port", + }); + } + }); + +export type TargetRecipe = z.infer; + +export type AdmittedTargetRecipe = { + recipe: TargetRecipe; + recipeDigest: `sha256:${string}`; + targetLocator: `recipe:${string}@${string}`; + requiredAuthorization: { + mode: "durable-ledger-required"; + actionClasses: TargetRecipe["safety"]["permittedActionClasses"]; + networkProfile: TargetRecipe["runtime"]["researchNetworkProfile"]; + }; +}; + +export function parseTargetRecipe( + value: unknown, + source = "target recipe", +): TargetRecipe { + const result = targetRecipeSchema.safeParse(value); + if (!result.success) { + const details = result.error.issues + .map((issue) => `${issue.path.join(".") || "recipe"}: ${issue.message}`) + .join("; "); + throw new Error( + `${source} is not an admissible Target Recipe v1: ${details}`, + ); + } + return result.data; +} + +export function admitTargetRecipe( + value: unknown, + source?: string, +): AdmittedTargetRecipe { + const recipe = parseTargetRecipe(value, source); + const canonical = stableJson(recipe); + const recipeDigest = + `sha256:${createHash("sha256").update(canonical).digest("hex")}` as const; + return { + recipe, + recipeDigest, + targetLocator: `recipe:${recipe.identity.id}@${recipe.provenance.recipeRevision}`, + requiredAuthorization: { + mode: recipe.safety.authorizationMode, + actionClasses: recipe.safety.permittedActionClasses, + networkProfile: recipe.runtime.researchNetworkProfile, + }, + }; +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => `${JSON.stringify(key)}:${stableJson(child)}`) + .join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} diff --git a/tests/integration/target-recipes.test.ts b/tests/integration/target-recipes.test.ts new file mode 100644 index 000000000..3a790bd58 --- /dev/null +++ b/tests/integration/target-recipes.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; + +import { admitTargetRecipe } from "../../src/server/targets"; + +function supportedRecipe() { + return { + version: 1, + identity: { + id: "litellm/minimal", + displayName: "LiteLLM minimal", + family: "litellm", + variant: "minimal", + upstreamUrl: "https://github.com/BerriAI/litellm", + licenseDecision: "Apache-2.0 reviewed for local research", + }, + revision: { + lane: "current-supported", + sourceRef: "v1.80.0", + sourceCommit: "0123456789abcdef0123456789abcdef01234567", + releaseDate: "2026-08-20", + imageDigests: [`sha256:${"a".repeat(64)}`], + dependencyLockDigest: `sha256:${"b".repeat(64)}`, + }, + configuration: { + profileId: "minimal-proxy", + fixtureVersion: "fixture-v1", + components: [ + { name: "litellm", version: "1.80.0", role: "application" }, + { name: "fake-openai", version: "fixture-v1", role: "local-fake" }, + ], + syntheticIdentities: [{ id: "research-admin", role: "admin" }], + externalServiceFakes: [ + { service: "OpenAI API", implementation: "fake-openai" }, + ], + }, + lifecycle: { + initialize: { + script: "lifecycle/initialize.sh", + expectedMaxDurationMs: 60_000, + }, + start: { script: "lifecycle/start.sh", expectedMaxDurationMs: 60_000 }, + readiness: { + script: "lifecycle/readiness.sh", + expectedMaxDurationMs: 30_000, + }, + reset: { script: "lifecycle/reset.sh", expectedMaxDurationMs: 60_000 }, + verifyReset: { + script: "lifecycle/verify-reset.sh", + expectedMaxDurationMs: 30_000, + }, + stop: { script: "lifecycle/stop.sh", expectedMaxDurationMs: 30_000 }, + destroy: { + script: "lifecycle/destroy.sh", + expectedMaxDurationMs: 60_000, + }, + verifyDestroy: { + script: "lifecycle/verify-destroy.sh", + expectedMaxDurationMs: 30_000, + }, + }, + runtime: { + exposure: "loopback", + hostBind: "127.0.0.1", + setupNetworkProfile: "package-egress", + researchNetworkProfile: "offline", + isolationClass: "container", + dockerSocket: "none", + privileged: false, + resourceClass: "micro", + limits: { cpus: 1, memoryMb: 768, pids: 256, writableMb: 1024 }, + }, + parallelism: { + instanceNameTemplate: "litellm-{instanceId}", + composeProjectNameTemplate: "litellm-{instanceId}", + dynamicPortPolicy: "loopback-allocated", + capacityWeight: 1, + }, + provenance: { + recipeRevision: "recipe-v1", + createdAt: "2026-08-26T12:00:00.000Z", + reviewedAt: "2026-08-26T12:00:00.000Z", + buildArtifactIds: ["artifact-build-log"], + intelligenceSnapshotId: "snapshot-2026-08-26", + }, + evidence: { + paths: [ + { + kind: "log", + path: "evidence/app.log", + redactionPolicy: "synthetic-canaries", + }, + ], + }, + safety: { + authorizationMode: "durable-ledger-required", + permittedActionClasses: [ + "passive-review", + "active-probe", + "shell-command", + ], + prohibitedActionClasses: ["credential-test", "browser-mutation", "patch"], + stopConditions: [ + "target escapes its isolated network", + "fixture reset cannot be verified", + ], + syntheticSecretPolicy: "per-run-canaries", + disclosureContact: "https://github.com/BerriAI/litellm/security/policy", + }, + }; +} + +describe("Target Recipe admission", () => { + it("admits one pinned, resettable recipe with a stable identity and authorization intent", () => { + const first = admitTargetRecipe(supportedRecipe(), "LiteLLM recipe"); + const second = admitTargetRecipe(supportedRecipe(), "LiteLLM recipe"); + + expect(first).toMatchObject({ + targetLocator: "recipe:litellm/minimal@recipe-v1", + requiredAuthorization: { + mode: "durable-ledger-required", + actionClasses: ["passive-review", "active-probe", "shell-command"], + networkProfile: "offline", + }, + }); + expect(first.recipeDigest).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(second.recipeDigest).toBe(first.recipeDigest); + }); + + it("rejects unpinned images and lifecycle scripts that escape the recipe", () => { + const recipe = supportedRecipe(); + recipe.revision.imageDigests = ["litellm:latest"]; + recipe.lifecycle.start.script = "../start.sh"; + + expect(() => admitTargetRecipe(recipe)).toThrow( + /imageDigests|relative path/, + ); + }); + + it("rejects conflicting permissions and published ports for internal-only targets", () => { + const recipe = supportedRecipe(); + recipe.runtime.exposure = "internal-only"; + recipe.safety.prohibitedActionClasses = ["active-probe"]; + + expect(() => admitTargetRecipe(recipe)).toThrow( + /cannot be both permitted|published host port|must not publish a host binding/, + ); + }); +});