From 820b2121eb3f375c7c87b216291623e94f84012d Mon Sep 17 00:00:00 2001 From: Dukeabadoon Date: Wed, 12 Aug 2026 14:11:07 +0800 Subject: [PATCH] refactor(core): extract work-plan and permission-trace validators (phase 3c) Move WorkPlan submission validation, scoped work-order checks, and permission trace helpers out of controller.ts into dedicated modules without changing the RunController public API. --- packages/core/src/controller.ts | 666 +----------------- .../src/controller/permission-trace.test.ts | 150 ++++ .../core/src/controller/permission-trace.ts | 160 +++++ .../controller/work-plan-validator.test.ts | 155 ++++ .../src/controller/work-plan-validator.ts | 593 ++++++++++++++++ 5 files changed, 1097 insertions(+), 627 deletions(-) create mode 100644 packages/core/src/controller/permission-trace.test.ts create mode 100644 packages/core/src/controller/permission-trace.ts create mode 100644 packages/core/src/controller/work-plan-validator.test.ts create mode 100644 packages/core/src/controller/work-plan-validator.ts diff --git a/packages/core/src/controller.ts b/packages/core/src/controller.ts index 1e4b547..2ef186e 100644 --- a/packages/core/src/controller.ts +++ b/packages/core/src/controller.ts @@ -54,6 +54,18 @@ import { isUserReportedEvidencePath, requiresDirectEvidence, } from "./controller/evidence-validator.js"; +import { + PermissionAuthorizationError, + buildWorkResultPermissionEvents, + tracePermissionDecision, +} from "./controller/permission-trace.js"; +import { + MAX_TOOL_CALLS_PER_RUN, + assertScopedWorkOrder, + assertWorkPlanSubmission, + type WorkNodeProjection, + type WorkPlanProjection, +} from "./controller/work-plan-validator.js"; const artifactInputsByPhase: Record> = { context_grounding: new Set([ @@ -164,7 +176,6 @@ const protocolPhaseByInternal: Record = { const MAX_NEXT_ACTION_INPUT_REFS = 256; const MAX_CLARIFICATIONS_PER_RUN = 1; -const MAX_TOOL_CALLS_PER_RUN = 4_000; const REFERENCE_URI_PATTERN = /^(?:artifact|trace|repo):\/\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]+$/u; @@ -487,26 +498,6 @@ function requireObjectBody(body: unknown): Record { return body as Record; } -interface WorkNodeProjection { - id: string; - dependsOn: string[]; - inputRefs?: string[]; - contextRefs?: string[]; - allowedTools?: string[]; - requiredCapabilities?: string[]; - acceptanceCriteria?: string[]; - permissions?: StructuredPermissionSet; - budgets?: { - maximumToolCalls?: number; - maximumChildren?: number; - }; -} - -interface WorkPlanProjection { - id: string; - nodes: WorkNodeProjection[]; -} - interface ArtifactProjection { id?: string; runId?: string; @@ -606,23 +597,6 @@ const alwaysReadOnlyCapabilities = new Set([ "subagent.spawn", ]); -const potentiallyMutatingPlanCapabilities = new Set([ - ...alwaysMutatingCapabilities, - "shell.execute", - "subagent.spawn", -]); - -class PermissionAuthorizationError extends Error { - constructor( - message: string, - readonly permissionDecision: TracePermissionDecision, - readonly inputRefs: string[], - ) { - super(message); - this.name = "PermissionAuthorizationError"; - } -} - function expectedReferenceType(path: string): string | null { if (path.endsWith(".originalRequestRef")) return "UserMessage"; if (path.endsWith(".problemFrameRef")) return "ProblemFrame"; @@ -670,40 +644,6 @@ function equalStringSets( ); } -function tracePermissionDecision( - action: Record, - allowed: boolean, - policyRefs: string[], - rationaleSummary: string, -): TracePermissionDecision { - const capability = - typeof action.capability === "string" ? action.capability : "unknown"; - const rawTarget = - typeof action.target === "string" ? action.target : "unknown"; - let scope = rawTarget; - if ( - (capability === "network.read" || capability === "external.write") && - rawTarget !== "unknown" - ) { - try { - const parsed = new URL( - rawTarget.includes("://") ? rawTarget : `http://${rawTarget}`, - ); - scope = - normalizeNetworkReadDomain(parsed.hostname) ?? "invalid_network_target"; - } catch { - scope = "invalid_network_target"; - } - } - return { - decision: allowed ? "allow" : "deny", - capability, - scope, - policyRefs, - rationaleSummary, - }; -} - export class RunController { constructor( private readonly ledger: SqliteLedger, @@ -1510,149 +1450,19 @@ export class RunController { }); } - private assertScopedWorkOrder( - orderValue: unknown, - options: { - label: string; - criterionField: "targetCriterionIds" | "failedCriterionIds"; - allowedCriteria: readonly string[]; - contractPermissions: StructuredPermissionSet; - authorizedPermissions: StructuredPermissionSet; - eligibleSourceRefs: ReadonlySet; - requireAllCriteria?: boolean; - requireExecutableCapability?: boolean; - }, - ): void { - if ( - typeof orderValue !== "object" || - orderValue === null || - Array.isArray(orderValue) - ) { - throw new Error(`${options.label} must be a typed scoped work order`); - } - const order = orderValue as Record; - const criterionIds = stringArray(order[options.criterionField]); - if ( - criterionIds.length === 0 || - new Set(criterionIds).size !== criterionIds.length || - criterionIds.some( - (criterion) => !options.allowedCriteria.includes(criterion), - ) - ) { - throw new Error(`${options.label} contains invalid contract criteria`); - } - if ( - options.requireAllCriteria === true && - !equalStringSets(criterionIds, options.allowedCriteria) - ) { - throw new Error( - `${options.label} must cover every authorized criterion exactly once`, - ); - } - const permissions = permissionSet( - order.permissions, - `${options.label} permissions`, - ); - if ( - !permissionSetIsSubset(permissions, options.contractPermissions) || - !permissionSetIsSubset(permissions, options.authorizedPermissions) - ) { - throw new Error(`${options.label} permissions exceed authorization`); - } - const capabilities = stringArray(order.allowedCapabilities); - if ( - options.requireExecutableCapability === true && - capabilities.length === 0 - ) { - throw new Error( - `${options.label} requires at least one executable capability`, - ); - } - if ( - capabilities.some( - (capability) => !permissionAllowsCapability(permissions, capability), - ) - ) { - throw new Error( - `${options.label} capabilities are not permission-backed`, - ); - } - if ( - !stringArray(order.sourceRefs).some((reference) => - options.eligibleSourceRefs.has(reference), - ) - ) { - throw new Error(`${options.label} lacks current supporting evidence`); - } - } - private workResultPermissionEvents( run: RunRecord, body: Record, ) { - const node = this.executionProgress(run.runId).pendingNode; - if (!node) return []; - const nodePermissions = permissionSet( - node.permissions, - `Work node ${node.id} permissions`, + return buildWorkResultPermissionEvents( + run, + body, + this.executionProgress(run.runId).pendingNode, + [ + this.latestRef(run.runId, "TaskContract"), + this.latestRef(run.runId, "WorkPlan"), + ], ); - const policies = [ - policyForMode(run.requestedMode), - policyFromPermissionSet(`work_node:${node.id}`, nodePermissions), - ]; - const policyRefs = [ - this.latestRef(run.runId, "TaskContract"), - this.latestRef(run.runId, "WorkPlan"), - ]; - const actions = Array.isArray(body.actions) - ? (body.actions as Array>) - : []; - return actions - .filter( - (action) => - action.status === "completed" || - action.status === "failed" || - action.status === "denied", - ) - .map((action) => { - const kind = - typeof action.capability === "string" - ? actionKindForCapability(action.capability) - : null; - const allowedTool = - typeof action.capability === "string" && - (node.allowedTools ?? []).includes(action.capability); - const decision = - kind && allowedTool - ? authorizeAction( - { - kind, - ...(typeof action.target === "string" - ? { target: action.target } - : {}), - }, - policies, - run.repositoryRoot, - ) - : { - allowed: false, - summary: `Denied: capability is outside work node ${node.id}.`, - }; - const permissionDecision = tracePermissionDecision( - action, - decision.allowed, - policyRefs, - decision.summary, - ); - return { - actor: "controller", - eventType: "permission_checked", - phase: run.phase, - inputRefs: policyRefs, - permissionDecision, - decisionSummary: `${permissionDecision.decision === "allow" ? "Allowed" : "Denied"} ${permissionDecision.capability} for ${permissionDecision.scope}.`, - }; - }); } private assertCrossArtifactInvariants( @@ -2082,420 +1892,22 @@ export class RunController { } if (submission.type === "WorkPlan") { - const contractRef = this.latestRef(run.runId, "TaskContract"); - if (body.taskContractRef !== contractRef) { - throw new Error("WorkPlan must target the current TaskContract"); - } - if (body.planValidation !== "valid") { - throw new Error( - "Only a validated WorkPlan may enter execution or review", - ); - } - if (body.executionMode !== "serial") { - throw new Error( - "The current controller supports deterministic serial WorkPlans only", - ); - } - const contract = this.latestBody(run.runId, "TaskContract")!; - const contractPermissions = permissionSet( - contract.permissions, - "TaskContract permissions", - ); - const criterionRecords = Array.isArray(contract.acceptanceCriteria) - ? contract.acceptanceCriteria.filter( - (criterion): criterion is Record => - typeof criterion === "object" && criterion !== null, - ) - : []; - const criteria = new Set( - criterionRecords - .map((criterion) => criterion.id) - .filter((id): id is string => typeof id === "string"), - ); - const criterionStages = new Map( - criterionRecords - .filter( - (criterion) => - typeof criterion.id === "string" && - typeof criterion.stage === "string", - ) - .map((criterion) => [ - criterion.id as string, - criterion.stage as string, - ]), - ); - const nodes = Array.isArray(body.nodes) - ? (body.nodes as unknown as WorkNodeProjection[]) - : []; - const globalBudgets = - typeof body.globalBudgets === "object" && body.globalBudgets !== null - ? (body.globalBudgets as Record) - : {}; - const requestedToolCalls = nodes.reduce((sum, node) => { - const budgets = ( - node as unknown as { budgets?: { maximumToolCalls?: unknown } } - ).budgets; - return ( - sum + - (typeof budgets?.maximumToolCalls === "number" - ? budgets.maximumToolCalls - : 0) - ); - }, 0); - if ( - typeof globalBudgets.maximumToolCalls !== "number" || - requestedToolCalls > globalBudgets.maximumToolCalls - ) { - throw new Error("WorkPlan node tool budgets exceed the global budget"); - } - const priorPlanToolCalls = this.ledger - .listArtifacts(run.runId) - .filter( - (artifact) => - artifact.type === "WorkPlan" && artifact.id !== submission.id, - ) - .reduce((sum, artifact) => { - const prior = this.ledger.getArtifact(run.runId, artifact.id)?.body; - const nodes = - typeof prior === "object" && - prior !== null && - "nodes" in prior && - Array.isArray(prior.nodes) - ? (prior.nodes as Array>) - : []; - return ( - sum + - nodes.reduce((nodeSum, node) => { - const budgets = node.budgets; - return ( - nodeSum + - (typeof budgets === "object" && - budgets !== null && - "maximumToolCalls" in budgets && - typeof budgets.maximumToolCalls === "number" - ? budgets.maximumToolCalls - : 0) - ); - }, 0) - ); - }, 0); - if (priorPlanToolCalls + requestedToolCalls > MAX_TOOL_CALLS_PER_RUN) { - throw new Error( - `WorkPlan exceeds the cumulative run tool-call budget of ${String(MAX_TOOL_CALLS_PER_RUN)}`, - ); - } - const envelopeBudgets = - typeof envelope.budgets === "object" && envelope.budgets !== null - ? (envelope.budgets as Record) - : {}; - if ( - typeof globalBudgets.maximumParallelWorkers !== "number" || - typeof globalBudgets.maximumSubagentDepth !== "number" || - globalBudgets.maximumParallelWorkers > - (typeof envelopeBudgets.maximumParallelWorkers === "number" - ? envelopeBudgets.maximumParallelWorkers - : 1) || - globalBudgets.maximumSubagentDepth > - (typeof envelopeBudgets.maximumSubagentDepth === "number" - ? envelopeBudgets.maximumSubagentDepth - : 0) - ) { - throw new Error("WorkPlan concurrency exceeds immutable host budgets"); - } - const latestControlRecord = [...this.ledger.listArtifacts(run.runId)] - .reverse() - .find( - (artifact) => - artifact.type === "QualityReview" || - artifact.type === "ReleaseAudit", - ); - const latestControl = latestControlRecord - ? this.ledger.getArtifact(run.runId, latestControlRecord.id)?.body - : null; - const remediationControl = - typeof latestControl === "object" && - latestControl !== null && - (latestControl as { decision?: unknown }).decision === "remediate" - ? (latestControl as Record) - : null; - const correctionControl = - latestControlRecord?.type === "QualityReview" && - typeof latestControl === "object" && - latestControl !== null && - (latestControl as { decision?: unknown }).decision === "proceed_to_fix" - ? (latestControl as Record) - : null; - const planCeiling = - run.requestedMode === "analyze_and_fix" && - !this.analysisFixUnlocked(run.runId) && - correctionControl === null - ? explicitPermissionProjection("analyze_only", envelope) - : contractPermissions; - let scopedCriteria: string[] | null = null; - if ((remediationControl || correctionControl) && latestControlRecord) { - const workOrder = correctionControl - ? typeof correctionControl.diagnosisGate === "object" && - correctionControl.diagnosisGate !== null - ? (correctionControl.diagnosisGate as Record) - .correctionWorkOrder - : null - : latestControlRecord.type === "QualityReview" - ? remediationControl!.remediationWorkOrder - : remediationControl!.remediationDefect; - if (typeof workOrder !== "object" || workOrder === null) { - throw new Error("Scoped replanning is missing its typed work order"); - } - const projectedOrder = workOrder as Record; - scopedCriteria = correctionControl - ? stringArray(projectedOrder.targetCriterionIds) - : stringArray(projectedOrder.failedCriterionIds); - const controllingRef = `artifact://${run.runId}/${latestControlRecord.id}`; - if ( - nodes.some( - (node) => !stringArray(node.inputRefs).includes(controllingRef), - ) - ) { - throw new Error( - "Every scoped node must reference its controlling review or audit", - ); - } - if ( - typeof projectedOrder.maximumToolCalls !== "number" || - typeof globalBudgets.maximumToolCalls !== "number" || - globalBudgets.maximumToolCalls > projectedOrder.maximumToolCalls - ) { - throw new Error("Scoped WorkPlan exceeds its work-order tool budget"); - } - const allowedCapabilities = new Set( - stringArray(projectedOrder.allowedCapabilities), - ); - if ( - nodes.some((node) => - (node.allowedTools ?? []).some( - (capability) => !allowedCapabilities.has(capability), - ), - ) - ) { - throw new Error( - "Scoped WorkPlan uses capabilities outside its work order", - ); - } - const scopedPermissions = permissionSet( - projectedOrder.permissions, - "Scoped work-order permissions", - ); - for (const node of nodes) { - if ( - !permissionSetIsSubset( - permissionSet( - node.permissions, - `Work node ${node.id} permissions`, - ), - scopedPermissions, - ) - ) { - throw new Error(`Work node ${node.id} exceeds scoped permissions`); - } - } - } - const coveredCriteria = new Set(); - for (const node of nodes) { - const nodePermissions = permissionSet( - node.permissions, - `Work node ${node.id} permissions`, - ); - if (!permissionSetIsSubset(nodePermissions, contractPermissions)) { - throw new Error( - `Work node ${node.id} permissions exceed the TaskContract`, - ); - } - if (!permissionSetIsSubset(nodePermissions, planCeiling)) { - throw new Error( - `Work node ${node.id} attempts mutation before the diagnosis review gate`, - ); - } - if ( - (node.budgets?.maximumChildren ?? 0) > - nodePermissions.subagents.maximumChildren - ) { - throw new Error( - `Work node ${node.id} child budget exceeds its subagent permission`, - ); - } - if ( - nodePermissions.subagents.maximumDepth > - (globalBudgets.maximumSubagentDepth as number) || - (nodePermissions.subagents.spawn && - (globalBudgets.maximumSubagentDepth as number) === 0) - ) { - throw new Error( - `Work node ${node.id} subagent depth exceeds the WorkPlan or host budget`, - ); - } - for (const capability of node.allowedTools ?? []) { - if (!permissionAllowsCapability(nodePermissions, capability)) { - throw new Error( - `Work node ${node.id} tool ${capability} is not permission-backed`, - ); - } - } - const requiredCapabilities = node.requiredCapabilities ?? []; - const uniqueRequiredCapabilities = new Set(requiredCapabilities); - if (uniqueRequiredCapabilities.size !== requiredCapabilities.length) { - throw new Error( - `Work node ${node.id} required capabilities must be unique`, - ); - } - if ( - run.requestedMode !== "report_only" && - run.requestedMode !== "plan_only" && - requiredCapabilities.length === 0 - ) { - throw new Error( - `Executable work node ${node.id} requires at least one completion capability`, - ); - } - if ( - requiredCapabilities.some( - (capability) => !(node.allowedTools ?? []).includes(capability), - ) - ) { - throw new Error( - `Work node ${node.id} requires a capability outside its allowed tools`, - ); - } - if ( - (node.budgets?.maximumToolCalls ?? 0) < - uniqueRequiredCapabilities.size - ) { - throw new Error( - `Work node ${node.id} tool budget cannot satisfy its required capabilities`, - ); - } - if ( - uniqueRequiredCapabilities.has("subagent.spawn") && - (node.budgets?.maximumChildren ?? 0) < 1 - ) { - throw new Error( - `Work node ${node.id} requires a child budget for subagent.spawn`, - ); - } - for (const criterion of node.acceptanceCriteria ?? []) { - if (!criteria.has(criterion)) { - throw new Error( - `Work node ${node.id} invents acceptance criterion ${criterion}`, - ); - } - if (scopedCriteria && !scopedCriteria.includes(criterion)) { - throw new Error( - `Scoped node ${node.id} exceeds its authorized criteria`, - ); - } - if ( - run.requestedMode === "analyze_and_fix" && - !this.analysisFixUnlocked(run.runId) && - correctionControl === null && - criterionStages.get(criterion) !== "diagnosis" - ) { - throw new Error( - `Initial diagnosis node ${node.id} cannot claim completion criterion ${criterion}`, - ); - } - coveredCriteria.add(criterion); - } - } - const initialDiagnosisCriteria = criterionRecords - .filter((criterion) => criterion.stage === "diagnosis") - .map((criterion) => criterion.id) - .filter((id): id is string => typeof id === "string"); - const requiredPlanCriteria = - scopedCriteria ?? - (run.requestedMode === "analyze_and_fix" && - !this.analysisFixUnlocked(run.runId) - ? initialDiagnosisCriteria - : [...criteria]); - if ( - requiredPlanCriteria.length > 0 && - !requiredPlanCriteria.every((criterion) => - coveredCriteria.has(criterion), - ) - ) { - throw new Error( - scopedCriteria - ? "Scoped WorkPlan must cover every authorized criterion" - : "WorkPlan must cover every TaskContract acceptance criterion", - ); - } - const requiredVerificationStage = - run.requestedMode === "analyze_and_fix" - ? correctionControl !== null || this.analysisFixUnlocked(run.runId) - ? "completion" - : "diagnosis" - : null; - const requiredVerifications = Array.isArray( - contract.verificationRequirements, - ) - ? (contract.verificationRequirements as Array>) - .filter((requirement) => requirement.required === true) - .filter( - (requirement) => - requiredVerificationStage === null || - requirement.stage === requiredVerificationStage, - ) - : []; - const nodesById = new Map(nodes.map((node) => [node.id, node])); - const ancestorIdsByNode = new Map>(); - for (const node of nodes) { - const pending = [...node.dependsOn]; - const ancestors = new Set(); - while (pending.length > 0) { - const current = pending.pop()!; - if (ancestors.has(current)) continue; - ancestors.add(current); - pending.push(...(nodesById.get(current)?.dependsOn ?? [])); - } - ancestorIdsByNode.set(node.id, ancestors); - } - const mutatingNodes = nodes.filter((node) => - (node.allowedTools ?? []).some((capability) => - potentiallyMutatingPlanCapabilities.has(capability), - ), - ); - for (const requirement of requiredVerifications) { - const capability = requirement.capability; - const stage = requirement.stage; - const verificationNodes = - typeof capability === "string" && - typeof stage === "string" && - nodes.filter( - (node) => - (node.requiredCapabilities ?? []).includes(capability) && - (node.acceptanceCriteria ?? []).some( - (criterion) => criterionStages.get(criterion) === stage, - ), - ); - if (!verificationNodes || verificationNodes.length === 0) { - throw new Error( - `Required verification ${String(requirement.id)} using ${String(capability)} is not scheduled by a same-stage WorkPlan node`, - ); - } - if (stage === "completion") { - for (const verificationNode of verificationNodes) { - for (const mutatingNode of mutatingNodes) { - if ( - verificationNode.id !== mutatingNode.id && - !ancestorIdsByNode - .get(verificationNode.id) - ?.has(mutatingNode.id) - ) { - throw new Error( - `Completion verification ${String(requirement.id)} must run after mutating node ${mutatingNode.id}`, - ); - } - } - } - } - } + assertWorkPlanSubmission({ + run, + submissionId: submission.id, + body, + envelope: + typeof envelope === "object" && envelope !== null + ? (envelope as Record) + : {}, + latestRef: (runId, type) => this.latestRef(runId, type), + latestContractBody: this.latestBody(run.runId, "TaskContract")!, + analysisFixUnlocked: (runId) => this.analysisFixUnlocked(runId), + listArtifacts: (runId) => this.ledger.listArtifacts(runId), + getArtifactBody: (runId, artifactId) => + this.ledger.getArtifact(runId, artifactId)?.body, + explicitPermissionProjection, + }); return; } @@ -3264,7 +2676,7 @@ export class RunController { typeof body.diagnosisGate === "object" && body.diagnosisGate !== null ? (body.diagnosisGate as Record) : null; - this.assertScopedWorkOrder(gate?.correctionWorkOrder, { + assertScopedWorkOrder(gate?.correctionWorkOrder, { label: "Diagnosis correction work order", criterionField: "targetCriterionIds", allowedCriteria: criterionRecords @@ -3283,7 +2695,7 @@ export class RunController { .filter((criterion) => criterion.stage === "diagnosis") .map((criterion) => criterion.id) .filter((id): id is string => typeof id === "string"); - this.assertScopedWorkOrder(body.remediationWorkOrder, { + assertScopedWorkOrder(body.remediationWorkOrder, { label: "Quality remediation work order", criterionField: "failedCriterionIds", allowedCriteria: @@ -3417,7 +2829,7 @@ export class RunController { throw new Error("ReleaseAudit claim matrix invents contract criteria"); } if (body.decision === "remediate") { - this.assertScopedWorkOrder(body.remediationDefect, { + assertScopedWorkOrder(body.remediationDefect, { label: "Release remediation defect", criterionField: "failedCriterionIds", allowedCriteria: expectedCriteria, diff --git a/packages/core/src/controller/permission-trace.test.ts b/packages/core/src/controller/permission-trace.test.ts new file mode 100644 index 0000000..6cc1c69 --- /dev/null +++ b/packages/core/src/controller/permission-trace.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; + +import { emptyPermissionSet } from "../permissions.js"; +import type { RunRecord } from "../types.js"; +import { + PermissionAuthorizationError, + buildWorkResultPermissionEvents, + tracePermissionDecision, +} from "./permission-trace.js"; + +function runRecord(overrides: Partial = {}): RunRecord { + return { + runId: "run-1", + phase: "agent_4_execute", + requestedMode: "analyze_and_fix", + repositoryRoot: "/workspace/project", + budgets: { + promptRevisionsRemaining: 1, + postExecutionRemediationsRemaining: 1, + escalationsRemaining: 1, + }, + ...overrides, + } as RunRecord; +} + +describe("tracePermissionDecision", () => { + it("records allow decisions with capability and scope", () => { + const decision = tracePermissionDecision( + { capability: "repository.read", target: "src/app.ts" }, + true, + ["artifact://run-1/contract-1"], + "Allowed repository.read.", + ); + expect(decision).toEqual({ + decision: "allow", + capability: "repository.read", + scope: "src/app.ts", + policyRefs: ["artifact://run-1/contract-1"], + rationaleSummary: "Allowed repository.read.", + }); + }); + + it("normalizes network targets to hostnames", () => { + const decision = tracePermissionDecision( + { capability: "network.read", target: "https://Example.COM/path" }, + false, + [], + "Denied.", + ); + expect(decision.scope).toBe("example.com"); + expect(decision.decision).toBe("deny"); + }); + + it("marks invalid network targets explicitly", () => { + const decision = tracePermissionDecision( + { capability: "external.write", target: "not a url" }, + false, + [], + "Denied.", + ); + expect(decision.scope).toBe("invalid_network_target"); + }); +}); + +describe("PermissionAuthorizationError", () => { + it("retains the permission decision and policy refs", () => { + const decision = tracePermissionDecision( + { capability: "repository.write", target: "src/app.ts" }, + false, + ["artifact://run-1/plan-1"], + "Denied.", + ); + const error = new PermissionAuthorizationError( + "mutation blocked", + decision, + ["artifact://run-1/plan-1"], + ); + expect(error.name).toBe("PermissionAuthorizationError"); + expect(error.message).toBe("mutation blocked"); + expect(error.permissionDecision).toBe(decision); + expect(error.inputRefs).toEqual(["artifact://run-1/plan-1"]); + }); +}); + +describe("buildWorkResultPermissionEvents", () => { + it("returns no events when no pending node exists", () => { + expect( + buildWorkResultPermissionEvents(runRecord(), { actions: [] }, null, [ + "artifact://run-1/contract-1", + ]), + ).toEqual([]); + }); + + it("emits permission_checked events for completed actions", () => { + const permissions = emptyPermissionSet(); + permissions.shell.inspect = true; + const events = buildWorkResultPermissionEvents( + runRecord({ requestedMode: "fix_only" }), + { + actions: [ + { + capability: "shell.inspect", + target: "process:list", + status: "completed", + }, + ], + }, + { + id: "node-1", + dependsOn: [], + allowedTools: ["shell.inspect"], + permissions, + }, + ["artifact://run-1/contract-1", "artifact://run-1/plan-1"], + ); + expect(events).toHaveLength(1); + expect(events[0]?.eventType).toBe("permission_checked"); + expect(events[0]?.permissionDecision.decision).toBe("allow"); + expect(events[0]?.inputRefs).toEqual([ + "artifact://run-1/contract-1", + "artifact://run-1/plan-1", + ]); + }); + + it("denies capabilities outside the work node", () => { + const permissions = emptyPermissionSet(); + permissions.repository.read = ["/workspace/project"]; + const events = buildWorkResultPermissionEvents( + runRecord({ requestedMode: "fix_only" }), + { + actions: [ + { + capability: "repository.write", + target: "src/app.ts", + status: "completed", + }, + ], + }, + { + id: "node-1", + dependsOn: [], + allowedTools: ["repository.read"], + permissions, + }, + ["artifact://run-1/contract-1"], + ); + expect(events[0]?.permissionDecision.decision).toBe("deny"); + expect(events[0]?.decisionSummary).toContain("Denied"); + }); +}); diff --git a/packages/core/src/controller/permission-trace.ts b/packages/core/src/controller/permission-trace.ts new file mode 100644 index 0000000..3fbc0f6 --- /dev/null +++ b/packages/core/src/controller/permission-trace.ts @@ -0,0 +1,160 @@ +import { + authorizeAction, + normalizeNetworkReadDomain, + policyForMode, + policyFromPermissionSet, + type ActionKind, +} from "../permissions.js"; +import type { RunRecord, TracePermissionDecision } from "../types.js"; +import type { WorkNodeProjection } from "./work-plan-validator.js"; + +export class PermissionAuthorizationError extends Error { + constructor( + message: string, + readonly permissionDecision: TracePermissionDecision, + readonly inputRefs: string[], + ) { + super(message); + this.name = "PermissionAuthorizationError"; + } +} + +export function tracePermissionDecision( + action: Record, + allowed: boolean, + policyRefs: string[], + rationaleSummary: string, +): TracePermissionDecision { + const capability = + typeof action.capability === "string" ? action.capability : "unknown"; + const rawTarget = + typeof action.target === "string" ? action.target : "unknown"; + let scope = rawTarget; + if ( + (capability === "network.read" || capability === "external.write") && + rawTarget !== "unknown" + ) { + try { + const parsed = new URL( + rawTarget.includes("://") ? rawTarget : `http://${rawTarget}`, + ); + scope = + normalizeNetworkReadDomain(parsed.hostname) ?? "invalid_network_target"; + } catch { + scope = "invalid_network_target"; + } + } + return { + decision: allowed ? "allow" : "deny", + capability, + scope, + policyRefs, + rationaleSummary, + }; +} + +function actionKindForCapability(capability: string): ActionKind | null { + if (capability === "runtime.restart") return "runtime.mutate"; + const known: ActionKind[] = [ + "repository.read", + "repository.write", + "repository.delete", + "shell.inspect", + "shell.execute", + "runtime.inspect", + "runtime.mutate", + "browser.inspect", + "browser.mutate", + "network.read", + "external.write", + "subagent.spawn", + ]; + return known.includes(capability as ActionKind) + ? (capability as ActionKind) + : null; +} + +function permissionSet( + value: unknown, + label: string, +): import("../types.js").StructuredPermissionSet { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${label} must be a structured permission set`); + } + return value as import("../types.js").StructuredPermissionSet; +} + +export interface WorkResultPermissionEvent { + actor: string; + eventType: string; + phase: RunRecord["phase"]; + inputRefs: string[]; + permissionDecision: TracePermissionDecision; + decisionSummary: string; +} + +export function buildWorkResultPermissionEvents( + run: RunRecord, + body: Record, + pendingNode: WorkNodeProjection | null, + policyRefs: string[], +): WorkResultPermissionEvent[] { + if (!pendingNode) return []; + const nodePermissions = permissionSet( + pendingNode.permissions, + `Work node ${pendingNode.id} permissions`, + ); + const policies = [ + policyForMode(run.requestedMode), + policyFromPermissionSet(`work_node:${pendingNode.id}`, nodePermissions), + ]; + const actions = Array.isArray(body.actions) + ? (body.actions as Array>) + : []; + return actions + .filter( + (action) => + action.status === "completed" || + action.status === "failed" || + action.status === "denied", + ) + .map((action) => { + const kind = + typeof action.capability === "string" + ? actionKindForCapability(action.capability) + : null; + const allowedTool = + typeof action.capability === "string" && + (pendingNode.allowedTools ?? []).includes(action.capability); + const decision = + kind && allowedTool + ? authorizeAction( + { + kind, + ...(typeof action.target === "string" + ? { target: action.target } + : {}), + }, + policies, + run.repositoryRoot, + ) + : { + allowed: false, + summary: `Denied: capability is outside work node ${pendingNode.id}.`, + }; + const permissionDecision = tracePermissionDecision( + action, + decision.allowed, + policyRefs, + decision.summary, + ); + return { + actor: "controller", + eventType: "permission_checked", + phase: run.phase, + inputRefs: policyRefs, + permissionDecision, + decisionSummary: `${permissionDecision.decision === "allow" ? "Allowed" : "Denied"} ${permissionDecision.capability} for ${permissionDecision.scope}.`, + }; + }); +} diff --git a/packages/core/src/controller/work-plan-validator.test.ts b/packages/core/src/controller/work-plan-validator.test.ts new file mode 100644 index 0000000..c23c290 --- /dev/null +++ b/packages/core/src/controller/work-plan-validator.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; + +import { emptyPermissionSet } from "../permissions.js"; +import type { RunRecord } from "../types.js"; +import { + assertScopedWorkOrder, + assertWorkPlanSubmission, +} from "./work-plan-validator.js"; + +function runRecord(overrides: Partial = {}): RunRecord { + return { + runId: "run-1", + phase: "agent_3_plan", + requestedMode: "fix_only", + repositoryRoot: "/workspace/project", + budgets: { + promptRevisionsRemaining: 1, + postExecutionRemediationsRemaining: 1, + escalationsRemaining: 1, + }, + ...overrides, + } as RunRecord; +} + +function contractPermissions() { + const permissions = emptyPermissionSet(); + permissions.repository.read = ["/workspace/project"]; + permissions.shell.inspect = true; + return permissions; +} + +function validNode(overrides: Record = {}) { + const permissions = contractPermissions(); + return { + id: "node-1", + dependsOn: [], + allowedTools: ["repository.read"], + requiredCapabilities: ["repository.read"], + acceptanceCriteria: ["criterion-1"], + permissions, + budgets: { maximumToolCalls: 1, maximumChildren: 0 }, + ...overrides, + }; +} + +describe("assertScopedWorkOrder", () => { + it("accepts a permission-backed scoped work order", () => { + const permissions = contractPermissions(); + expect(() => + assertScopedWorkOrder( + { + targetCriterionIds: ["criterion-1"], + permissions, + allowedCapabilities: ["repository.read"], + sourceRefs: ["artifact://run-1/evidence-1"], + }, + { + label: "Test work order", + criterionField: "targetCriterionIds", + allowedCriteria: ["criterion-1"], + contractPermissions: permissions, + authorizedPermissions: permissions, + eligibleSourceRefs: new Set(["artifact://run-1/evidence-1"]), + }, + ), + ).not.toThrow(); + }); + + it("rejects work orders with invalid criteria", () => { + const permissions = contractPermissions(); + expect(() => + assertScopedWorkOrder( + { + targetCriterionIds: ["missing"], + permissions, + allowedCapabilities: ["repository.read"], + sourceRefs: ["artifact://run-1/evidence-1"], + }, + { + label: "Test work order", + criterionField: "targetCriterionIds", + allowedCriteria: ["criterion-1"], + contractPermissions: permissions, + authorizedPermissions: permissions, + eligibleSourceRefs: new Set(["artifact://run-1/evidence-1"]), + }, + ), + ).toThrow(/invalid contract criteria/); + }); +}); + +describe("assertWorkPlanSubmission", () => { + const contractBody = { + permissions: contractPermissions(), + acceptanceCriteria: [{ id: "criterion-1", stage: "completion" }], + verificationRequirements: [], + }; + + function submit(body: Record) { + assertWorkPlanSubmission({ + run: runRecord(), + submissionId: "plan-1", + body: { + taskContractRef: "artifact://run-1/contract-1", + planValidation: "valid", + executionMode: "serial", + globalBudgets: { + maximumToolCalls: 2, + maximumParallelWorkers: 1, + maximumSubagentDepth: 0, + }, + nodes: [validNode()], + ...body, + }, + envelope: { + budgets: { + maximumParallelWorkers: 1, + maximumSubagentDepth: 0, + }, + }, + latestRef: () => "artifact://run-1/contract-1", + latestContractBody: contractBody, + analysisFixUnlocked: () => true, + listArtifacts: () => [], + getArtifactBody: () => null, + explicitPermissionProjection: () => contractPermissions(), + }); + } + + it("accepts a valid serial WorkPlan", () => { + expect(() => submit({})).not.toThrow(); + }); + + it("rejects WorkPlans that target the wrong TaskContract", () => { + expect(() => + submit({ taskContractRef: "artifact://run-1/other-contract" }), + ).toThrow(/current TaskContract/); + }); + + it("rejects non-serial execution modes", () => { + expect(() => submit({ executionMode: "parallel" })).toThrow(/serial/); + }); + + it("rejects WorkPlans that invent acceptance criteria", () => { + expect(() => + submit({ + nodes: [ + validNode({ + acceptanceCriteria: ["unknown-criterion"], + }), + ], + }), + ).toThrow(/invents acceptance criterion/); + }); +}); diff --git a/packages/core/src/controller/work-plan-validator.ts b/packages/core/src/controller/work-plan-validator.ts new file mode 100644 index 0000000..4c4b344 --- /dev/null +++ b/packages/core/src/controller/work-plan-validator.ts @@ -0,0 +1,593 @@ +import { permissionSetIsSubset } from "../permissions.js"; +import type { + IntentMode, + RunRecord, + StructuredPermissionSet, +} from "../types.js"; + +export const MAX_TOOL_CALLS_PER_RUN = 4_000; + +export interface WorkNodeProjection { + id: string; + dependsOn: string[]; + inputRefs?: string[]; + contextRefs?: string[]; + allowedTools?: string[]; + requiredCapabilities?: string[]; + acceptanceCriteria?: string[]; + permissions?: StructuredPermissionSet; + budgets?: { + maximumToolCalls?: number; + maximumChildren?: number; + }; +} + +export interface WorkPlanProjection { + id: string; + nodes: WorkNodeProjection[]; +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; +} + +function permissionSet(value: unknown, label: string): StructuredPermissionSet { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${label} must be a structured permission set`); + } + return value as StructuredPermissionSet; +} + +function permissionAllowsCapability( + permissions: StructuredPermissionSet, + capability: string, +): boolean { + switch (capability) { + case "repository.read": + return permissions.repository.read.length > 0; + case "repository.write": + return permissions.repository.write.length > 0; + case "repository.delete": + return permissions.repository.delete.length > 0; + case "shell.inspect": + return permissions.shell.inspect; + case "shell.execute": + return permissions.shell.executeAllowlist.length > 0; + case "runtime.inspect": + return permissions.runtime.inspect.length > 0; + case "runtime.restart": + return permissions.runtime.restart.length > 0; + case "browser.inspect": + return permissions.browser.inspect; + case "browser.mutate": + return permissions.browser.mutateState; + case "network.read": + return permissions.network.readDomains.length > 0; + case "external.write": + return permissions.network.externalWrite; + case "subagent.spawn": + return ( + permissions.subagents.spawn && permissions.subagents.maximumChildren > 0 + ); + default: + return false; + } +} + +function equalStringSets( + left: readonly string[], + right: readonly string[], +): boolean { + return ( + left.length === right.length && + new Set(left).size === left.length && + left.every((item) => right.includes(item)) + ); +} + +const potentiallyMutatingPlanCapabilities = new Set([ + "repository.write", + "repository.delete", + "runtime.restart", + "runtime.mutate", + "browser.mutate", + "external.write", + "shell.execute", + "subagent.spawn", +]); + +export interface ScopedWorkOrderOptions { + label: string; + criterionField: "targetCriterionIds" | "failedCriterionIds"; + allowedCriteria: readonly string[]; + contractPermissions: StructuredPermissionSet; + authorizedPermissions: StructuredPermissionSet; + eligibleSourceRefs: ReadonlySet; + requireAllCriteria?: boolean; + requireExecutableCapability?: boolean; +} + +export function assertScopedWorkOrder( + orderValue: unknown, + options: ScopedWorkOrderOptions, +): void { + if ( + typeof orderValue !== "object" || + orderValue === null || + Array.isArray(orderValue) + ) { + throw new Error(`${options.label} must be a typed scoped work order`); + } + const order = orderValue as Record; + const criterionIds = stringArray(order[options.criterionField]); + if ( + criterionIds.length === 0 || + new Set(criterionIds).size !== criterionIds.length || + criterionIds.some( + (criterion) => !options.allowedCriteria.includes(criterion), + ) + ) { + throw new Error(`${options.label} contains invalid contract criteria`); + } + if ( + options.requireAllCriteria === true && + !equalStringSets(criterionIds, options.allowedCriteria) + ) { + throw new Error( + `${options.label} must cover every authorized criterion exactly once`, + ); + } + const permissions = permissionSet( + order.permissions, + `${options.label} permissions`, + ); + if ( + !permissionSetIsSubset(permissions, options.contractPermissions) || + !permissionSetIsSubset(permissions, options.authorizedPermissions) + ) { + throw new Error(`${options.label} permissions exceed authorization`); + } + const capabilities = stringArray(order.allowedCapabilities); + if ( + options.requireExecutableCapability === true && + capabilities.length === 0 + ) { + throw new Error( + `${options.label} requires at least one executable capability`, + ); + } + if ( + capabilities.some( + (capability) => !permissionAllowsCapability(permissions, capability), + ) + ) { + throw new Error(`${options.label} capabilities are not permission-backed`); + } + if ( + !stringArray(order.sourceRefs).some((reference) => + options.eligibleSourceRefs.has(reference), + ) + ) { + throw new Error(`${options.label} lacks current supporting evidence`); + } +} + +export interface WorkPlanValidationContext { + run: RunRecord; + submissionId: string; + body: Record; + envelope: Record; + latestRef: (runId: string, type: string) => string; + latestContractBody: Record; + analysisFixUnlocked: (runId: string) => boolean; + listArtifacts: (runId: string) => Array<{ id: string; type: string }>; + getArtifactBody: (runId: string, artifactId: string) => unknown; + explicitPermissionProjection: ( + mode: IntentMode, + envelope: unknown, + ) => StructuredPermissionSet; +} + +export function assertWorkPlanSubmission(ctx: WorkPlanValidationContext): void { + const { run, submissionId, body, envelope } = ctx; + const contractRef = ctx.latestRef(run.runId, "TaskContract"); + if (body.taskContractRef !== contractRef) { + throw new Error("WorkPlan must target the current TaskContract"); + } + if (body.planValidation !== "valid") { + throw new Error("Only a validated WorkPlan may enter execution or review"); + } + if (body.executionMode !== "serial") { + throw new Error( + "The current controller supports deterministic serial WorkPlans only", + ); + } + const contract = ctx.latestContractBody; + const contractPermissions = permissionSet( + contract.permissions, + "TaskContract permissions", + ); + const criterionRecords = Array.isArray(contract.acceptanceCriteria) + ? contract.acceptanceCriteria.filter( + (criterion): criterion is Record => + typeof criterion === "object" && criterion !== null, + ) + : []; + const criteria = new Set( + criterionRecords + .map((criterion) => criterion.id) + .filter((id): id is string => typeof id === "string"), + ); + const criterionStages = new Map( + criterionRecords + .filter( + (criterion) => + typeof criterion.id === "string" && + typeof criterion.stage === "string", + ) + .map((criterion) => [criterion.id as string, criterion.stage as string]), + ); + const nodes = Array.isArray(body.nodes) + ? (body.nodes as unknown as WorkNodeProjection[]) + : []; + const globalBudgets = + typeof body.globalBudgets === "object" && body.globalBudgets !== null + ? (body.globalBudgets as Record) + : {}; + const requestedToolCalls = nodes.reduce((sum, node) => { + const budgets = ( + node as unknown as { budgets?: { maximumToolCalls?: unknown } } + ).budgets; + return ( + sum + + (typeof budgets?.maximumToolCalls === "number" + ? budgets.maximumToolCalls + : 0) + ); + }, 0); + if ( + typeof globalBudgets.maximumToolCalls !== "number" || + requestedToolCalls > globalBudgets.maximumToolCalls + ) { + throw new Error("WorkPlan node tool budgets exceed the global budget"); + } + const priorPlanToolCalls = ctx + .listArtifacts(run.runId) + .filter( + (artifact) => + artifact.type === "WorkPlan" && artifact.id !== submissionId, + ) + .reduce((sum, artifact) => { + const prior = ctx.getArtifactBody(run.runId, artifact.id); + const priorNodes = + typeof prior === "object" && + prior !== null && + "nodes" in prior && + Array.isArray(prior.nodes) + ? (prior.nodes as Array>) + : []; + return ( + sum + + priorNodes.reduce((nodeSum, node) => { + const budgets = node.budgets; + return ( + nodeSum + + (typeof budgets === "object" && + budgets !== null && + "maximumToolCalls" in budgets && + typeof budgets.maximumToolCalls === "number" + ? budgets.maximumToolCalls + : 0) + ); + }, 0) + ); + }, 0); + if (priorPlanToolCalls + requestedToolCalls > MAX_TOOL_CALLS_PER_RUN) { + throw new Error( + `WorkPlan exceeds the cumulative run tool-call budget of ${String(MAX_TOOL_CALLS_PER_RUN)}`, + ); + } + const envelopeBudgets = + typeof envelope.budgets === "object" && envelope.budgets !== null + ? (envelope.budgets as Record) + : {}; + if ( + typeof globalBudgets.maximumParallelWorkers !== "number" || + typeof globalBudgets.maximumSubagentDepth !== "number" || + globalBudgets.maximumParallelWorkers > + (typeof envelopeBudgets.maximumParallelWorkers === "number" + ? envelopeBudgets.maximumParallelWorkers + : 1) || + globalBudgets.maximumSubagentDepth > + (typeof envelopeBudgets.maximumSubagentDepth === "number" + ? envelopeBudgets.maximumSubagentDepth + : 0) + ) { + throw new Error("WorkPlan concurrency exceeds immutable host budgets"); + } + const latestControlRecord = [...ctx.listArtifacts(run.runId)] + .reverse() + .find( + (artifact) => + artifact.type === "QualityReview" || artifact.type === "ReleaseAudit", + ); + const latestControl = latestControlRecord + ? ctx.getArtifactBody(run.runId, latestControlRecord.id) + : null; + const remediationControl = + typeof latestControl === "object" && + latestControl !== null && + (latestControl as { decision?: unknown }).decision === "remediate" + ? (latestControl as Record) + : null; + const correctionControl = + latestControlRecord?.type === "QualityReview" && + typeof latestControl === "object" && + latestControl !== null && + (latestControl as { decision?: unknown }).decision === "proceed_to_fix" + ? (latestControl as Record) + : null; + const planCeiling = + run.requestedMode === "analyze_and_fix" && + !ctx.analysisFixUnlocked(run.runId) && + correctionControl === null + ? ctx.explicitPermissionProjection("analyze_only", envelope) + : contractPermissions; + let scopedCriteria: string[] | null = null; + if ((remediationControl || correctionControl) && latestControlRecord) { + const workOrder = correctionControl + ? typeof correctionControl.diagnosisGate === "object" && + correctionControl.diagnosisGate !== null + ? (correctionControl.diagnosisGate as Record) + .correctionWorkOrder + : null + : latestControlRecord.type === "QualityReview" + ? remediationControl!.remediationWorkOrder + : remediationControl!.remediationDefect; + if (typeof workOrder !== "object" || workOrder === null) { + throw new Error("Scoped replanning is missing its typed work order"); + } + const projectedOrder = workOrder as Record; + scopedCriteria = correctionControl + ? stringArray(projectedOrder.targetCriterionIds) + : stringArray(projectedOrder.failedCriterionIds); + const controllingRef = `artifact://${run.runId}/${latestControlRecord.id}`; + if ( + nodes.some( + (node) => !stringArray(node.inputRefs).includes(controllingRef), + ) + ) { + throw new Error( + "Every scoped node must reference its controlling review or audit", + ); + } + if ( + typeof projectedOrder.maximumToolCalls !== "number" || + typeof globalBudgets.maximumToolCalls !== "number" || + globalBudgets.maximumToolCalls > projectedOrder.maximumToolCalls + ) { + throw new Error("Scoped WorkPlan exceeds its work-order tool budget"); + } + const allowedCapabilities = new Set( + stringArray(projectedOrder.allowedCapabilities), + ); + if ( + nodes.some((node) => + (node.allowedTools ?? []).some( + (capability) => !allowedCapabilities.has(capability), + ), + ) + ) { + throw new Error( + "Scoped WorkPlan uses capabilities outside its work order", + ); + } + const scopedPermissions = permissionSet( + projectedOrder.permissions, + "Scoped work-order permissions", + ); + for (const node of nodes) { + if ( + !permissionSetIsSubset( + permissionSet(node.permissions, `Work node ${node.id} permissions`), + scopedPermissions, + ) + ) { + throw new Error(`Work node ${node.id} exceeds scoped permissions`); + } + } + } + const coveredCriteria = new Set(); + for (const node of nodes) { + const nodePermissions = permissionSet( + node.permissions, + `Work node ${node.id} permissions`, + ); + if (!permissionSetIsSubset(nodePermissions, contractPermissions)) { + throw new Error( + `Work node ${node.id} permissions exceed the TaskContract`, + ); + } + if (!permissionSetIsSubset(nodePermissions, planCeiling)) { + throw new Error( + `Work node ${node.id} attempts mutation before the diagnosis review gate`, + ); + } + if ( + (node.budgets?.maximumChildren ?? 0) > + nodePermissions.subagents.maximumChildren + ) { + throw new Error( + `Work node ${node.id} child budget exceeds its subagent permission`, + ); + } + if ( + nodePermissions.subagents.maximumDepth > + (globalBudgets.maximumSubagentDepth as number) || + (nodePermissions.subagents.spawn && + (globalBudgets.maximumSubagentDepth as number) === 0) + ) { + throw new Error( + `Work node ${node.id} subagent depth exceeds the WorkPlan or host budget`, + ); + } + for (const capability of node.allowedTools ?? []) { + if (!permissionAllowsCapability(nodePermissions, capability)) { + throw new Error( + `Work node ${node.id} tool ${capability} is not permission-backed`, + ); + } + } + const requiredCapabilities = node.requiredCapabilities ?? []; + const uniqueRequiredCapabilities = new Set(requiredCapabilities); + if (uniqueRequiredCapabilities.size !== requiredCapabilities.length) { + throw new Error( + `Work node ${node.id} required capabilities must be unique`, + ); + } + if ( + run.requestedMode !== "report_only" && + run.requestedMode !== "plan_only" && + requiredCapabilities.length === 0 + ) { + throw new Error( + `Executable work node ${node.id} requires at least one completion capability`, + ); + } + if ( + requiredCapabilities.some( + (capability) => !(node.allowedTools ?? []).includes(capability), + ) + ) { + throw new Error( + `Work node ${node.id} requires a capability outside its allowed tools`, + ); + } + if ( + (node.budgets?.maximumToolCalls ?? 0) < uniqueRequiredCapabilities.size + ) { + throw new Error( + `Work node ${node.id} tool budget cannot satisfy its required capabilities`, + ); + } + if ( + uniqueRequiredCapabilities.has("subagent.spawn") && + (node.budgets?.maximumChildren ?? 0) < 1 + ) { + throw new Error( + `Work node ${node.id} requires a child budget for subagent.spawn`, + ); + } + for (const criterion of node.acceptanceCriteria ?? []) { + if (!criteria.has(criterion)) { + throw new Error( + `Work node ${node.id} invents acceptance criterion ${criterion}`, + ); + } + if (scopedCriteria && !scopedCriteria.includes(criterion)) { + throw new Error( + `Scoped node ${node.id} exceeds its authorized criteria`, + ); + } + if ( + run.requestedMode === "analyze_and_fix" && + !ctx.analysisFixUnlocked(run.runId) && + correctionControl === null && + criterionStages.get(criterion) !== "diagnosis" + ) { + throw new Error( + `Initial diagnosis node ${node.id} cannot claim completion criterion ${criterion}`, + ); + } + coveredCriteria.add(criterion); + } + } + const initialDiagnosisCriteria = criterionRecords + .filter((criterion) => criterion.stage === "diagnosis") + .map((criterion) => criterion.id) + .filter((id): id is string => typeof id === "string"); + const requiredPlanCriteria = + scopedCriteria ?? + (run.requestedMode === "analyze_and_fix" && + !ctx.analysisFixUnlocked(run.runId) + ? initialDiagnosisCriteria + : [...criteria]); + if ( + requiredPlanCriteria.length > 0 && + !requiredPlanCriteria.every((criterion) => coveredCriteria.has(criterion)) + ) { + throw new Error( + scopedCriteria + ? "Scoped WorkPlan must cover every authorized criterion" + : "WorkPlan must cover every TaskContract acceptance criterion", + ); + } + const requiredVerificationStage = + run.requestedMode === "analyze_and_fix" + ? correctionControl !== null || ctx.analysisFixUnlocked(run.runId) + ? "completion" + : "diagnosis" + : null; + const requiredVerifications = Array.isArray(contract.verificationRequirements) + ? (contract.verificationRequirements as Array>) + .filter((requirement) => requirement.required === true) + .filter( + (requirement) => + requiredVerificationStage === null || + requirement.stage === requiredVerificationStage, + ) + : []; + const nodesById = new Map(nodes.map((node) => [node.id, node])); + const ancestorIdsByNode = new Map>(); + for (const node of nodes) { + const pending = [...node.dependsOn]; + const ancestors = new Set(); + while (pending.length > 0) { + const current = pending.pop()!; + if (ancestors.has(current)) continue; + ancestors.add(current); + pending.push(...(nodesById.get(current)?.dependsOn ?? [])); + } + ancestorIdsByNode.set(node.id, ancestors); + } + const mutatingNodes = nodes.filter((node) => + (node.allowedTools ?? []).some((capability) => + potentiallyMutatingPlanCapabilities.has(capability), + ), + ); + for (const requirement of requiredVerifications) { + const capability = requirement.capability; + const stage = requirement.stage; + const verificationNodes = + typeof capability === "string" && + typeof stage === "string" && + nodes.filter( + (node) => + (node.requiredCapabilities ?? []).includes(capability) && + (node.acceptanceCriteria ?? []).some( + (criterion) => criterionStages.get(criterion) === stage, + ), + ); + if (!verificationNodes || verificationNodes.length === 0) { + throw new Error( + `Required verification ${String(requirement.id)} using ${String(capability)} is not scheduled by a same-stage WorkPlan node`, + ); + } + if (stage === "completion") { + for (const verificationNode of verificationNodes) { + for (const mutatingNode of mutatingNodes) { + if ( + verificationNode.id !== mutatingNode.id && + !ancestorIdsByNode.get(verificationNode.id)?.has(mutatingNode.id) + ) { + throw new Error( + `Completion verification ${String(requirement.id)} must run after mutating node ${mutatingNode.id}`, + ); + } + } + } + } + } +}