Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
- 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
- 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.
Expand Down
231 changes: 231 additions & 0 deletions src/agent-runtime/procedural-evaluation-handoff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import {
assertProceduralEvaluationEvidence,
type ProceduralEvaluationEvidence,
} from "./procedural-evaluation-authority";
import {
normalizeProceduralError,
proceduralDigest,
proceduralHash,
proceduralIdentity,
proceduralInteger,
readProceduralRecord,
rejectProceduralInput,
} from "./procedural-input";

const HANDOFF_SCHEMA_VERSION = "noema.procedural-evaluation-handoff/v1" as const;
const MAX_HANDOFF_LIFETIME_SECONDS = 300;
const MAX_FUTURE_CLOCK_SKEW_SECONDS = 30;
const MAX_EPOCH_SECONDS = 4_102_444_800;
const ECDSA_P256_SIGNATURE_BYTES = 64;
const ECDSA_P256_SIGNATURE_BASE64URL_LENGTH = 86;

/**
* Signed evaluator handoff that binds one exact procedural evaluation envelope to a bounded validity
* interval and an opaque signer key identity. The signature is verification evidence, not key custody.
*/
export interface ProceduralEvaluationHandoff {
readonly schemaVersion: typeof HANDOFF_SCHEMA_VERSION;
readonly envelopeDigest: string;
readonly signerKeyId: string;
readonly issuedAtEpochSeconds: number;
readonly expiresAtEpochSeconds: number;
readonly signature: string;
}

/**
* Trusted verification input selected by the composition root from its external identity/key authority;
* Agent Runtime consumes this public key but does not discover, rotate, store, or administer signer keys.
*/
export interface ProceduralEvaluationHandoffTrust {
readonly signerKeyId: string;
readonly verificationKey: CryptoKey;
}

/**
* Process-local authority created only after the exact evaluation envelope is verified against the
* separately trusted signer key and bounded handoff interval. It never grants policy or activation.
*/
export interface AuthenticatedProceduralEvaluationEvidence {
readonly evidence: ProceduralEvaluationEvidence;
readonly signerKeyId: string;
readonly issuedAtEpochSeconds: number;
readonly expiresAtEpochSeconds: number;
readonly handoffDigest: string;
readonly activationAuthorized: false;
}

const admittedAuthenticatedEvidence = new WeakSet<object>();

function parseSignature(value: unknown): Uint8Array {
if (
typeof value !== "string"
|| value.length !== ECDSA_P256_SIGNATURE_BASE64URL_LENGTH
|| !/^[A-Za-z0-9_-]+$/.test(value)
) {
rejectProceduralInput("evaluation_handoff_signature_invalid");
}
try {
const base64 = value.replace(/-/g, "+").replace(/_/g, "/") + "==";
const decoded = atob(base64);
if (decoded.length !== ECDSA_P256_SIGNATURE_BYTES) {
rejectProceduralInput("evaluation_handoff_signature_invalid");
}
const canonical = btoa(decoded)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
if (canonical !== value) {
rejectProceduralInput("evaluation_handoff_signature_invalid");
}
return Uint8Array.from(decoded, character => character.charCodeAt(0));
} catch {
rejectProceduralInput("evaluation_handoff_signature_invalid");
}
}

function parseHandoff(input: unknown): {
handoff: ProceduralEvaluationHandoff;
signatureBytes: Uint8Array;
} {
const value = readProceduralRecord(input, [
"schemaVersion",
"envelopeDigest",
"signerKeyId",
"issuedAtEpochSeconds",
"expiresAtEpochSeconds",
"signature",
]);
if (value.schemaVersion !== HANDOFF_SCHEMA_VERSION) rejectProceduralInput("unsupported_schema");
const signatureBytes = parseSignature(value.signature);
return {
handoff: Object.freeze({
schemaVersion: HANDOFF_SCHEMA_VERSION,
envelopeDigest: proceduralDigest(value.envelopeDigest),
signerKeyId: proceduralIdentity(value.signerKeyId),
issuedAtEpochSeconds: proceduralInteger(value.issuedAtEpochSeconds, 0, MAX_EPOCH_SECONDS),
expiresAtEpochSeconds: proceduralInteger(value.expiresAtEpochSeconds, 0, MAX_EPOCH_SECONDS),
signature: value.signature as string,
}),
signatureBytes,
};
}

function parseTrust(input: unknown): ProceduralEvaluationHandoffTrust {
const value = readProceduralRecord(input, ["signerKeyId", "verificationKey"]);
if (value.verificationKey === null || typeof value.verificationKey !== "object") {
rejectProceduralInput("evaluation_handoff_signature_invalid");
}
return Object.freeze({
signerKeyId: proceduralIdentity(value.signerKeyId),
verificationKey: value.verificationKey as CryptoKey,
});
}

function signedMessage(handoff: ProceduralEvaluationHandoff): Uint8Array {
return new TextEncoder().encode(JSON.stringify([
HANDOFF_SCHEMA_VERSION,
handoff.envelopeDigest,
handoff.signerKeyId,
handoff.issuedAtEpochSeconds,
handoff.expiresAtEpochSeconds,
]));
}

