diff --git a/CHANGELOG.md b/CHANGELOG.md index 8752f5fda..ff85f0d67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,13 +3,14 @@ - Add a Noema-owned exact-claim evidence receipt contract whose execution and research producers serialize one canonical artifact that binds every receipt semantic field, including command/result/isolation/network or source revision/excerpt/retrieval policy. Admission accepts only a receipt ID from untrusted model output. The owner API first verifies the exact authenticated OpenCode-handoff manifest digest, canonical envelope bytes, reviewed producer-to-kind policy, and repository/head/workflow/run/attempt identity before it can construct an immutable typed index; admission then reconstructs each canonical artifact and verifies time/claim/artifact identity. The version-2 manifest now binds a separate producer-authenticated `ClaimEvidenceRequirement` containing the exact claim, independently required evidence kind, and `context` or `finding` publication authority. Raw current-head source lines are context only: they are withheld from finding-reference prompts and cannot publish a finding or `request_changes`; an explicitly producer-authorized source finding remains usable and retains exact path/line checks. Finding-free model `request_changes` and `blocked` verdicts cannot bypass receipt admission to publish a vacuous blocking review. Requirement/receipt kind mismatch, fixed-artifact semantic substitution, caller-supplied receipt dictionaries, model self-classification, stale identities, cross-kind receipts, marker-only sandbox output, noncanonical artifact bytes, and expired receipts fail closed before the GitHub publisher. This remains the owner prerequisite for ContextualWisdomLab/.github#1641 and issue #555. The reviewed `sandboxed_verify` adapter exists in owner source, but its actual central stdout/stderr/marker-to-manifest wiring and the trusted research producer are not yet integrated; exact-head hosted GREEN, immutable release, and the verified central consumer bump remain required. ## Unreleased +- Policy / Approval에 procedural graph lineage의 독립 승인·취소 CAS 원장을 추가한다. 기존 Agent Runtime이 admit한 exact graph와 State / Checkpoint가 provenance-preserving read authority로 반환한 verified evaluation history를 exact candidate/history/evaluator handoff identity에 결합하고, monotonic approval-version CAS·exact decision replay·explicit revoke·bounded digest-chain integrity를 검증한다. structural clone history/approval snapshot, stale writer, malformed 또는 mismatched independent decision과 retained-byte integrity 위반은 실패-폐쇄하며 모든 event/snapshot은 `activationAuthorized:false`를 유지한다. Keyverse signer/key custody, graph publication, immutable release/deployment/canary, tool/model/provider routing과 product/domain truth는 이 경계 밖이다. issue #584, PR #601. - State / Checkpoint의 durable procedural evaluation history에 provenance-preserving read authority를 추가한다. canonical repository가 retained bytes와 graph lineage를 검증해 반환한 immutable snapshot만 process-local admission으로 표시하고, structural clone·deserialized lookalike·append-return snapshot·caller-created object·null은 downstream Policy / Approval anti-corruption boundary에서 authority로 오인되지 않도록 실패-폐쇄한다. 이 표시는 읽은 시점의 verified provenance만 증명하며 이후 append에 대한 currentness, Policy / Approval decision, graph publication/promotion 또는 activation authority를 부여하지 않는다. issue #584, PR #599. - State / Checkpoint에 authenticated procedural evaluation/rejection history를 추가한다. 기존 Agent Runtime이 admit한 exact graph/evaluation/handoff identity만 tenant/task/graph lineage별 bounded complete event chain으로 저장하고 monotonic CAS·exact replay·digest-chain integrity·durable rejection projection을 검증한다. stale writer, rejection-context omission, structural forgery, wrong lineage, retained-byte corruption, duplicate handoff, 128-event capacity 초과는 실패-폐쇄하며 eviction은 하지 않는다. 이 저장소는 Workflow / Task lifecycle, Policy / Approval, graph publication/promotion 또는 activation authority를 만들지 않고 모든 event는 `activationAuthorized:false`를 유지한다. issue #584, PR #597. - Agent Runtime의 procedural evaluation envelope이 baseline/candidate/context/paired receipt identity뿐 아니라 deterministic `rejectionKey`, screening reason, `eligibleForApproval`까지 함께 해시한다. 동일 graph/evaluation evidence라도 durable rejection history에 따라 `validation_non_regression`과 `previously_rejected` 처분이 달라지면 envelope identity도 달라져, 하나의 signed evaluator handoff를 다른 approval-eligibility 결과에 재사용할 수 없다. 반환 evidence에도 rejection/disposition identity를 보존하며 `activationAuthorized:false`, Keyverse key custody, State / Checkpoint persistence, Policy / Approval, publication 경계는 그대로 유지한다. issue #584, PR #596. - Agent Runtime이 separately authenticated evaluator handoff의 P-256 ECDSA 서명을 composition root가 공급한 trusted public key/key id로 검증하고 exact evaluation envelope digest와 최대 300초 validity window를 결합한다. 검증된 evidence도 signed expiry 이후에는 process-local authority로 재사용할 수 없으며 structural copy·wrong key/signer/envelope·expired/future handoff는 실패-폐쇄한다. Keyverse/owner가 signer identity·key custody를 유지하고 Noema는 private key·credential discovery·provider routing·durable State / Checkpoint·Policy / Approval·publication·activation authority를 소유하지 않는다. issue #584, PR #594. - Agent Runtime의 screened procedural decision과 evaluator/profile 조건을 하나의 canonical envelope digest에 결합하고, separately authenticated evaluator handoff에서 받은 exact digest와 일치할 때만 process-local evaluation evidence로 admit한다. 이 adapter는 binding만 검증하며 digest 재계산 자체를 evaluator authentication으로 취급하지 않고, Keyverse/owner signer trust·credential·provider routing·durable State / Checkpoint·Policy / Approval·publication·activation authority를 소유하지 않는다. 모든 evidence는 `activationAuthorized:false`를 유지한다. issue #584, PR #593. - Agent Runtime의 procedural candidate screening decision을 process-local provenance로 제한한다. `assessProceduralCandidate()`가 기존 lineage·held-out·safety·score 검증을 마친 결과만 locally admitted decision으로 발행하고, State / Checkpoint나 Policy / Approval 경계가 구조만 복사·직렬화·프록시·직접 생성한 lookalike를 `assertProceduralCandidateDecision()`으로 실패-폐쇄한다. 이 brand는 evaluator authentication·persistence·approval·activation authority가 아니며 모든 decision의 `activationAuthorized:false`는 유지된다. issue #584, PR #591. -- Agent Runtime의 screened procedural decision에 baseline/candidate paired evaluation receipt의 canonical SHA-256 identity를 결합한다. exact graph/context/case-set/score/safety validation 뒤 admitted holdout 순서로 정규화한 증거 semantics를 해시하므로 observation 배열 순서만 바꿔 identity를 우회할 수 없고 실제 score/safety evidence가 바뀌면 identity도 바뀐다. 이 digest는 향후 authenticated evaluator evidence와 durable State / Checkpoint retention을 정확히 결합하기 위한 local evidence identity일 뿐 signature·producer authentication·Policy / Approval·persistence·activation authority를 부여하지 않는다. issue #584, PR #592. +- Agent Runtime의 screened procedural decision에 baseline/candidate paired evaluation receipt의 canonical SHA-256 identity를 결합한다. exact graph/context/case-set/score/safety validation 뒤 admitted holdout 순서로 정규화한 증거 semantics를 해시하므로 observation 배열 순서만 바꿔 identity를 우회할 수 없고 실제 score/safety evidence가 바뀌면 identity도 달라진다. 이 digest는 향후 authenticated evaluator evidence와 durable State / Checkpoint retention을 정확히 결합하기 위한 local evidence identity일 뿐 signature·producer authentication·Policy / Approval·persistence·activation authority를 부여하지 않는다. issue #584, PR #592. - Agent Runtime의 workflow-backed procedural guidance가 매 판단마다 기존 execution-scoped `NOEMA_WORKFLOW_STATE`의 current Workflow / Task Execution evidence를 다시 읽는다. re-admitted plan과 locally admitted procedural session의 canonical execution identity가 다르면 Durable Object를 선택하거나 읽기 전에 실패-폐쇄하고, 현재 cancellation·terminal·pre-start evidence는 guidance를 억제한다. 이 ACL은 두 번째 lifecycle DB, task/lifecycle mutation, retry, tool, Policy / Approval, provider routing 또는 activation authority를 만들지 않으며 non-workflow lifecycle freshness와 deployed Durable Object compatibility/p95/recovery는 별도 acceptance로 남긴다. issue #584, ADR 0017. - Agent Runtime에 tenant/task/execution-scoped immutable procedural graph와 bounded advisory context, paired held-out candidate screening을 추가한다. 모든 candidate decision은 `activationAuthorized: false`를 유지하고 tool·retry·Policy/Approval·provider routing·credential·foreign-domain authority를 부여하지 않는다. 그래프/평가 wire contract는 아직 Noema-local이며 cross-service publication은 context-graph-contracts의 immutable release를 기다린다. issue #584, ADR 0017. - Agent Runtime의 procedural guidance를 locally admitted session brand와 canonical execution lifecycle에 결합한다. 구조만 흉내 낸 session은 callback/property를 읽기 전에 거부하고, guidance는 동일 execution의 `running` 상태에서만 반환하며 accepted·cancellation-requested·terminal 상태에서는 context request를 읽지 않고 억제한다. 결과는 계속 `advisory_only`이고 tool·retry·Policy/Approval·transition authority를 만들지 않는다. issue #584. @@ -23,7 +24,7 @@ - Workflow / Task Execution은 untrusted DAG를 execution/plan identity에 결합한 detached immutable snapshot으로 승인하고, validated array bounds 안에서만 task/dependency/state evidence를 읽는다. runnable 선택은 cross-execution·foreign·duplicate·non-canonical evidence, admitted concurrency를 초과한 running state, 성공하지 않은 prerequisite 뒤에 존재하는 causally impossible executed state를 실패-폐쇄하며, 선택 결과는 reservation이나 side-effect authority가 아닌 후보임을 명시한다. Agent Runtime lifecycle·State & Checkpoint·Workflow admission은 null·throwing accessor·revoked proxy 같은 malformed runtime input의 임의 JavaScript 예외를 각 bounded-context domain error로 정규화한다. - State & Checkpoint admission은 accepted/replay 결과와 내부 checkpoint를 모두 caller-owned alias에서 분리한 frozen snapshot으로 반환한다. TypeScript `readonly`만으로는 막을 수 없는 JavaScript 런타임 alias mutation이 승인된 checkpoint authority나 `accepted`/`replay` 분류를 사후 변경하지 못하도록 실패-폐쇄한다. - Noema의 필수 PR 워크플로 `ci`, `reviewer-ci`, `patch-validator-image`를 부동 `ubuntu-latest` 대신 명시적 `ubuntu-24.04` GitHub-hosted runner에 고정하고, 인용 여부와 무관하게 `ubuntu-latest` 회귀를 탐지하는 계약 테스트를 추가해 pre-checkout runner-assignment stall의 repository-owned selector 원인을 제거한다. 중앙 `Security Scan`의 runner/control-plane 권한은 별도 `.github` owner 경계에 유지한다. -- 비공개 취약점 보고 감사가 16 KiB 응답 상한, bounded stream 취소, canonical repository/source identity의 독립 검증, SHA-1/SHA-256 exact revision, symlink·retained-path 보호를 실패-폐쇄로 강제한다. 이 감사 결과는 live private reporting 활성화, notification staffing, 실제 advisory 대응 또는 release/deployment 완료 증거를 대신하지 않는다. +- 비공개 취약점 보고 감사가 16 KiB 응답 상한, bounded stream 취소, canonical repository/source identity의 독립 검증, SHA-1/SHA-256 exact revision, symlink·retained-path 보호를 실패-폐쇄로 강제한다. 이 감사 결과는 live private reporting 활성화·notification staffing·실제 advisory 대응 또는 release/deployment 완료 증거를 대신하지 않는다. - External scheduler evidence audits now retain source authority through final report publication: reports are owner-only, no-follow, exclusive one-shot receipts, so a concurrent rename cannot move the accepted source inode onto the report pathname and have it replaced. Source/report path and inode alias checks, single-link retained-source validation, and Unicode control sanitization remain fail closed. - revenue/transfer acquisition evidence의 `source_documents`를 임의 문자열 label 대신 stable retained artifact의 `{path, sha256}` binding으로 검증한다. Digest 일치는 보존된 bytes의 무결성만 증명하며 CRM·계약·매출·법률 기록의 진실성이나 승인 권한은 계속 별도 buyer evidence로 요구한다. - production runtime credential envelope parsing을 fail-closed로 강화한다. GitHub App PKCS#1 key의 canonical PKCS#8 변환은 유지하되, bare carriage return처럼 비정규 body bytes가 포함된 PKCS#8 PEM은 readiness/import 단계의 암묵적 정규화에 넘기지 않고 즉시 거부해 malformed secret이 ready 상태로 승인되지 않게 한다. diff --git a/src/policy-approval/procedural-policy-approval.ts b/src/policy-approval/procedural-policy-approval.ts new file mode 100644 index 000000000..3b1e0256d --- /dev/null +++ b/src/policy-approval/procedural-policy-approval.ts @@ -0,0 +1,564 @@ +import { + assertProceduralGraph, + type ProceduralGraph, +} from "../agent-runtime/procedural-graph"; +import { + assertProceduralEvaluationHistorySnapshot, +} from "../state-checkpoint/procedural-evaluation-history-authority"; +import type { + ProceduralEvaluationHistorySnapshot, +} from "../state-checkpoint/procedural-evaluation-history"; + +const APPROVAL_SCHEMA_VERSION = "noema.procedural-policy-approval/v1" as const; +const DECISION_SCHEMA_VERSION = "noema.procedural-policy-decision/v1" as const; +const EVENT_SCHEMA_VERSION = "noema.procedural-policy-approval-event/v1" as const; +const MAX_PROCEDURAL_POLICY_APPROVAL_EVENTS = 128; +const SHA256 = /^[0-9a-f]{64}$/u; +const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/u; + +/** Exact request exposed to Noema's independently composed Policy / Approval authority. */ +export interface ProceduralPolicyDecisionRequest { + readonly tenantId: string; + readonly taskType: string; + readonly graphId: string; + readonly candidateDigest: string; + readonly historyVersion: number; + readonly historyHeadEventDigest: string; + readonly envelopeDigest: string; + readonly handoffDigest: string; + readonly signerKeyId: string; + readonly expectedApprovalVersion: number; +} + +/** + * Independent Noema Policy / Approval decision bound to one exact graph and verified State / Checkpoint + * history position. The decision may admit a pilot or revoke it; neither action grants activation. + */ +export interface TrustedProceduralPolicyDecision extends ProceduralPolicyDecisionRequest { + readonly schemaVersion: typeof DECISION_SCHEMA_VERSION; + readonly decisionId: string; + readonly action: "approve_for_pilot" | "revoke"; + readonly policyVersion: string; +} + +/** + * Composition-root port for current Noema Policy / Approval truth. Implementations remain responsible + * for independent policy authority; this boundary only consumes one exact decision and never mints it. + */ +export interface ProceduralPolicyDecisionAuthority { + /** + * Resolves an independently authorized decision for the exact request without transferring identity, + * key-custody, provider-routing, publication, or activation ownership into this repository. + * @param request Exact graph/history/CAS position proposed for a policy decision. + * @returns A matching trusted decision, or null when independent approval is absent or revoked. + */ + resolveProceduralPolicyDecision( + request: Readonly, + ): TrustedProceduralPolicyDecision | null; +} + +/** + * Immutable Policy / Approval CAS event binding one independent decision to the exact candidate graph, + * verified evaluation-history position, evaluator handoff identity, and previous approval event digest. + */ +export interface ProceduralPolicyApprovalEvent { + readonly schemaVersion: typeof EVENT_SCHEMA_VERSION; + readonly version: number; + readonly decisionId: string; + readonly action: TrustedProceduralPolicyDecision["action"]; + readonly policyVersion: string; + readonly candidateDigest: string; + readonly historyVersion: number; + readonly historyHeadEventDigest: string; + readonly envelopeDigest: string; + readonly handoffDigest: string; + readonly signerKeyId: string; + readonly expectedApprovalVersion: number; + readonly status: "approved_for_pilot" | "revoked"; + readonly activationAuthorized: false; + readonly priorEventDigest: string | null; + readonly eventDigest: string; +} + +/** Verified immutable point-in-time Policy / Approval snapshot for one procedural graph lineage. */ +export interface ProceduralPolicyApprovalSnapshot { + readonly schemaVersion: typeof APPROVAL_SCHEMA_VERSION; + readonly tenantId: string; + readonly taskType: string; + readonly graphId: string; + readonly version: number; + readonly status: "approved_for_pilot" | "revoked"; + readonly candidateDigest: string; + readonly historyVersion: number; + readonly historyHeadEventDigest: string; + readonly policyVersion: string; + readonly headEventDigest: string; + readonly activationAuthorized: false; + readonly events: readonly ProceduralPolicyApprovalEvent[]; +} + +/** Result of one approval CAS append, distinguishing a new winner from exact idempotent replay. */ +export interface ProceduralPolicyApprovalAppendResult { + readonly kind: "accepted" | "replay"; + readonly event: ProceduralPolicyApprovalEvent; + readonly snapshot: ProceduralPolicyApprovalSnapshot; +} + +/** Raised when procedural Policy / Approval provenance, semantics, or CAS state fails closed. */ +export class ProceduralPolicyApprovalConflictError extends Error { + constructor(message: string) { + super(message); + this.name = "ProceduralPolicyApprovalConflictError"; + } +} + +type MutableApproval = { + schemaVersion: typeof APPROVAL_SCHEMA_VERSION; + tenantId: string; + taskType: string; + graphId: string; + version: number; + status: "approved_for_pilot" | "revoked"; + candidateDigest: string; + historyVersion: number; + historyHeadEventDigest: string; + policyVersion: string; + headEventDigest: string; + activationAuthorized: false; + events: ProceduralPolicyApprovalEvent[]; +}; + +type ApprovalStorage = Pick; +type ApprovalTransaction = Pick; + +const admittedApprovalSnapshots = new WeakSet(); + +function rejectApproval(message: string): never { + throw new ProceduralPolicyApprovalConflictError(message); +} + +function requireApproval(condition: boolean, message: string): asserts condition { + if (!condition) rejectApproval(message); +} + +function requireIdentity(value: unknown, label: string): string { + requireApproval(typeof value === "string" && IDENTITY.test(value), `${label} is not canonical`); + return value; +} + +function requireDigest(value: unknown, label: string): string { + requireApproval(typeof value === "string" && SHA256.test(value), `${label} is not canonical`); + return value; +} + +function requireVersion(value: unknown, label: string): number { + requireApproval( + typeof value === "number" + && Number.isSafeInteger(value) + && value >= 0 + && value <= MAX_PROCEDURAL_POLICY_APPROVAL_EVENTS, + `${label} is not canonical`, + ); + return value; +} + +async function sha256(value: unknown): Promise { + const bytes = new TextEncoder().encode(JSON.stringify(value)); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function storageKey(candidate: ProceduralGraph): Promise { + return `procedural-policy-approval:v1:${await sha256([ + APPROVAL_SCHEMA_VERSION, + candidate.tenantId, + candidate.taskType, + candidate.graphId, + ])}`; +} + +function exactRecord(input: unknown, keys: readonly string[]): Record { + requireApproval(input !== null && typeof input === "object" && !Array.isArray(input), "independent procedural policy decision is malformed"); + const proto = Object.getPrototypeOf(input); + requireApproval(proto === Object.prototype || proto === null, "independent procedural policy decision is malformed"); + const descriptors = Object.getOwnPropertyDescriptors(input); + const actual = Reflect.ownKeys(descriptors); + requireApproval( + actual.length === keys.length + && actual.every((key) => typeof key === "string" && keys.includes(key)), + "independent procedural policy decision is malformed", + ); + const record: Record = Object.create(null); + for (const key of keys) { + const descriptor = descriptors[key]; + requireApproval( + descriptor !== undefined && Object.hasOwn(descriptor, "value"), + "independent procedural policy decision is malformed", + ); + record[key] = descriptor.value; + } + return record; +} + +function snapshotDecision(input: unknown): TrustedProceduralPolicyDecision { + const value = exactRecord(input, [ + "schemaVersion", + "decisionId", + "action", + "policyVersion", + "tenantId", + "taskType", + "graphId", + "candidateDigest", + "historyVersion", + "historyHeadEventDigest", + "envelopeDigest", + "handoffDigest", + "signerKeyId", + "expectedApprovalVersion", + ]); + requireApproval(value.schemaVersion === DECISION_SCHEMA_VERSION, "independent procedural policy decision is malformed"); + requireApproval( + value.action === "approve_for_pilot" || value.action === "revoke", + "independent procedural policy decision is malformed", + ); + return Object.freeze({ + schemaVersion: DECISION_SCHEMA_VERSION, + decisionId: requireIdentity(value.decisionId, "decision id"), + action: value.action, + policyVersion: requireIdentity(value.policyVersion, "policy version"), + tenantId: requireIdentity(value.tenantId, "tenant id"), + taskType: requireIdentity(value.taskType, "task type"), + graphId: requireIdentity(value.graphId, "graph id"), + candidateDigest: requireDigest(value.candidateDigest, "candidate digest"), + historyVersion: requireVersion(value.historyVersion, "history version"), + historyHeadEventDigest: requireDigest(value.historyHeadEventDigest, "history head event digest"), + envelopeDigest: requireDigest(value.envelopeDigest, "envelope digest"), + handoffDigest: requireDigest(value.handoffDigest, "handoff digest"), + signerKeyId: requireIdentity(value.signerKeyId, "signer key id"), + expectedApprovalVersion: requireVersion(value.expectedApprovalVersion, "expected approval version"), + }); +} + +function decisionMatchesRequest( + decision: TrustedProceduralPolicyDecision, + request: ProceduralPolicyDecisionRequest, +): boolean { + return decision.tenantId === request.tenantId + && decision.taskType === request.taskType + && decision.graphId === request.graphId + && decision.candidateDigest === request.candidateDigest + && decision.historyVersion === request.historyVersion + && decision.historyHeadEventDigest === request.historyHeadEventDigest + && decision.envelopeDigest === request.envelopeDigest + && decision.handoffDigest === request.handoffDigest + && decision.signerKeyId === request.signerKeyId + && decision.expectedApprovalVersion === request.expectedApprovalVersion; +} + +function eventHashMaterial( + candidate: ProceduralGraph, + event: Omit, +): unknown { + return [ + event.schemaVersion, + candidate.tenantId, + candidate.taskType, + candidate.graphId, + event.version, + event.decisionId, + event.action, + event.policyVersion, + event.candidateDigest, + event.historyVersion, + event.historyHeadEventDigest, + event.envelopeDigest, + event.handoffDigest, + event.signerKeyId, + event.expectedApprovalVersion, + event.status, + event.activationAuthorized, + event.priorEventDigest, + ]; +} + +function frozenSnapshot(approval: MutableApproval): ProceduralPolicyApprovalSnapshot { + const snapshot = Object.freeze({ + schemaVersion: approval.schemaVersion, + tenantId: approval.tenantId, + taskType: approval.taskType, + graphId: approval.graphId, + version: approval.version, + status: approval.status, + candidateDigest: approval.candidateDigest, + historyVersion: approval.historyVersion, + historyHeadEventDigest: approval.historyHeadEventDigest, + policyVersion: approval.policyVersion, + headEventDigest: approval.headEventDigest, + activationAuthorized: false as const, + events: Object.freeze(approval.events.map((event) => Object.freeze({ ...event }))), + }); + admittedApprovalSnapshots.add(snapshot); + return snapshot; +} + +async function verifyStoredApproval( + input: unknown, + candidate: ProceduralGraph, +): Promise { + requireApproval(input !== null && typeof input === "object" && !Array.isArray(input), "durable procedural policy approval integrity check failed"); + const approval = input as MutableApproval; + requireApproval(approval.schemaVersion === APPROVAL_SCHEMA_VERSION, "durable procedural policy approval integrity check failed"); + requireApproval(approval.tenantId === candidate.tenantId, "durable procedural policy approval integrity check failed"); + requireApproval(approval.taskType === candidate.taskType, "durable procedural policy approval integrity check failed"); + requireApproval(approval.graphId === candidate.graphId, "durable procedural policy approval integrity check failed"); + requireApproval(Number.isSafeInteger(approval.version) && approval.version >= 1, "durable procedural policy approval integrity check failed"); + requireApproval(approval.version <= MAX_PROCEDURAL_POLICY_APPROVAL_EVENTS, "durable procedural policy approval integrity check failed"); + requireApproval(Array.isArray(approval.events) && approval.events.length === approval.version, "durable procedural policy approval integrity check failed"); + requireApproval(approval.activationAuthorized === false, "durable procedural policy approval integrity check failed"); + + let priorEventDigest: string | null = null; + let expectedStatus: MutableApproval["status"] | null = null; + const decisions = new Set(); + for (let index = 0; index < approval.events.length; index += 1) { + const event = approval.events[index]; + requireApproval(event !== null && typeof event === "object", "durable procedural policy approval integrity check failed"); + requireApproval(event.schemaVersion === EVENT_SCHEMA_VERSION, "durable procedural policy approval integrity check failed"); + requireApproval(event.version === index + 1, "durable procedural policy approval integrity check failed"); + requireApproval(IDENTITY.test(event.decisionId), "durable procedural policy approval integrity check failed"); + requireApproval(!decisions.has(event.decisionId), "durable procedural policy approval integrity check failed"); + decisions.add(event.decisionId); + requireApproval(event.action === "approve_for_pilot" || event.action === "revoke", "durable procedural policy approval integrity check failed"); + requireApproval(IDENTITY.test(event.policyVersion), "durable procedural policy approval integrity check failed"); + requireApproval(SHA256.test(event.candidateDigest), "durable procedural policy approval integrity check failed"); + requireApproval(Number.isSafeInteger(event.historyVersion) && event.historyVersion >= 1, "durable procedural policy approval integrity check failed"); + requireApproval(SHA256.test(event.historyHeadEventDigest), "durable procedural policy approval integrity check failed"); + requireApproval(SHA256.test(event.envelopeDigest), "durable procedural policy approval integrity check failed"); + requireApproval(SHA256.test(event.handoffDigest), "durable procedural policy approval integrity check failed"); + requireApproval(IDENTITY.test(event.signerKeyId), "durable procedural policy approval integrity check failed"); + requireApproval(event.expectedApprovalVersion === index, "durable procedural policy approval integrity check failed"); + requireApproval(event.activationAuthorized === false, "durable procedural policy approval integrity check failed"); + requireApproval(event.priorEventDigest === priorEventDigest, "durable procedural policy approval integrity check failed"); + requireApproval(SHA256.test(event.eventDigest), "durable procedural policy approval integrity check failed"); + requireApproval(event.eventDigest === await sha256(eventHashMaterial(candidate, event)), "durable procedural policy approval integrity check failed"); + if (event.action === "approve_for_pilot") { + requireApproval(expectedStatus !== "approved_for_pilot", "durable procedural policy approval integrity check failed"); + expectedStatus = "approved_for_pilot"; + } else { + requireApproval(expectedStatus === "approved_for_pilot", "durable procedural policy approval integrity check failed"); + expectedStatus = "revoked"; + } + requireApproval(event.status === expectedStatus, "durable procedural policy approval integrity check failed"); + priorEventDigest = event.eventDigest; + } + const latest = approval.events.at(-1); + requireApproval(latest !== undefined, "durable procedural policy approval integrity check failed"); + requireApproval(approval.status === expectedStatus, "durable procedural policy approval integrity check failed"); + requireApproval(approval.candidateDigest === latest.candidateDigest, "durable procedural policy approval integrity check failed"); + requireApproval(approval.historyVersion === latest.historyVersion, "durable procedural policy approval integrity check failed"); + requireApproval(approval.historyHeadEventDigest === latest.historyHeadEventDigest, "durable procedural policy approval integrity check failed"); + requireApproval(approval.policyVersion === latest.policyVersion, "durable procedural policy approval integrity check failed"); + requireApproval(approval.headEventDigest === latest.eventDigest, "durable procedural policy approval integrity check failed"); + return approval; +} + +function requestFrom( + candidate: ProceduralGraph, + history: ProceduralEvaluationHistorySnapshot, + expectedApprovalVersion: number, +): ProceduralPolicyDecisionRequest { + requireApproval(history.stream.tenantId === candidate.tenantId, "verified procedural history does not bind the candidate graph"); + requireApproval(history.stream.taskType === candidate.taskType, "verified procedural history does not bind the candidate graph"); + requireApproval(history.stream.graphId === candidate.graphId, "verified procedural history does not bind the candidate graph"); + requireApproval(history.version >= 1 && history.events.length === history.version, "verified procedural history is empty or inconsistent"); + const latest = history.events.at(-1); + requireApproval(latest !== undefined, "verified procedural history is empty or inconsistent"); + requireApproval(latest.candidateRevision === candidate.revision, "verified procedural history does not bind the candidate graph"); + requireApproval(latest.candidateDigest === candidate.digest, "verified procedural history does not bind the candidate graph"); + requireApproval(latest.baselineDigest === candidate.parentDigest, "verified procedural history does not bind the candidate graph"); + requireApproval(history.headEventDigest === latest.eventDigest, "verified procedural history is inconsistent"); + requireApproval(latest.activationAuthorized === false, "verified procedural history cannot carry activation authority"); + return Object.freeze({ + tenantId: candidate.tenantId, + taskType: candidate.taskType, + graphId: candidate.graphId, + candidateDigest: candidate.digest, + historyVersion: history.version, + historyHeadEventDigest: history.headEventDigest, + envelopeDigest: latest.envelopeDigest, + handoffDigest: latest.handoffDigest, + signerKeyId: latest.signerKeyId, + expectedApprovalVersion, + }); +} + +function resolveDecision( + authority: ProceduralPolicyDecisionAuthority, + request: ProceduralPolicyDecisionRequest, +): TrustedProceduralPolicyDecision { + let value: TrustedProceduralPolicyDecision | null; + try { + value = authority.resolveProceduralPolicyDecision(request); + } catch { + return rejectApproval("independent procedural policy decision is required"); + } + if (value === null) return rejectApproval("independent procedural policy decision is required"); + const decision = snapshotDecision(value); + requireApproval(decisionMatchesRequest(decision, request), "independent procedural policy decision does not bind the exact request"); + return decision; +} + +function replayMatches( + event: ProceduralPolicyApprovalEvent, + decision: TrustedProceduralPolicyDecision, +): boolean { + return event.decisionId === decision.decisionId + && event.action === decision.action + && event.policyVersion === decision.policyVersion + && event.candidateDigest === decision.candidateDigest + && event.historyVersion === decision.historyVersion + && event.historyHeadEventDigest === decision.historyHeadEventDigest + && event.envelopeDigest === decision.envelopeDigest + && event.handoffDigest === decision.handoffDigest + && event.signerKeyId === decision.signerKeyId + && event.expectedApprovalVersion === decision.expectedApprovalVersion; +} + +/** + * Durable Noema Policy / Approval repository for one procedural graph lineage. It records only Noema's + * approval/revocation decisions and exact evidence references; State / Checkpoint remains the evaluation + * owner, and publication/activation must perform their own fresh cross-authority checks. + */ +export class DurableProceduralPolicyApprovalRepository { + constructor( + private readonly storage: ApprovalStorage, + private readonly authority: ProceduralPolicyDecisionAuthority, + ) {} + + /** + * Reads and verifies current Policy / Approval state for an admitted graph lineage. + * @param candidate Locally admitted procedural graph selecting the approval stream. + * @returns An admitted immutable snapshot, or null when no decision exists. + */ + async read(candidate: ProceduralGraph): Promise { + assertProceduralGraph(candidate); + const retained = await this.storage.get(await storageKey(candidate)); + if (retained === undefined) return null; + return frozenSnapshot(await verifyStoredApproval(retained, candidate)); + } + + /** + * Resolves an independent exact policy decision and atomically appends approval or revocation under + * monotonic CAS. The State / Checkpoint snapshot must carry fresh process-local read provenance, but + * this transaction cannot prove that a separate State / Checkpoint object has not advanced afterward; + * publication/activation therefore must re-read and compare the approved history identity. + * @param candidate Locally admitted graph proposed for a pilot approval or revocation decision. + * @param history Fresh repository-verified State / Checkpoint snapshot for the same graph lineage. + * @param expectedApprovalVersion Exact Policy / Approval version observed before this append attempt. + * @returns Accepted append or exact replay with a verified point-in-time approval snapshot. + */ + async append( + candidate: ProceduralGraph, + history: ProceduralEvaluationHistorySnapshot, + expectedApprovalVersion: number, + ): Promise { + assertProceduralGraph(candidate); + assertProceduralEvaluationHistorySnapshot(history); + const expectedVersion = requireVersion(expectedApprovalVersion, "expected approval version"); + const request = requestFrom(candidate, history, expectedVersion); + const decision = resolveDecision(this.authority, request); + if (decision.action === "approve_for_pilot") { + const latest = history.events.at(-1); + requireApproval(latest !== undefined, "verified procedural history is empty or inconsistent"); + requireApproval(latest.eligibleForApproval, "latest procedural evaluation is not eligible for approval"); + requireApproval(latest.decisionReason === "validation_non_regression", "latest procedural evaluation is not eligible for approval"); + } + const key = await storageKey(candidate); + + return this.storage.transaction(async (transaction: ApprovalTransaction) => { + const retained = await transaction.get(key); + let approval: MutableApproval | undefined; + if (retained !== undefined) { + approval = await verifyStoredApproval(retained, candidate); + const replay = approval.events.find((event) => event.decisionId === decision.decisionId); + if (replay !== undefined) { + requireApproval(replayMatches(replay, decision), "policy decision replay names different semantics"); + return { + kind: "replay" as const, + event: Object.freeze({ ...replay }), + snapshot: frozenSnapshot(approval), + }; + } + requireApproval(approval.version === expectedVersion, "expected approval version lost the CAS race"); + } else { + requireApproval(expectedVersion === 0, "expected approval version lost the CAS race"); + } + + requireApproval((approval?.events.length ?? 0) < MAX_PROCEDURAL_POLICY_APPROVAL_EVENTS, "durable procedural policy approval capacity is exhausted"); + const priorStatus = approval?.status ?? null; + if (decision.action === "approve_for_pilot") { + requireApproval(priorStatus !== "approved_for_pilot", "procedural graph is already approved for pilot"); + } else { + requireApproval(priorStatus === "approved_for_pilot", "procedural graph is not approved for pilot"); + } + const status = decision.action === "approve_for_pilot" ? "approved_for_pilot" as const : "revoked" as const; + const withoutDigest: Omit = { + schemaVersion: EVENT_SCHEMA_VERSION, + version: expectedVersion + 1, + decisionId: decision.decisionId, + action: decision.action, + policyVersion: decision.policyVersion, + candidateDigest: decision.candidateDigest, + historyVersion: decision.historyVersion, + historyHeadEventDigest: decision.historyHeadEventDigest, + envelopeDigest: decision.envelopeDigest, + handoffDigest: decision.handoffDigest, + signerKeyId: decision.signerKeyId, + expectedApprovalVersion: decision.expectedApprovalVersion, + status, + activationAuthorized: false, + priorEventDigest: approval?.headEventDigest ?? null, + }; + const event = Object.freeze({ + ...withoutDigest, + eventDigest: await sha256(eventHashMaterial(candidate, withoutDigest)), + }); + const events = [...(approval?.events ?? []), event]; + const next: MutableApproval = { + schemaVersion: APPROVAL_SCHEMA_VERSION, + tenantId: candidate.tenantId, + taskType: candidate.taskType, + graphId: candidate.graphId, + version: event.version, + status, + candidateDigest: decision.candidateDigest, + historyVersion: decision.historyVersion, + historyHeadEventDigest: decision.historyHeadEventDigest, + policyVersion: decision.policyVersion, + headEventDigest: event.eventDigest, + activationAuthorized: false, + events, + }; + await transaction.put(key, next); + return { + kind: "accepted" as const, + event, + snapshot: frozenSnapshot(next), + }; + }); + } +} + +/** + * Requires a snapshot emitted by this module after durable verification or a successful local CAS. + * Structural clones and caller-created lookalikes never acquire Policy / Approval authority. + * @param value Unknown candidate Policy / Approval snapshot. + * @returns Nothing; normal return means the snapshot has current process-local repository provenance. + */ +export function assertProceduralPolicyApprovalSnapshot( + value: unknown, +): asserts value is ProceduralPolicyApprovalSnapshot { + if (value === null || typeof value !== "object" || !admittedApprovalSnapshots.has(value)) { + rejectApproval("unadmitted procedural policy approval snapshot"); + } +} diff --git a/test/procedural-policy-approval.test.mjs b/test/procedural-policy-approval.test.mjs new file mode 100644 index 000000000..904213891 --- /dev/null +++ b/test/procedural-policy-approval.test.mjs @@ -0,0 +1,329 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { createProceduralGraph } from "../src/agent-runtime/procedural-graph.ts"; +import { DurableProceduralEvaluationHistoryRepository } from "../src/state-checkpoint/procedural-evaluation-history.ts"; +import { ProceduralEvaluationHistoryAuthority } from "../src/state-checkpoint/procedural-evaluation-history-authority.ts"; +import { + DurableProceduralPolicyApprovalRepository, + assertProceduralPolicyApprovalSnapshot, +} from "../src/policy-approval/procedural-policy-approval.ts"; + +const digest = (character) => character.repeat(64); + +async function hash(value) { + const bytes = new TextEncoder().encode(JSON.stringify(value)); + const result = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(result)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function candidateGraph() { + return createProceduralGraph({ + schemaVersion: "noema.procedural-graph/v1", + tenantId: "tenant-policy-cas", + taskType: "repair", + graphId: "graph-policy-cas", + revision: 2, + parentDigest: digest("a"), + nodes: ["Start", "verify"], + edges: [{ + from: "Start", + relation: "requires", + to: "verify", + condition: "", + guidance: "Verify exact policy evidence", + pitfalls: "", + }], + }); +} + +function evaluationEventMaterial(stream, event) { + return [ + event.schemaVersion, + stream.tenantId, + stream.taskType, + stream.graphId, + event.version, + event.candidateRevision, + event.baselineDigest, + event.candidateDigest, + event.contextDigest, + event.baselineReceiptDigest, + event.candidateReceiptDigest, + event.rejectionKey, + event.decisionReason, + event.envelopeDigest, + event.signerKeyId, + event.handoffDigest, + event.issuedAtEpochSeconds, + event.expiresAtEpochSeconds, + event.eligibleForApproval, + event.activationAuthorized, + event.priorEventDigest, + ]; +} + +async function verifiedHistory(candidate, { includeRegression = false } = {}) { + const stream = { + tenantId: candidate.tenantId, + taskType: candidate.taskType, + graphId: candidate.graphId, + }; + const firstWithoutDigest = { + schemaVersion: "noema.procedural-evaluation-history-event/v1", + version: 1, + candidateRevision: candidate.revision, + baselineDigest: candidate.parentDigest, + candidateDigest: candidate.digest, + contextDigest: digest("c"), + baselineReceiptDigest: digest("d"), + candidateReceiptDigest: digest("e"), + rejectionKey: digest("f"), + decisionReason: "validation_non_regression", + envelopeDigest: digest("1"), + signerKeyId: "keyverse:evaluator/procedural-v1", + handoffDigest: digest("2"), + issuedAtEpochSeconds: 1_700_000_000, + expiresAtEpochSeconds: 1_700_000_120, + eligibleForApproval: true, + activationAuthorized: false, + priorEventDigest: null, + }; + const firstEvent = { + ...firstWithoutDigest, + eventDigest: await hash(evaluationEventMaterial(stream, firstWithoutDigest)), + }; + const events = [firstEvent]; + const rejectedKeys = []; + if (includeRegression) { + const secondWithoutDigest = { + ...firstWithoutDigest, + version: 2, + rejectionKey: digest("3"), + decisionReason: "score_regression", + envelopeDigest: digest("4"), + handoffDigest: digest("5"), + issuedAtEpochSeconds: 1_700_000_121, + expiresAtEpochSeconds: 1_700_000_241, + eligibleForApproval: false, + priorEventDigest: firstEvent.eventDigest, + }; + const secondEvent = { + ...secondWithoutDigest, + eventDigest: await hash(evaluationEventMaterial(stream, secondWithoutDigest)), + }; + events.push(secondEvent); + rejectedKeys.push(secondEvent.rejectionKey); + } + const keyDigest = await hash([ + "noema.procedural-evaluation-history/v1", + stream.tenantId, + stream.taskType, + stream.graphId, + ]); + const retained = { + schemaVersion: "noema.procedural-evaluation-history/v1", + stream, + version: events.length, + headEventDigest: events.at(-1).eventDigest, + rejectedKeys, + events, + }; + const storage = { + async get(key) { + return key === `procedural-evaluation-history:v1:${keyDigest}` ? structuredClone(retained) : undefined; + }, + async put() {}, + async transaction(callback) { return callback(this); }, + }; + const repository = new DurableProceduralEvaluationHistoryRepository(storage); + const authority = new ProceduralEvaluationHistoryAuthority(repository); + return authority.read(candidate); +} + +function memoryStorage() { + const records = new Map(); + return { + records, + async get(key) { + const value = records.get(key); + return value === undefined ? undefined : structuredClone(value); + }, + async put(key, value) { + records.set(key, structuredClone(value)); + }, + async transaction(callback) { + return callback(this); + }, + }; +} + +function decisionFrom(request, action, decisionId) { + return { + schemaVersion: "noema.procedural-policy-decision/v1", + decisionId, + action, + policyVersion: "policy:procedural/pilot-v1", + tenantId: request.tenantId, + taskType: request.taskType, + graphId: request.graphId, + candidateDigest: request.candidateDigest, + historyVersion: request.historyVersion, + historyHeadEventDigest: request.historyHeadEventDigest, + envelopeDigest: request.envelopeDigest, + handoffDigest: request.handoffDigest, + signerKeyId: request.signerKeyId, + expectedApprovalVersion: request.expectedApprovalVersion, + }; +} + +test("Policy / Approval CAS binds exact graph and verified durable evaluation history", async () => { + const candidate = await candidateGraph(); + const history = await verifiedHistory(candidate); + assert.ok(history); + + let action = "approve_for_pilot"; + let decisionId = "decision:approve-1"; + const authority = { + resolveProceduralPolicyDecision(request) { + return decisionFrom(request, action, decisionId); + }, + }; + const storage = memoryStorage(); + const repository = new DurableProceduralPolicyApprovalRepository(storage, authority); + + assert.equal(await repository.read(candidate), null); + + const approved = await repository.append(candidate, history, 0); + assert.equal(approved.kind, "accepted"); + assert.equal(approved.snapshot.version, 1); + assert.equal(approved.snapshot.status, "approved_for_pilot"); + assert.equal(approved.snapshot.candidateDigest, candidate.digest); + assert.equal(approved.snapshot.historyVersion, history.version); + assert.equal(approved.snapshot.historyHeadEventDigest, history.headEventDigest); + assert.equal(approved.snapshot.activationAuthorized, false); + assert.doesNotThrow(() => assertProceduralPolicyApprovalSnapshot(approved.snapshot)); + assert.throws( + () => assertProceduralPolicyApprovalSnapshot(structuredClone(approved.snapshot)), + /unadmitted procedural policy approval snapshot/, + ); + + const replay = await repository.append(candidate, history, 0); + assert.equal(replay.kind, "replay"); + assert.equal(replay.snapshot.version, 1); + + decisionId = "decision:stale-writer"; + await assert.rejects( + () => repository.append(candidate, history, 0), + /expected approval version lost the CAS race/, + ); + + action = "revoke"; + decisionId = "decision:revoke-1"; + const revoked = await repository.append(candidate, history, 1); + assert.equal(revoked.kind, "accepted"); + assert.equal(revoked.snapshot.version, 2); + assert.equal(revoked.snapshot.status, "revoked"); + assert.equal(revoked.snapshot.activationAuthorized, false); + assert.doesNotThrow(() => assertProceduralPolicyApprovalSnapshot(revoked.snapshot)); + + const restored = await repository.read(candidate); + assert.ok(restored); + assert.equal(restored.version, 2); + assert.equal(restored.status, "revoked"); + assert.doesNotThrow(() => assertProceduralPolicyApprovalSnapshot(restored)); +}); + +test("Policy / Approval can revoke an approved pilot against current non-eligible evaluation history", async () => { + const candidate = await candidateGraph(); + const eligibleHistory = await verifiedHistory(candidate); + const regressedHistory = await verifiedHistory(candidate, { includeRegression: true }); + assert.ok(eligibleHistory); + assert.ok(regressedHistory); + assert.equal(regressedHistory.events.at(-1).eligibleForApproval, false); + assert.equal(regressedHistory.events.at(-1).decisionReason, "score_regression"); + + let action = "approve_for_pilot"; + let decisionId = "decision:approve-before-regression"; + const repository = new DurableProceduralPolicyApprovalRepository(memoryStorage(), { + resolveProceduralPolicyDecision(request) { + return decisionFrom(request, action, decisionId); + }, + }); + await repository.append(candidate, eligibleHistory, 0); + + action = "revoke"; + decisionId = "decision:revoke-after-regression"; + const revoked = await repository.append(candidate, regressedHistory, 1); + assert.equal(revoked.kind, "accepted"); + assert.equal(revoked.snapshot.status, "revoked"); + assert.equal(revoked.snapshot.historyVersion, regressedHistory.version); + assert.equal(revoked.snapshot.historyHeadEventDigest, regressedHistory.headEventDigest); + assert.equal(revoked.snapshot.activationAuthorized, false); +}); + +test("Policy / Approval fails closed for forged history and absent or malformed authority", async () => { + const candidate = await candidateGraph(); + const history = await verifiedHistory(candidate); + assert.ok(history); + + const missingAuthority = new DurableProceduralPolicyApprovalRepository(memoryStorage(), { + resolveProceduralPolicyDecision() { return null; }, + }); + await assert.rejects( + () => missingAuthority.append(candidate, history, 0), + /independent procedural policy decision is required/, + ); + + const throwingAuthority = new DurableProceduralPolicyApprovalRepository(memoryStorage(), { + resolveProceduralPolicyDecision() { throw new Error("owner unavailable"); }, + }); + await assert.rejects( + () => throwingAuthority.append(candidate, history, 0), + /independent procedural policy decision is required/, + ); + + const malformedAuthority = new DurableProceduralPolicyApprovalRepository(memoryStorage(), { + resolveProceduralPolicyDecision(request) { + const decision = decisionFrom(request, "approve_for_pilot", "decision:malformed"); + delete decision.policyVersion; + return decision; + }, + }); + await assert.rejects( + () => malformedAuthority.append(candidate, history, 0), + /independent procedural policy decision is malformed/, + ); + + const mismatchedAuthority = new DurableProceduralPolicyApprovalRepository(memoryStorage(), { + resolveProceduralPolicyDecision(request) { + return { ...decisionFrom(request, "approve_for_pilot", "decision:mismatch"), graphId: "graph-other" }; + }, + }); + await assert.rejects( + () => mismatchedAuthority.append(candidate, history, 0), + /does not bind the exact request/, + ); + + const nullPrototypeAuthority = new DurableProceduralPolicyApprovalRepository(memoryStorage(), { + resolveProceduralPolicyDecision(request) { + return Object.assign( + Object.create(null), + decisionFrom(request, "approve_for_pilot", "decision:null-prototype"), + ); + }, + }); + const nullPrototypeApproval = await nullPrototypeAuthority.append(candidate, history, 0); + assert.equal(nullPrototypeApproval.snapshot.status, "approved_for_pilot"); + assert.equal(nullPrototypeApproval.snapshot.activationAuthorized, false); + + const authority = { + resolveProceduralPolicyDecision(request) { + return decisionFrom(request, "approve_for_pilot", "decision:approve-forgery-test"); + }, + }; + const repository = new DurableProceduralPolicyApprovalRepository(memoryStorage(), authority); + await assert.rejects( + () => repository.append(candidate, structuredClone(history), 0), + /unadmitted durable procedural history snapshot/, + ); +}); \ No newline at end of file