diff --git a/agent-governance-typescript/package.json b/agent-governance-typescript/package.json index f8e55d19b..c828c167f 100644 --- a/agent-governance-typescript/package.json +++ b/agent-governance-typescript/package.json @@ -46,6 +46,7 @@ "@noble/curves": "2.2.0", "@noble/ed25519": "3.1.0", "@noble/hashes": "2.2.0", + "canonicalize": "2.0.0", "js-yaml": "5.1.0" }, "engines": { diff --git a/agent-governance-typescript/src/approval-protocol/binding.ts b/agent-governance-typescript/src/approval-protocol/binding.ts new file mode 100644 index 000000000..03acde112 --- /dev/null +++ b/agent-governance-typescript/src/approval-protocol/binding.ts @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Action binding for the action-bound approval protocol (ADR-0030 section 2). + * + * An ActionBinding captures the exact executable request an approval authorizes. + * Its actionDigest is the SHA-256 over the JCS serialization of the binding, + * so an approval for one binding can never authorize a different action. + * + * Parity with agent-governance-python + * agent-mesh/src/agentmesh/governance/approval_protocol/binding.py. + * Refs #3083. + */ + +import { sha256Jcs } from './digest'; + +export const SCHEMA_VERSION = '1.0'; + +export interface ActionTarget { + readonly toolName: string; + readonly toolSchemaVersion: string; + readonly resource: string | null; +} + +export function makeActionTarget( + opts: Pick & + Partial>, +): ActionTarget { + return { resource: null, ...opts }; +} + +function targetToCanonical(target: ActionTarget): Record { + return { + tool_name: target.toolName, + tool_schema_version: target.toolSchemaVersion, + resource: target.resource, + }; +} + +export interface ActionBinding { + readonly operation: string; + readonly agentId: string; + readonly target: ActionTarget; + readonly parameters: Record; + readonly subjectId: string | null; + readonly schemaVersion: string; +} + +export function makeActionBinding( + opts: Pick & { + parameters?: Record; + subjectId?: string | null; + schemaVersion?: string; + }, +): ActionBinding { + return { + parameters: {}, + subjectId: null, + schemaVersion: SCHEMA_VERSION, + ...opts, + }; +} + +export function bindingToCanonical(binding: ActionBinding): Record { + return { + schema_version: binding.schemaVersion, + operation: binding.operation, + agent_id: binding.agentId, + subject_id: binding.subjectId, + target: targetToCanonical(binding.target), + parameters: binding.parameters, + }; +} + +export function bindingDigest(binding: ActionBinding): string { + return sha256Jcs(bindingToCanonical(binding)); +} diff --git a/agent-governance-typescript/src/approval-protocol/coordinator.ts b/agent-governance-typescript/src/approval-protocol/coordinator.ts new file mode 100644 index 000000000..bf08e6087 --- /dev/null +++ b/agent-governance-typescript/src/approval-protocol/coordinator.ts @@ -0,0 +1,356 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Approval coordinator for the action-bound approval protocol (ADR-0030). + * + * The coordinator owns the approval lifecycle: + * - openRequest: turns a require_approval policy decision into a durable, + * action-bound ApprovalRequest + * - submitEntry: records an authenticated, hash-linked approver decision + * - validateForExecution: atomic pre-execution revalidation (ADR-0030 section 6) + * + * Every failure path is fail-closed: anything that is not an unambiguous + * terminal allow over the exact action, policy version, and chain version + * denies execution and returns a machine-readable reason code. + * + * Parity with agent-governance-python + * agent-mesh/src/agentmesh/governance/approval_protocol/coordinator.py. + * Refs #3083. + */ + +import type { ActionBinding } from './binding'; +import { bindingDigest } from './binding'; +import { + type ApprovalChainEntry, + type ApprovalRequest, + type ApprovalResolution, + type ApproverKind, + type EntryDecision, + type Outcome, + type PolicyDecisionRecord, + computeEntryDigest, + inputDigest, + makeApprovalChainEntry, + makeApprovalRequest, + makeApprovalResolution, + makePolicyDecisionRecord, + sealEntry, + utcnow, + verifyEntryDigest, +} from './models'; +import type { ApprovalStore } from './store'; + +export class ApprovalProtocolError extends Error {} + +export const ReasonCode = { + OK: 'ok', + UNKNOWN_REQUEST: 'unknown_request', + NO_RESOLUTION: 'no_resolution', + NOT_TERMINAL_ALLOW: 'not_terminal_allow', + EXPIRED: 'expired', + CANCELLED: 'cancelled', + ALREADY_CONSUMED: 'already_consumed', + ACTION_DIGEST_MISMATCH: 'action_digest_mismatch', + POLICY_VERSION_MISMATCH: 'policy_version_mismatch', + CHAIN_VERSION_MISMATCH: 'chain_version_mismatch', + CHAIN_TAMPERED: 'chain_tampered', + INTERNAL_ERROR: 'internal_error', +} as const; + +export interface ExecutionDecision { + readonly allowed: boolean; + readonly reasonCode: string; +} + +/** One ordered stage of an approval chain. */ +export interface ApprovalStage { + readonly stageIndex: number; + readonly allowedIdentities: ReadonlySet; + readonly allowedRoles: ReadonlySet; + readonly required: boolean; +} + +export function makeApprovalStage( + opts: Pick & { + allowedIdentities?: Iterable; + allowedRoles?: Iterable; + required?: boolean; + }, +): ApprovalStage { + return { + allowedIdentities: new Set(opts.allowedIdentities ?? []), + allowedRoles: new Set(opts.allowedRoles ?? []), + required: opts.required ?? true, + stageIndex: opts.stageIndex, + }; +} + +export function stageAuthorizes( + stage: ApprovalStage, + identity: string, + roles: Iterable, +): boolean { + if (stage.allowedIdentities.has(identity)) return true; + for (const role of roles) { + if (stage.allowedRoles.has(role)) return true; + } + return false; +} + +/** A versioned, immutable approval-chain configuration. */ +export interface ApprovalChain { + readonly chainId: string; + readonly version: string; + readonly stages: readonly ApprovalStage[]; +} + +export function getStage(chain: ApprovalChain, stageIndex: number): ApprovalStage | null { + return chain.stages.find((s) => s.stageIndex === stageIndex) ?? null; +} + +/** Creates, advances, and validates action-bound approval requests. */ +export class ApprovalCoordinator { + constructor( + private readonly store: ApprovalStore, + private readonly chains: Map, + private readonly clock: () => Date = utcnow, + ) {} + + openRequest( + binding: ActionBinding, + opts: { + policyRuleId: string; + policyVersion: string; + chainId: string; + ttlSeconds: number; + targetResource?: string | null; + failClosedOnTimeout?: boolean; + }, + ): { decision: PolicyDecisionRecord; request: ApprovalRequest } { + const chain = this.chains.get(opts.chainId); + if (!chain) throw new ApprovalProtocolError(`unknown approval chain: ${opts.chainId}`); + + const actionDigest = bindingDigest(binding); + const decision = makePolicyDecisionRecord({ + actionDigest, + policyRuleId: opts.policyRuleId, + policyVersion: opts.policyVersion, + approvalChainId: chain.chainId, + approvalChainVersion: chain.version, + }); + const now = this.clock(); + const expiresAt = new Date(now.getTime() + opts.ttlSeconds * 1000); + const request = makeApprovalRequest({ + policyDecisionId: decision.policyDecisionId, + actionDigest, + agentId: binding.agentId, + operation: binding.operation, + policyVersion: opts.policyVersion, + approvalChainId: chain.chainId, + approvalChainVersion: chain.version, + expiresAt, + subjectId: binding.subjectId, + targetResource: + opts.targetResource !== undefined ? opts.targetResource : binding.target.resource, + failClosedOnTimeout: opts.failClosedOnTimeout ?? true, + }); + this.store.saveRequest(request); + return { decision, request }; + } + + submitEntry( + approvalRequestId: string, + opts: { + stageIndex: number; + approverKind: ApproverKind; + approverIdentity: string; + identityAssurance: string; + decision: EntryDecision; + reasonCode?: string; + roles?: Iterable; + chainEntryId?: string; + }, + ): ApprovalChainEntry { + const request = this.store.getRequest(approvalRequestId); + if (!request) throw new ApprovalProtocolError(`unknown approval request: ${approvalRequestId}`); + + // Idempotent resubmission by caller-supplied chainEntryId. + if (opts.chainEntryId !== undefined) { + for (const existing of this.store.getEntries(approvalRequestId)) { + if (existing.chainEntryId === opts.chainEntryId) return existing; + } + } + + if (this._expireIfDue(request)) throw new ApprovalProtocolError('approval request has expired'); + if (request.status !== 'pending') { + throw new ApprovalProtocolError( + `approval request is not pending (status=${request.status})`, + ); + } + + const chain = this.chains.get(request.approvalChainId)!; + const stage = getStage(chain, opts.stageIndex); + if (!stage) throw new ApprovalProtocolError(`unknown stage index: ${opts.stageIndex}`); + + const isAdvisory = opts.approverKind === 'llm_advisory'; + if ( + !isAdvisory && + !stageAuthorizes(stage, opts.approverIdentity, opts.roles ?? []) + ) { + throw new ApprovalProtocolError( + `identity ${opts.approverIdentity} not permitted for stage ${opts.stageIndex}`, + ); + } + + const prior = this.store.getEntries(approvalRequestId); + const previousDigest = prior.length > 0 ? prior[prior.length - 1].entryDigest : null; + const entry = sealEntry( + makeApprovalChainEntry({ + approvalRequestId, + stageIndex: opts.stageIndex, + approverKind: opts.approverKind, + approverIdentity: opts.approverIdentity, + identityAssurance: opts.identityAssurance, + decision: opts.decision, + inputDigest: inputDigest(request), + reasonCode: opts.reasonCode ?? '', + previousEntryDigest: previousDigest, + ...(opts.chainEntryId !== undefined ? { chainEntryId: opts.chainEntryId } : {}), + }), + ); + + this.store.appendEntry(entry); + if (!isAdvisory) { + this._maybeResolve(request, chain); + } + return entry; + } + + validateForExecution( + approvalRequestId: string, + opts: { + currentActionDigest: string; + currentPolicyVersion: string; + currentChainVersion: string; + consume?: boolean; + }, + ): ExecutionDecision { + try { + const request = this.store.getRequest(approvalRequestId); + if (!request) return { allowed: false, reasonCode: ReasonCode.UNKNOWN_REQUEST }; + + this._expireIfDue(request); + + const resolution = this.store.getResolution(approvalRequestId); + if (!resolution) return { allowed: false, reasonCode: ReasonCode.NO_RESOLUTION }; + if (resolution.outcome === 'expired') return { allowed: false, reasonCode: ReasonCode.EXPIRED }; + if (resolution.outcome !== 'allow') return { allowed: false, reasonCode: ReasonCode.NOT_TERMINAL_ALLOW }; + + if (request.status === 'consumed') return { allowed: false, reasonCode: ReasonCode.ALREADY_CONSUMED }; + if (request.status === 'cancelled') return { allowed: false, reasonCode: ReasonCode.CANCELLED }; + if (request.status !== 'allowed') return { allowed: false, reasonCode: ReasonCode.NOT_TERMINAL_ALLOW }; + + if (this.clock() >= request.expiresAt) return { allowed: false, reasonCode: ReasonCode.EXPIRED }; + + if (opts.currentActionDigest !== resolution.actionDigest) { + return { allowed: false, reasonCode: ReasonCode.ACTION_DIGEST_MISMATCH }; + } + if (opts.currentPolicyVersion !== resolution.policyVersion) { + return { allowed: false, reasonCode: ReasonCode.POLICY_VERSION_MISMATCH }; + } + if (opts.currentChainVersion !== resolution.approvalChainVersion) { + return { allowed: false, reasonCode: ReasonCode.CHAIN_VERSION_MISMATCH }; + } + + if (!this._chainIntact(approvalRequestId, resolution.finalEntryDigest)) { + return { allowed: false, reasonCode: ReasonCode.CHAIN_TAMPERED }; + } + + if ((opts.consume ?? true) && !this.store.consume(approvalRequestId)) { + return { allowed: false, reasonCode: ReasonCode.ALREADY_CONSUMED }; + } + + return { allowed: true, reasonCode: ReasonCode.OK }; + } catch { + return { allowed: false, reasonCode: ReasonCode.INTERNAL_ERROR }; + } + } + + private _expireIfDue(request: ApprovalRequest): boolean { + if (request.status !== 'pending') return request.status === 'expired'; + if (this.clock() >= request.expiresAt) { + this._resolve(request, 'expired', null); + return true; + } + return false; + } + + private _maybeResolve(request: ApprovalRequest, chain: ApprovalChain): void { + if (this.store.getResolution(request.approvalRequestId) !== null) return; + + const required = new Set(chain.stages.filter((s) => s.required).map((s) => s.stageIndex)); + // A chain with zero required stages is misconfigured: deny immediately rather + // than vacuously allowing (fail-closed semantics, mirrors Go port in #3242). + if (required.size === 0) { + this._resolve(request, 'deny', null); + return; + } + + const entries = this.store + .getEntries(request.approvalRequestId) + .filter((e) => e.approverKind !== 'llm_advisory'); + + for (const entry of entries) { + if (entry.decision === 'deny') { + this._resolve(request, 'deny', entry.entryDigest); + return; + } + } + + const allowedStages = new Set( + entries.filter((e) => e.decision === 'allow').map((e) => e.stageIndex), + ); + if ([...required].every((idx) => allowedStages.has(idx))) { + const finalDigest = entries.length > 0 ? entries[entries.length - 1].entryDigest : null; + this._resolve(request, 'allow', finalDigest); + } + } + + private _resolve( + request: ApprovalRequest, + outcome: Outcome, + finalEntryDigest: string | null, + ): ApprovalResolution { + const resolution = makeApprovalResolution({ + approvalRequestId: request.approvalRequestId, + outcome, + actionDigest: request.actionDigest, + policyVersion: request.policyVersion, + approvalChainVersion: request.approvalChainVersion, + finalEntryDigest, + }); + this.store.saveResolution(resolution); + const statusMap: Record = { + allow: 'allowed', + deny: 'denied', + expired: 'expired', + }; + this.store.setStatus(request.approvalRequestId, statusMap[outcome]); + return resolution; + } + + private _chainIntact( + approvalRequestId: string, + finalEntryDigest: string | null, + ): boolean { + const entries = this.store.getEntries(approvalRequestId); + let previous: string | null = null; + for (const entry of entries) { + if (!verifyEntryDigest(entry)) return false; + if (entry.previousEntryDigest !== previous) return false; + previous = entry.entryDigest; + } + if (finalEntryDigest !== null && previous !== finalEntryDigest) return false; + return true; + } +} diff --git a/agent-governance-typescript/src/approval-protocol/digest.ts b/agent-governance-typescript/src/approval-protocol/digest.ts new file mode 100644 index 000000000..6c8a99a9d --- /dev/null +++ b/agent-governance-typescript/src/approval-protocol/digest.ts @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * RFC 8785 JSON Canonicalization Scheme (JCS) and SHA-256 action digests. + * + * ADR-0030 binds every approval to the exact action under review by hashing a + * canonical serialization of the request. The same digest must be reproducible + * at the execution boundary, so the serialization must be deterministic. + * + * Parity with agent-governance-python + * agent-mesh/src/agentmesh/governance/approval_protocol/digest.py. + * Refs #3083. + */ + +import { createHash } from 'crypto'; +import canonicalizeRfc8785 from 'canonicalize'; + +export const DIGEST_PREFIX = 'sha256:'; + +/** Return the RFC 8785 canonical UTF-8 encoding of value. */ +export function canonicalize(value: unknown): Buffer { + // canonicalize() returns undefined for non-serializable values (NaN, Infinity, etc.) + const json = canonicalizeRfc8785(value as Parameters[0]); + if (json === undefined) { + throw new TypeError('JCS cannot serialize value (NaN, Infinity, or non-serializable type)'); + } + return Buffer.from(json, 'utf8'); +} + +/** Return "sha256:" over the JCS encoding of value. */ +export function sha256Jcs(value: unknown): string { + return DIGEST_PREFIX + createHash('sha256').update(canonicalize(value)).digest('hex'); +} diff --git a/agent-governance-typescript/src/approval-protocol/index.ts b/agent-governance-typescript/src/approval-protocol/index.ts new file mode 100644 index 000000000..69a083473 --- /dev/null +++ b/agent-governance-typescript/src/approval-protocol/index.ts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +export { canonicalize, sha256Jcs, DIGEST_PREFIX } from './digest'; +export { + makeActionTarget, + makeActionBinding, + bindingToCanonical, + bindingDigest, + SCHEMA_VERSION, +} from './binding'; +export type { ActionTarget, ActionBinding } from './binding'; +export { + utcnow, + makePolicyDecisionRecord, + makeApprovalRequest, + presentedCanonical, + inputDigest, + makeApprovalChainEntry, + computeEntryDigest, + sealEntry, + verifyEntryDigest, + makeApprovalResolution, +} from './models'; +export type { + Verdict, + ApprovalStatus, + ApproverKind, + EntryDecision, + Outcome, + PolicyDecisionRecord, + ApprovalRequest, + ApprovalChainEntry, + ApprovalResolution, +} from './models'; +export { InMemoryApprovalStore } from './store'; +export type { ApprovalStore } from './store'; +export { + ApprovalProtocolError, + ApprovalCoordinator, + ReasonCode, + makeApprovalStage, + stageAuthorizes, + getStage, +} from './coordinator'; +export type { + ExecutionDecision, + ApprovalStage, + ApprovalChain, +} from './coordinator'; diff --git a/agent-governance-typescript/src/approval-protocol/models.ts b/agent-governance-typescript/src/approval-protocol/models.ts new file mode 100644 index 000000000..a327f0486 --- /dev/null +++ b/agent-governance-typescript/src/approval-protocol/models.ts @@ -0,0 +1,197 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Protocol objects for the action-bound approval protocol (ADR-0030 section 3). + * + * Parity with agent-governance-python + * agent-mesh/src/agentmesh/governance/approval_protocol/models.py. + * Refs #3083. + */ + +import { randomUUID } from 'crypto'; +import { sha256Jcs } from './digest'; + +export function utcnow(): Date { + return new Date(); +} + +function newId(prefix: string): string { + return `${prefix}_${randomUUID().replace(/-/g, '')}`; +} + +/** Canonical policy enforcement outcomes (ADR-0030 section 1). */ +export type Verdict = 'allow' | 'deny' | 'require_approval'; + +/** Lifecycle state of an ApprovalRequest. */ +export type ApprovalStatus = + | 'pending' + | 'allowed' + | 'denied' + | 'expired' + | 'cancelled' + | 'consumed'; + +/** Kind of principal recorded on a chain entry. */ +export type ApproverKind = 'human' | 'service' | 'llm_advisory'; + +/** An individual approver's vote on a chain entry. */ +export type EntryDecision = 'allow' | 'deny'; + +/** Terminal resolution outcome of an approval request. */ +export type Outcome = 'allow' | 'deny' | 'expired'; + +export interface PolicyDecisionRecord { + readonly actionDigest: string; + readonly policyRuleId: string; + readonly policyVersion: string; + readonly approvalChainId: string; + readonly approvalChainVersion: string; + readonly verdict: Verdict; + readonly policyDecisionId: string; + readonly decidedAt: Date; +} + +export function makePolicyDecisionRecord( + opts: Pick< + PolicyDecisionRecord, + 'actionDigest' | 'policyRuleId' | 'policyVersion' | 'approvalChainId' | 'approvalChainVersion' + >, +): PolicyDecisionRecord { + return { + verdict: 'require_approval', + policyDecisionId: newId('pd'), + decidedAt: utcnow(), + ...opts, + }; +} + +export interface ApprovalRequest { + readonly policyDecisionId: string; + readonly actionDigest: string; + readonly agentId: string; + readonly operation: string; + readonly policyVersion: string; + readonly approvalChainId: string; + readonly approvalChainVersion: string; + readonly expiresAt: Date; + readonly subjectId: string | null; + readonly targetResource: string | null; + readonly failClosedOnTimeout: boolean; + status: ApprovalStatus; + readonly approvalRequestId: string; + readonly requestedAt: Date; +} + +export function makeApprovalRequest( + opts: Omit & + Partial>, +): ApprovalRequest { + return { + subjectId: null, + targetResource: null, + failClosedOnTimeout: true, + status: 'pending', + approvalRequestId: newId('ar'), + requestedAt: utcnow(), + ...opts, + }; +} + +export function presentedCanonical(request: ApprovalRequest): Record { + return { + approval_request_id: request.approvalRequestId, + policy_decision_id: request.policyDecisionId, + action_digest: request.actionDigest, + agent_id: request.agentId, + subject_id: request.subjectId, + operation: request.operation, + target_resource: request.targetResource, + policy_version: request.policyVersion, + approval_chain_id: request.approvalChainId, + approval_chain_version: request.approvalChainVersion, + expires_at: request.expiresAt.toISOString(), + }; +} + +export function inputDigest(request: ApprovalRequest): string { + return sha256Jcs(presentedCanonical(request)); +} + +export interface ApprovalChainEntry { + readonly approvalRequestId: string; + readonly stageIndex: number; + readonly approverKind: ApproverKind; + readonly approverIdentity: string; + readonly identityAssurance: string; + readonly decision: EntryDecision; + readonly inputDigest: string; + readonly reasonCode: string; + readonly previousEntryDigest: string | null; + readonly chainEntryId: string; + readonly decidedAt: Date; + entryDigest: string | null; +} + +export function makeApprovalChainEntry( + opts: Omit & + Partial>, +): ApprovalChainEntry { + return { + reasonCode: '', + previousEntryDigest: null, + chainEntryId: newId('ace'), + decidedAt: utcnow(), + entryDigest: null, + ...opts, + }; +} + +function canonicalWithoutDigest(entry: ApprovalChainEntry): Record { + return { + approval_request_id: entry.approvalRequestId, + chain_entry_id: entry.chainEntryId, + stage_index: entry.stageIndex, + approver_kind: entry.approverKind, + approver_identity: entry.approverIdentity, + identity_assurance: entry.identityAssurance, + decision: entry.decision, + reason_code: entry.reasonCode, + input_digest: entry.inputDigest, + previous_entry_digest: entry.previousEntryDigest, + decided_at: entry.decidedAt.toISOString(), + }; +} + +export function computeEntryDigest(entry: ApprovalChainEntry): string { + return sha256Jcs(canonicalWithoutDigest(entry)); +} + +export function sealEntry(entry: ApprovalChainEntry): ApprovalChainEntry { + entry.entryDigest = computeEntryDigest(entry); + return entry; +} + +export function verifyEntryDigest(entry: ApprovalChainEntry): boolean { + return entry.entryDigest !== null && entry.entryDigest === computeEntryDigest(entry); +} + +export interface ApprovalResolution { + readonly approvalRequestId: string; + readonly outcome: Outcome; + readonly actionDigest: string; + readonly policyVersion: string; + readonly approvalChainVersion: string; + readonly finalEntryDigest: string | null; + readonly approvalResolutionId: string; + readonly resolvedAt: Date; +} + +export function makeApprovalResolution( + opts: Omit, +): ApprovalResolution { + return { + approvalResolutionId: newId('apr'), + resolvedAt: utcnow(), + ...opts, + }; +} diff --git a/agent-governance-typescript/src/approval-protocol/store.ts b/agent-governance-typescript/src/approval-protocol/store.ts new file mode 100644 index 000000000..3cea2be5b --- /dev/null +++ b/agent-governance-typescript/src/approval-protocol/store.ts @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** + * Durable store contract for pending approvals (ADR-0030 section 5). + * + * ApprovalStore is the protocol production deployments implement against a real + * backend; InMemoryApprovalStore is a reference implementation for tests and + * single-process embedders. Node.js is single-threaded so no mutex is needed. + * + * Parity with agent-governance-python + * agent-mesh/src/agentmesh/governance/approval_protocol/store.py. + * Refs #3083. + */ + +import type { + ApprovalChainEntry, + ApprovalRequest, + ApprovalResolution, + ApprovalStatus, +} from './models'; + +export interface ApprovalStore { + saveRequest(request: ApprovalRequest): void; + getRequest(approvalRequestId: string): ApprovalRequest | null; + setStatus(approvalRequestId: string, status: ApprovalStatus): void; + appendEntry(entry: ApprovalChainEntry): void; + getEntries(approvalRequestId: string): ApprovalChainEntry[]; + saveResolution(resolution: ApprovalResolution): void; + getResolution(approvalRequestId: string): ApprovalResolution | null; + /** Atomically mark an ALLOWED request CONSUMED. Returns true exactly once. */ + consume(approvalRequestId: string): boolean; +} + +export class InMemoryApprovalStore implements ApprovalStore { + private _requests = new Map(); + private _entries = new Map(); + private _resolutions = new Map(); + + saveRequest(request: ApprovalRequest): void { + this._requests.set(request.approvalRequestId, request); + if (!this._entries.has(request.approvalRequestId)) { + this._entries.set(request.approvalRequestId, []); + } + } + + getRequest(approvalRequestId: string): ApprovalRequest | null { + return this._requests.get(approvalRequestId) ?? null; + } + + setStatus(approvalRequestId: string, status: ApprovalStatus): void { + const req = this._requests.get(approvalRequestId); + if (req) req.status = status; + } + + appendEntry(entry: ApprovalChainEntry): void { + const list = this._entries.get(entry.approvalRequestId); + if (list) list.push(entry); + else this._entries.set(entry.approvalRequestId, [entry]); + } + + getEntries(approvalRequestId: string): ApprovalChainEntry[] { + return [...(this._entries.get(approvalRequestId) ?? [])]; + } + + saveResolution(resolution: ApprovalResolution): void { + this._resolutions.set(resolution.approvalRequestId, resolution); + } + + getResolution(approvalRequestId: string): ApprovalResolution | null { + return this._resolutions.get(approvalRequestId) ?? null; + } + + consume(approvalRequestId: string): boolean { + const req = this._requests.get(approvalRequestId); + if (!req || req.status !== 'allowed') return false; + req.status = 'consumed'; + return true; + } +} diff --git a/agent-governance-typescript/src/index.ts b/agent-governance-typescript/src/index.ts index f59dc527c..d918536a7 100644 --- a/agent-governance-typescript/src/index.ts +++ b/agent-governance-typescript/src/index.ts @@ -192,3 +192,49 @@ export type { ShadowAgentRecord, ShadowDiscoveryOptions, } from './discovery'; + +// Action-Bound Approval Protocol (ADR-0030 / issue #3083 — parity with Python agent-mesh) +export { + canonicalize, + sha256Jcs, + DIGEST_PREFIX, + makeActionTarget, + makeActionBinding, + bindingToCanonical, + bindingDigest, + SCHEMA_VERSION, + utcnow, + makePolicyDecisionRecord, + makeApprovalRequest, + presentedCanonical, + inputDigest, + makeApprovalChainEntry, + computeEntryDigest, + sealEntry, + verifyEntryDigest, + makeApprovalResolution, + InMemoryApprovalStore, + ApprovalProtocolError, + ApprovalCoordinator, + ReasonCode, + makeApprovalStage, + stageAuthorizes, + getStage, +} from './approval-protocol'; +export type { + ActionTarget, + ActionBinding, + Verdict, + ApprovalStatus, + ApproverKind, + EntryDecision, + Outcome, + PolicyDecisionRecord, + ApprovalRequest, + ApprovalChainEntry, + ApprovalResolution, + ApprovalStore, + ExecutionDecision, + ApprovalStage, + ApprovalChain, +} from './approval-protocol'; diff --git a/agent-governance-typescript/tests/approval-protocol.test.ts b/agent-governance-typescript/tests/approval-protocol.test.ts new file mode 100644 index 000000000..1913abfc0 --- /dev/null +++ b/agent-governance-typescript/tests/approval-protocol.test.ts @@ -0,0 +1,553 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import { + ApprovalCoordinator, + ApprovalProtocolError, + InMemoryApprovalStore, + ReasonCode, + bindingDigest, + canonicalize, + getStage, + makeActionBinding, + makeActionTarget, + makeApprovalStage, + sha256Jcs, + stageAuthorizes, + verifyEntryDigest, +} from '../src/approval-protocol'; +import type { ApprovalChain } from '../src/approval-protocol'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const ALICE = 'alice'; +const BOB = 'bob'; + +function makeChain(id = 'default', identities: string[] = [ALICE]): ApprovalChain { + return { + chainId: id, + version: '1', + stages: [makeApprovalStage({ stageIndex: 0, allowedIdentities: identities })], + }; +} + +function makeCoordinator(chain = makeChain()) { + const store = new InMemoryApprovalStore(); + const chains = new Map([[chain.chainId, chain]]); + return new ApprovalCoordinator(store, chains); +} + +function makeBinding(params: Record = {}) { + return makeActionBinding({ + operation: 'tool.invoke', + agentId: 'agent-1', + target: makeActionTarget({ toolName: 'transfer', toolSchemaVersion: '1.0' }), + parameters: params, + }); +} + +// --------------------------------------------------------------------------- +// JCS canonicalization + digest +// --------------------------------------------------------------------------- + +describe('canonicalize / sha256Jcs', () => { + it('sorts object keys by UTF-16 code unit order', () => { + const obj = { z: 1, a: 2, m: 3 }; + const canonical = canonicalize(obj).toString('utf8'); + expect(canonical).toBe('{"a":2,"m":3,"z":1}'); + }); + + it('serializes null', () => { + expect(canonicalize(null).toString()).toBe('null'); + }); + + it('serializes booleans', () => { + expect(canonicalize(true).toString()).toBe('true'); + expect(canonicalize(false).toString()).toBe('false'); + }); + + it('serializes integers without decimal', () => { + expect(canonicalize(42).toString()).toBe('42'); + expect(canonicalize(-7).toString()).toBe('-7'); + }); + + it('serializes arrays', () => { + expect(canonicalize([1, 'two', null]).toString()).toBe('[1,"two",null]'); + }); + + it('rejects NaN', () => { + expect(() => canonicalize(NaN)).toThrow(); + }); + + it('rejects Infinity', () => { + expect(() => canonicalize(Infinity)).toThrow(); + }); + + it('sha256Jcs returns sha256: prefix', () => { + const d = sha256Jcs({ foo: 'bar' }); + expect(d.startsWith('sha256:')).toBe(true); + expect(d).toHaveLength(7 + 64); + }); + + it('sha256Jcs is deterministic', () => { + expect(sha256Jcs({ b: 2, a: 1 })).toBe(sha256Jcs({ a: 1, b: 2 })); + }); +}); + +// --------------------------------------------------------------------------- +// ActionBinding / bindingDigest +// --------------------------------------------------------------------------- + +describe('bindingDigest', () => { + it('different parameters produce different digests', () => { + const b1 = makeBinding({ amount: 100 }); + const b2 = makeBinding({ amount: 999 }); + expect(bindingDigest(b1)).not.toBe(bindingDigest(b2)); + }); + + it('same parameters produce same digest', () => { + const b1 = makeBinding({ amount: 100 }); + const b2 = makeBinding({ amount: 100 }); + expect(bindingDigest(b1)).toBe(bindingDigest(b2)); + }); +}); + +// --------------------------------------------------------------------------- +// ApprovalCoordinator — openRequest +// --------------------------------------------------------------------------- + +describe('ApprovalCoordinator.openRequest', () => { + it('opens a pending request', () => { + const coord = makeCoordinator(); + const binding = makeBinding({ amount: 100 }); + const { request } = coord.openRequest(binding, { + policyRuleId: 'rule-1', + policyVersion: 'v1', + chainId: 'default', + ttlSeconds: 300, + }); + expect(request.status).toBe('pending'); + expect(request.actionDigest).toBe(bindingDigest(binding)); + }); + + it('throws on unknown chain', () => { + const coord = makeCoordinator(); + expect(() => + coord.openRequest(makeBinding(), { + policyRuleId: 'r', + policyVersion: 'v1', + chainId: 'nonexistent', + ttlSeconds: 300, + }), + ).toThrow(ApprovalProtocolError); + }); +}); + +// --------------------------------------------------------------------------- +// ApprovalCoordinator — submitEntry +// --------------------------------------------------------------------------- + +describe('ApprovalCoordinator.submitEntry', () => { + it('allowed entry resolves request to allowed', () => { + const store = new InMemoryApprovalStore(); + const chain = makeChain(); + const coord = new ApprovalCoordinator(store, new Map([[chain.chainId, chain]])); + const { request } = coord.openRequest(makeBinding(), { + policyRuleId: 'r', + policyVersion: 'v1', + chainId: 'default', + ttlSeconds: 300, + }); + coord.submitEntry(request.approvalRequestId, { + stageIndex: 0, + approverKind: 'human', + approverIdentity: ALICE, + identityAssurance: 'session', + decision: 'allow', + }); + expect(store.getRequest(request.approvalRequestId)!.status).toBe('allowed'); + expect(store.getResolution(request.approvalRequestId)!.outcome).toBe('allow'); + }); + + it('deny entry resolves request to denied immediately', () => { + const store = new InMemoryApprovalStore(); + const chain = makeChain(); + const coord = new ApprovalCoordinator(store, new Map([[chain.chainId, chain]])); + const { request } = coord.openRequest(makeBinding(), { + policyRuleId: 'r', + policyVersion: 'v1', + chainId: 'default', + ttlSeconds: 300, + }); + coord.submitEntry(request.approvalRequestId, { + stageIndex: 0, + approverKind: 'human', + approverIdentity: ALICE, + identityAssurance: 'session', + decision: 'deny', + }); + expect(store.getRequest(request.approvalRequestId)!.status).toBe('denied'); + }); + + it('unpermitted identity throws', () => { + const coord = makeCoordinator(); + const { request } = coord.openRequest(makeBinding(), { + policyRuleId: 'r', + policyVersion: 'v1', + chainId: 'default', + ttlSeconds: 300, + }); + expect(() => + coord.submitEntry(request.approvalRequestId, { + stageIndex: 0, + approverKind: 'human', + approverIdentity: 'mallory', + identityAssurance: 'session', + decision: 'allow', + }), + ).toThrow(ApprovalProtocolError); + }); + + it('llm_advisory entry does not satisfy stage', () => { + const store = new InMemoryApprovalStore(); + const chain = makeChain(); + const coord = new ApprovalCoordinator(store, new Map([[chain.chainId, chain]])); + const { request } = coord.openRequest(makeBinding(), { + policyRuleId: 'r', + policyVersion: 'v1', + chainId: 'default', + ttlSeconds: 300, + }); + coord.submitEntry(request.approvalRequestId, { + stageIndex: 0, + approverKind: 'llm_advisory', + approverIdentity: 'model-x', + identityAssurance: 'advisory', + decision: 'allow', + }); + // Advisory vote alone must not resolve the request. + expect(store.getRequest(request.approvalRequestId)!.status).toBe('pending'); + }); + + it('idempotent resubmission by chainEntryId returns existing entry', () => { + const coord = makeCoordinator(); + const { request } = coord.openRequest(makeBinding(), { + policyRuleId: 'r', + policyVersion: 'v1', + chainId: 'default', + ttlSeconds: 300, + }); + const e1 = coord.submitEntry(request.approvalRequestId, { + stageIndex: 0, + approverKind: 'human', + approverIdentity: ALICE, + identityAssurance: 'session', + decision: 'allow', + chainEntryId: 'eid-1', + }); + const e2 = coord.submitEntry(request.approvalRequestId, { + stageIndex: 0, + approverKind: 'human', + approverIdentity: ALICE, + identityAssurance: 'session', + decision: 'allow', + chainEntryId: 'eid-1', + }); + expect(e1.chainEntryId).toBe(e2.chainEntryId); + }); + + it('throws on expired request', () => { + // Use TTL=0 so the request expires the instant the clock ticks + const store = new InMemoryApprovalStore(); + const chain = makeChain(); + let tick = 0; + const ticking = () => new Date(Date.now() + tick * 1000); + const coord = new ApprovalCoordinator(store, new Map([[chain.chainId, chain]]), ticking); + const { request } = coord.openRequest(makeBinding(), { + policyRuleId: 'r', + policyVersion: 'v1', + chainId: 'default', + ttlSeconds: 0, + }); + tick = 1; // advance clock past expiry + expect(() => + coord.submitEntry(request.approvalRequestId, { + stageIndex: 0, + approverKind: 'human', + approverIdentity: ALICE, + identityAssurance: 'session', + decision: 'allow', + }), + ).toThrow(ApprovalProtocolError); + }); +}); + +// --------------------------------------------------------------------------- +// ApprovalCoordinator — validateForExecution +// --------------------------------------------------------------------------- + +describe('ApprovalCoordinator.validateForExecution', () => { + function setupApproved() { + const store = new InMemoryApprovalStore(); + const chain = makeChain(); + const coord = new ApprovalCoordinator(store, new Map([[chain.chainId, chain]])); + const binding = makeBinding({ amount: 100 }); + const { request } = coord.openRequest(binding, { + policyRuleId: 'r', + policyVersion: 'v1', + chainId: 'default', + ttlSeconds: 300, + }); + coord.submitEntry(request.approvalRequestId, { + stageIndex: 0, + approverKind: 'human', + approverIdentity: ALICE, + identityAssurance: 'session', + decision: 'allow', + }); + return { coord, store, request, binding, chain }; + } + + it('returns OK and allows execution for an approved request', () => { + const { coord, request, chain } = setupApproved(); + const result = coord.validateForExecution(request.approvalRequestId, { + currentActionDigest: request.actionDigest, + currentPolicyVersion: 'v1', + currentChainVersion: chain.version, + }); + expect(result.allowed).toBe(true); + expect(result.reasonCode).toBe(ReasonCode.OK); + }); + + it('consumes the approval exactly once', () => { + const { coord, store, request, chain } = setupApproved(); + coord.validateForExecution(request.approvalRequestId, { + currentActionDigest: request.actionDigest, + currentPolicyVersion: 'v1', + currentChainVersion: chain.version, + }); + expect(store.getRequest(request.approvalRequestId)!.status).toBe('consumed'); + const second = coord.validateForExecution(request.approvalRequestId, { + currentActionDigest: request.actionDigest, + currentPolicyVersion: 'v1', + currentChainVersion: chain.version, + }); + expect(second.allowed).toBe(false); + expect(second.reasonCode).toBe(ReasonCode.ALREADY_CONSUMED); + }); + + it('rejects on action digest mismatch', () => { + const { coord, request, chain } = setupApproved(); + const result = coord.validateForExecution(request.approvalRequestId, { + currentActionDigest: 'sha256:deadbeef', + currentPolicyVersion: 'v1', + currentChainVersion: chain.version, + }); + expect(result.allowed).toBe(false); + expect(result.reasonCode).toBe(ReasonCode.ACTION_DIGEST_MISMATCH); + }); + + it('rejects on policy version mismatch', () => { + const { coord, request, chain } = setupApproved(); + const result = coord.validateForExecution(request.approvalRequestId, { + currentActionDigest: request.actionDigest, + currentPolicyVersion: 'v2', + currentChainVersion: chain.version, + }); + expect(result.allowed).toBe(false); + expect(result.reasonCode).toBe(ReasonCode.POLICY_VERSION_MISMATCH); + }); + + it('rejects on chain version mismatch', () => { + const { coord, request } = setupApproved(); + const result = coord.validateForExecution(request.approvalRequestId, { + currentActionDigest: request.actionDigest, + currentPolicyVersion: 'v1', + currentChainVersion: 'v99', + }); + expect(result.allowed).toBe(false); + expect(result.reasonCode).toBe(ReasonCode.CHAIN_VERSION_MISMATCH); + }); + + it('rejects unknown request', () => { + const coord = makeCoordinator(); + const result = coord.validateForExecution('nonexistent', { + currentActionDigest: 'sha256:abc', + currentPolicyVersion: 'v1', + currentChainVersion: '1', + }); + expect(result.allowed).toBe(false); + expect(result.reasonCode).toBe(ReasonCode.UNKNOWN_REQUEST); + }); + + it('rejects expired request (TTL=0)', () => { + const store = new InMemoryApprovalStore(); + const chain = makeChain(); + let tick = 0; + const ticking = () => new Date(Date.now() + tick * 1000); + const coord = new ApprovalCoordinator(store, new Map([[chain.chainId, chain]]), ticking); + const binding = makeBinding({ amount: 100 }); + const { request } = coord.openRequest(binding, { + policyRuleId: 'r', + policyVersion: 'v1', + chainId: 'default', + ttlSeconds: 0, + }); + tick = 1; // advance clock past expiry + const result = coord.validateForExecution(request.approvalRequestId, { + currentActionDigest: request.actionDigest, + currentPolicyVersion: 'v1', + currentChainVersion: chain.version, + }); + expect(result.allowed).toBe(false); + expect(result.reasonCode).toBe(ReasonCode.EXPIRED); + }); +}); + +// --------------------------------------------------------------------------- +// _maybeResolve — zero required stages (fail-closed) +// --------------------------------------------------------------------------- + +describe('ApprovalCoordinator zero-required-stages guard', () => { + it('denies immediately when chain has no required stages (vacuous allow is unsafe)', () => { + const store = new InMemoryApprovalStore(); + // All stages are advisory-only (required: false) → zero required stages + const chain: ApprovalChain = { + chainId: 'no-required', + version: '1', + stages: [makeApprovalStage({ stageIndex: 0, allowedIdentities: [ALICE], required: false })], + }; + const coord = new ApprovalCoordinator(store, new Map([[chain.chainId, chain]])); + const { request } = coord.openRequest(makeBinding(), { + policyRuleId: 'r', + policyVersion: 'v1', + chainId: 'no-required', + ttlSeconds: 300, + }); + coord.submitEntry(request.approvalRequestId, { + stageIndex: 0, + approverKind: 'human', + approverIdentity: ALICE, + identityAssurance: 'session', + decision: 'allow', + }); + // A chain with zero required stages must not vacuously resolve to allow. + const req = store.getRequest(request.approvalRequestId)!; + expect(req.status).toBe('denied'); + expect(store.getResolution(request.approvalRequestId)!.outcome).toBe('deny'); + }); +}); + +// --------------------------------------------------------------------------- +// Chain integrity +// --------------------------------------------------------------------------- + +describe('chain integrity', () => { + it('verifyEntryDigest returns true for a sealed entry', () => { + const coord = makeCoordinator(); + const { request } = coord.openRequest(makeBinding(), { + policyRuleId: 'r', + policyVersion: 'v1', + chainId: 'default', + ttlSeconds: 300, + }); + const entry = coord.submitEntry(request.approvalRequestId, { + stageIndex: 0, + approverKind: 'human', + approverIdentity: ALICE, + identityAssurance: 'session', + decision: 'allow', + }); + expect(verifyEntryDigest(entry)).toBe(true); + }); + + it('rejects tampered chain', () => { + const store = new InMemoryApprovalStore(); + const chain = makeChain(); + const coord = new ApprovalCoordinator(store, new Map([[chain.chainId, chain]])); + const { request } = coord.openRequest(makeBinding(), { + policyRuleId: 'r', + policyVersion: 'v1', + chainId: 'default', + ttlSeconds: 300, + }); + const entry = coord.submitEntry(request.approvalRequestId, { + stageIndex: 0, + approverKind: 'human', + approverIdentity: ALICE, + identityAssurance: 'session', + decision: 'allow', + }); + // Tamper with the entry digest + entry.entryDigest = 'sha256:' + '0'.repeat(64); + const result = coord.validateForExecution(request.approvalRequestId, { + currentActionDigest: request.actionDigest, + currentPolicyVersion: 'v1', + currentChainVersion: chain.version, + }); + expect(result.allowed).toBe(false); + expect(result.reasonCode).toBe(ReasonCode.CHAIN_TAMPERED); + }); +}); + +// --------------------------------------------------------------------------- +// InMemoryApprovalStore +// --------------------------------------------------------------------------- + +describe('InMemoryApprovalStore', () => { + it('consume returns true once, then false', () => { + const coord = makeCoordinator(); + const { request } = coord.openRequest(makeBinding(), { + policyRuleId: 'r', + policyVersion: 'v1', + chainId: 'default', + ttlSeconds: 300, + }); + coord.submitEntry(request.approvalRequestId, { + stageIndex: 0, + approverKind: 'human', + approverIdentity: ALICE, + identityAssurance: 'session', + decision: 'allow', + }); + const store = new InMemoryApprovalStore(); + // Direct store test + const store2 = new InMemoryApprovalStore(); + const chain = makeChain(); + const coord2 = new ApprovalCoordinator(store2, new Map([[chain.chainId, chain]])); + const { request: r2 } = coord2.openRequest(makeBinding(), { + policyRuleId: 'r', + policyVersion: 'v1', + chainId: 'default', + ttlSeconds: 300, + }); + coord2.submitEntry(r2.approvalRequestId, { + stageIndex: 0, + approverKind: 'human', + approverIdentity: ALICE, + identityAssurance: 'session', + decision: 'allow', + }); + expect(store2.consume(r2.approvalRequestId)).toBe(true); + expect(store2.consume(r2.approvalRequestId)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// makeApprovalStage / stageAuthorizes / getStage +// --------------------------------------------------------------------------- + +describe('ApprovalStage', () => { + it('authorizes by identity', () => { + const stage = makeApprovalStage({ stageIndex: 0, allowedIdentities: ['alice'] }); + expect(stageAuthorizes(stage, 'alice', [])).toBe(true); + expect(stageAuthorizes(stage, 'bob', [])).toBe(false); + }); + + it('authorizes by role', () => { + const stage = makeApprovalStage({ stageIndex: 0, allowedRoles: ['approver'] }); + expect(stageAuthorizes(stage, 'unknown', ['approver'])).toBe(true); + expect(stageAuthorizes(stage, 'unknown', ['other'])).toBe(false); + }); +});