function assertHandoffTime(handoff: ProceduralEvaluationHandoff, nowEpochSeconds: number): void {
if (handoff.expiresAtEpochSeconds <= nowEpochSeconds) {
rejectProceduralInput("evaluation_handoff_expired");
}
if (
handoff.issuedAtEpochSeconds > nowEpochSeconds + MAX_FUTURE_CLOCK_SKEW_SECONDS
|| handoff.expiresAtEpochSeconds <= handoff.issuedAtEpochSeconds
|| handoff.expiresAtEpochSeconds - handoff.issuedAtEpochSeconds > MAX_HANDOFF_LIFETIME_SECONDS
) {
rejectProceduralInput("evaluation_handoff_time_invalid");
}
}

async function verifySignature(
verificationKey: CryptoKey,
signatureBytes: Uint8Array,
message: Uint8Array,
): Promise<void> {
let verified = false;
try {
verified = await crypto.subtle.verify(
{ name: "ECDSA", hash: "SHA-256" },
verificationKey,
signatureBytes,
message,
);
} catch {
rejectProceduralInput("evaluation_handoff_signature_invalid");
}
if (!verified) rejectProceduralInput("evaluation_handoff_signature_invalid");
}

/**
* Verifies an exact signed evaluator handoff against a public verification key already selected by the
* trusted composition root. This function does not resolve Keyverse identity, fetch credentials, persist
* evidence, approve a candidate, publish a contract, or authorize activation.
* @param evidence Locally admitted evaluation envelope whose exact digest must be signed by the evaluator.
* @param handoffInput Exact-key signed handoff carrying signer identity, bounded time interval, and signature.
* @param trustInput Separately trusted signer key id and public CryptoKey supplied by the composition root.
* @returns Frozen process-local authenticated evidence that remains explicitly unauthorized for activation.
*/
export async function verifyProceduralEvaluationHandoff(
evidence: ProceduralEvaluationEvidence,
handoffInput: unknown,
trustInput: unknown,
): Promise<AuthenticatedProceduralEvaluationEvidence> {
try {
assertProceduralEvaluationEvidence(evidence);
const { handoff, signatureBytes } = parseHandoff(handoffInput);
const trust = parseTrust(trustInput);
if (handoff.signerKeyId !== trust.signerKeyId) {
rejectProceduralInput("evaluation_handoff_signer_mismatch");
}
if (handoff.envelopeDigest !== evidence.envelopeDigest) {
rejectProceduralInput("evaluation_handoff_envelope_mismatch");
}
const nowEpochSeconds = Math.floor(Date.now() / 1000);
assertHandoffTime(handoff, nowEpochSeconds);
await verifySignature(trust.verificationKey, signatureBytes, signedMessage(handoff));
const authority = Object.freeze({
evidence,
signerKeyId: handoff.signerKeyId,
issuedAtEpochSeconds: handoff.issuedAtEpochSeconds,
expiresAtEpochSeconds: handoff.expiresAtEpochSeconds,
handoffDigest: await proceduralHash([
HANDOFF_SCHEMA_VERSION,
handoff.envelopeDigest,
handoff.signerKeyId,
handoff.issuedAtEpochSeconds,
handoff.expiresAtEpochSeconds,
handoff.signature,
]),
activationAuthorized: false as const,
});
admittedAuthenticatedEvidence.add(authority);
return authority;
} catch (error) {
return normalizeProceduralError(error);
}
}

/**
* Requires process-local provenance and a still-current signed validity interval so structural copies,
* serialized lookalikes, and expired handoffs cannot be mistaken for authenticated evaluator evidence.
* @param value Unknown value proposed as authenticated procedural evaluation evidence.
* @returns Returns normally only for this module's admitted unexpired object; otherwise throws a closed error.
*/
export function assertAuthenticatedProceduralEvaluationEvidence(
value: unknown,
): asserts value is AuthenticatedProceduralEvaluationEvidence {
if (value === null || typeof value !== "object" || !admittedAuthenticatedEvidence.has(value)) {
rejectProceduralInput("unadmitted_authenticated_evaluation");
}
const authenticated = value as AuthenticatedProceduralEvaluationEvidence;
if (authenticated.expiresAtEpochSeconds <= Math.floor(Date.now() / 1000)) {
rejectProceduralInput("evaluation_handoff_expired");
}
}
6 changes: 6 additions & 0 deletions src/agent-runtime/procedural-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@ const PROCEDURAL_ERROR_CODES = [
"unadmitted_session",
"unadmitted_decision",
"unadmitted_evaluation_evidence",
"unadmitted_authenticated_evaluation",
"evaluation_evidence_digest_mismatch",
"evaluation_handoff_signer_mismatch",
"evaluation_handoff_envelope_mismatch",
"evaluation_handoff_time_invalid",
"evaluation_handoff_expired",
"evaluation_handoff_signature_invalid",
"unsupported_schema",
"duplicate_node",
"missing_start",
Expand Down
Loading
Loading