From f0c3ff412eb2c022dbe760b7b94ea842f766a269 Mon Sep 17 00:00:00 2001 From: Murthy G Date: Fri, 16 Oct 2026 18:39:45 -0500 Subject: [PATCH 1/9] TypeScript SDK: Let validators require human confirmation, fail-closed Implements the "Requiring Confirmation" proposal from #45: - Wire types: ValidationResult.confirmation (ConfirmationRequest) and invocation context.target / context.confirmations. - RFC 8785 (JCS) canonicalization and the call binding hash ("sha256:" + hex SHA-256 of { event, phase, target, payload }). - Validator helpers: confirmationRequired, findConfirmation, requireConfirmation, and self-verifying HMAC tokens bound to id, callHash, sessionId and expiry. - Invoker: runValidationPhase asks only when every blocking result carries a confirmation, blocks on denial/unreachable/expiry or a request not bound to this call, re-validates each validator with its own confirmation, and never asks for audit-mode validators. isConfirmedCall re-checks the payload after later mutations. Co-Authored-By: Claude Opus 5.5 --- typescript/sdk/README.md | 32 +++ typescript/sdk/src/confirmation.test.ts | 204 +++++++++++++++++ typescript/sdk/src/confirmation.ts | 206 +++++++++++++++++ typescript/sdk/src/index.ts | 4 + typescript/sdk/src/jcs.ts | 50 ++++ typescript/sdk/src/types.ts | 101 ++++++++ typescript/sdk/src/validation.test.ts | 233 +++++++++++++++++++ typescript/sdk/src/validation.ts | 291 ++++++++++++++++++++++++ 8 files changed, 1121 insertions(+) create mode 100644 typescript/sdk/src/confirmation.test.ts create mode 100644 typescript/sdk/src/confirmation.ts create mode 100644 typescript/sdk/src/jcs.ts create mode 100644 typescript/sdk/src/types.ts create mode 100644 typescript/sdk/src/validation.test.ts create mode 100644 typescript/sdk/src/validation.ts diff --git a/typescript/sdk/README.md b/typescript/sdk/README.md index 37523a0..d37a0b8 100644 --- a/typescript/sdk/README.md +++ b/typescript/sdk/README.md @@ -19,6 +19,38 @@ import { Client, Server } from '@modelcontextprotocol/sdk'; // Example usage will be added as the implementation progresses ``` +### Requiring confirmation + +A validator can block a call unless a human approves it. The request is +bound to the exact `{ event, phase, target, payload }`, so an approval +cannot be replayed for a different call: + +```typescript +import { requireConfirmation, runValidationPhase, isConfirmedCall } from '@ext-modelcontextprotocol/interceptors'; + +// Validator: passes only with a valid confirmation for this exact call. +const guard = { + name: 'public-write-guard', + invoke: async (params) => requireConfirmation(params, { reason: 'Post to a public repo?', secret }), +}; + +// Invoker: asks the person (e.g. via elicitation) and re-validates. +// Without `confirm`, the request simply blocks. +const outcome = await runValidationPhase({ + event: 'tools/call', + phase: 'request', + payload, + target: 'github', + validators: [guard], + confirm: async (prompt, signal) => ({ approved: await askUser(prompt, signal) }), +}); + +// After any later mutations, the payload must still match what was approved. +if (outcome.confirmedCallHash && !isConfirmedCall(finalCall, outcome.confirmedCallHash)) { + // block +} +``` + ## Development ```bash diff --git a/typescript/sdk/src/confirmation.test.ts b/typescript/sdk/src/confirmation.test.ts new file mode 100644 index 0000000..52bb955 --- /dev/null +++ b/typescript/sdk/src/confirmation.test.ts @@ -0,0 +1,204 @@ +// Copyright 2025 The MCP Interceptors Authors. All rights reserved. +// Use of this source code is governed by a Apache-2.0 +// license that can be found in the LICENSE file. + +import { createHash } from 'node:crypto'; +import { describe, it, expect } from 'vitest'; + +import { + callBinding, + computeCallHash, + confirmationRequired, + createConfirmationToken, + findConfirmation, + requireConfirmation, + verifyConfirmationToken, +} from './confirmation.js'; +import { canonicalize } from './jcs.js'; +import type { Confirmation, InterceptorInvocationParams } from './types.js'; + +const SECRET = 'test-secret'; + +function params(overrides: Partial = {}): InterceptorInvocationParams { + return { + name: 'public-write-guard', + event: 'tools/call', + phase: 'request', + payload: { name: 'post_issue', arguments: { repo: 'acme/site', body: 'hi' } }, + ...overrides, + context: { timestamp: '2026-10-16T00:00:00Z', target: 'github', sessionId: 's1', ...overrides.context }, + }; +} + +function confirmationFor(p: InterceptorInvocationParams, at = new Date()): Confirmation { + const req = confirmationRequired(p, { reason: 'r', secret: SECRET, now: () => at }).confirmation!; + return { interceptor: p.name, id: req.id, callHash: req.callHash, token: req.token, decidedAt: at.toISOString() }; +} + +describe('canonicalize', () => { + it('sorts keys and has no whitespace', () => { + expect(canonicalize({ b: [1, { d: true, c: null }], a: 'x' })).toBe('{"a":"x","b":[1,{"c":null,"d":true}]}'); + }); + + it('serializes numbers as ECMAScript does (RFC 8785 examples)', () => { + expect(canonicalize([1e21, 1e-7, -0, 0.1, 4.5, 0.002])).toBe('[1e+21,1e-7,0,0.1,4.5,0.002]'); + }); + + it('orders keys by UTF-16 code units', () => { + expect(canonicalize({ '\u20ac': 1, '\ud83d\ude00': 2, '\r': 3, '1': 4 })).toBe( + '{"\\r":3,"1":4,"\u20ac":1,"\ud83d\ude00":2}', + ); + }); + + it('omits undefined properties', () => { + expect(canonicalize({ a: undefined, b: 1 })).toBe('{"b":1}'); + }); + + it('rejects values without an I-JSON form', () => { + expect(() => canonicalize(NaN)).toThrow(TypeError); + expect(() => canonicalize(1n)).toThrow(TypeError); + expect(() => canonicalize('\ud800')).toThrow(TypeError); + expect(() => canonicalize([undefined])).toThrow(TypeError); + }); +}); + +describe('computeCallHash', () => { + it('is sha256 over the JCS form of { event, phase, target, payload }', () => { + const p = params(); + const expected = createHash('sha256') + .update( + '{"event":"tools/call","payload":{"arguments":{"body":"hi","repo":"acme/site"},"name":"post_issue"},"phase":"request","target":"github"}', + ) + .digest('hex'); + expect(computeCallHash(callBinding(p))).toBe(`sha256:${expected}`); + }); + + it('uses null when target is absent', () => { + const p: InterceptorInvocationParams = { name: 'v', event: 'e', phase: 'request', payload: 1 }; + expect(callBinding(p).target).toBeNull(); + }); + + it('differs for the same call to a different target', () => { + const a = computeCallHash(callBinding(params())); + const b = computeCallHash(callBinding(params({ context: { timestamp: 't', target: 'gitlab' } }))); + expect(a).not.toBe(b); + }); + + it('ignores payload key order', () => { + const a = params({ payload: { x: 1, y: 2 } }); + const b = params({ payload: { y: 2, x: 1 } }); + expect(computeCallHash(callBinding(a))).toBe(computeCallHash(callBinding(b))); + }); +}); + +describe('confirmation tokens', () => { + const claims = { id: 'r1', callHash: 'sha256:aa', sessionId: 's1', expiresAt: '2026-10-16T00:05:00.000Z' }; + const before = new Date('2026-10-16T00:00:00Z'); + + it('verifies for the same id, callHash and session before expiry', () => { + const token = createConfirmationToken(SECRET, claims); + expect(verifyConfirmationToken(SECRET, token, claims, before)).toBe(true); + }); + + it('rejects after expiry', () => { + const token = createConfirmationToken(SECRET, claims); + expect(verifyConfirmationToken(SECRET, token, claims, new Date('2026-10-16T00:05:00Z'))).toBe(false); + }); + + it('rejects other ids, calls, sessions and secrets', () => { + const token = createConfirmationToken(SECRET, claims); + expect(verifyConfirmationToken(SECRET, token, { ...claims, id: 'r2' }, before)).toBe(false); + expect(verifyConfirmationToken(SECRET, token, { ...claims, callHash: 'sha256:bb' }, before)).toBe(false); + expect(verifyConfirmationToken(SECRET, token, { ...claims, sessionId: 's2' }, before)).toBe(false); + expect(verifyConfirmationToken('other', token, claims, before)).toBe(false); + }); + + it('rejects a tampered expiry', () => { + const [v, , mac] = createConfirmationToken(SECRET, claims).split('.'); + const later = Buffer.from('2099-01-01T00:00:00.000Z').toString('base64url'); + expect(verifyConfirmationToken(SECRET, `${v}.${later}.${mac}`, claims, before)).toBe(false); + }); + + it('rejects malformed tokens', () => { + expect(verifyConfirmationToken(SECRET, 'garbage', claims, before)).toBe(false); + expect(verifyConfirmationToken(SECRET, 'v1.x.y', claims, before)).toBe(false); + }); +}); + +describe('confirmationRequired', () => { + it('returns a blocking error carrying the request', () => { + const now = new Date('2026-10-16T00:00:00Z'); + const p = params(); + const result = confirmationRequired(p, { reason: 'Post publicly?', secret: SECRET, ttlMs: 60_000, now: () => now }); + expect(result).toMatchObject({ interceptor: p.name, type: 'validation', phase: 'request', valid: false, severity: 'error' }); + expect(result.confirmation).toMatchObject({ + reason: 'Post publicly?', + callHash: computeCallHash(callBinding(p)), + expiresAt: '2026-10-16T00:01:00.000Z', + }); + expect(result.confirmation!.token).toBeTypeOf('string'); + }); + + it('issues a fresh id per request', () => { + const p = params(); + expect(confirmationRequired(p, { reason: 'r' }).confirmation!.id).not.toBe( + confirmationRequired(p, { reason: 'r' }).confirmation!.id, + ); + }); +}); + +describe('findConfirmation / requireConfirmation', () => { + it('accepts a confirmation for the exact call', () => { + const p = params(); + const c = confirmationFor(p); + const withC = params({ context: { timestamp: 't', target: 'github', sessionId: 's1', confirmations: [c] } }); + expect(findConfirmation(withC, { secret: SECRET })).toEqual(c); + expect(requireConfirmation(withC, { reason: 'r', secret: SECRET }).valid).toBe(true); + }); + + it('rejects a confirmation for different arguments', () => { + const c = confirmationFor(params()); + const other = params({ + payload: { name: 'post_issue', arguments: { repo: 'acme/site', body: 'secrets' } }, + context: { timestamp: 't', target: 'github', sessionId: 's1', confirmations: [c] }, + }); + expect(findConfirmation(other, { secret: SECRET })).toBeUndefined(); + expect(requireConfirmation(other, { reason: 'r', secret: SECRET }).confirmation).toBeDefined(); + }); + + it('rejects a confirmation for another target', () => { + const c = confirmationFor(params()); + const other = params({ context: { timestamp: 't', target: 'gitlab', sessionId: 's1', confirmations: [c] } }); + expect(findConfirmation(other, { secret: SECRET })).toBeUndefined(); + }); + + it('rejects a confirmation from another session', () => { + const c = confirmationFor(params()); + const other = params({ context: { timestamp: 't', target: 'github', sessionId: 's2', confirmations: [c] } }); + expect(findConfirmation(other, { secret: SECRET })).toBeUndefined(); + }); + + it('rejects a confirmation addressed to another validator', () => { + const c = { ...confirmationFor(params()), interceptor: 'someone-else' }; + const withC = params({ context: { timestamp: 't', target: 'github', sessionId: 's1', confirmations: [c] } }); + expect(findConfirmation(withC, { secret: SECRET })).toBeUndefined(); + }); + + it('rejects an expired confirmation', () => { + const c = confirmationFor(params(), new Date('2026-10-16T00:00:00Z')); + const withC = params({ context: { timestamp: 't', target: 'github', sessionId: 's1', confirmations: [c] } }); + expect(findConfirmation(withC, { secret: SECRET, now: () => new Date('2026-10-16T01:00:00Z') })).toBeUndefined(); + }); + + it('rejects a missing or forged token when a secret is set', () => { + const c = confirmationFor(params()); + const noToken = params({ + context: { timestamp: 't', target: 'github', sessionId: 's1', confirmations: [{ ...c, token: undefined }] }, + }); + const forged = params({ + context: { timestamp: 't', target: 'github', sessionId: 's1', confirmations: [{ ...c, id: 'other' }] }, + }); + expect(findConfirmation(noToken, { secret: SECRET })).toBeUndefined(); + expect(findConfirmation(forged, { secret: SECRET })).toBeUndefined(); + }); +}); diff --git a/typescript/sdk/src/confirmation.ts b/typescript/sdk/src/confirmation.ts new file mode 100644 index 0000000..b67f49b --- /dev/null +++ b/typescript/sdk/src/confirmation.ts @@ -0,0 +1,206 @@ +// Copyright 2025 The MCP Interceptors Authors. All rights reserved. +// Use of this source code is governed by a Apache-2.0 +// license that can be found in the LICENSE file. + +/** + * Validator confirmations ("Requiring Confirmation" in the SEP). + * + * A validator that wants a human to approve a call returns a blocking + * ValidationResult carrying a ConfirmationRequest. The request is bound to + * the exact call through `callHash`, so an approval for one call cannot be + * replayed for another. + */ + +import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto'; + +import { canonicalize } from './jcs.js'; +import type { + Confirmation, + InterceptionEvent, + InterceptorInvocationParams, + InterceptorPhase, + ValidationMessage, + ValidationResult, +} from './types.js'; + +/** The object `callHash` covers. */ +export interface CallBinding { + event: InterceptionEvent; + phase: InterceptorPhase; + /** `context.target`, or null if absent. */ + target: string | null; + payload: unknown; +} + +/** Builds the call binding of an invocation. */ +export function callBinding( + params: Pick, +): CallBinding { + return { + event: params.event, + phase: params.phase, + target: params.context?.target ?? null, + payload: params.payload, + }; +} + +/** Returns "sha256:" followed by the lowercase hex SHA-256 of the binding's JCS form. */ +export function computeCallHash(binding: CallBinding): string { + const digest = createHash('sha256').update(canonicalize(binding), 'utf8').digest('hex'); + return `sha256:${digest}`; +} + +export type ConfirmationSecret = string | Uint8Array; + +const TOKEN_VERSION = 'v1'; + +interface TokenClaims { + id: string; + callHash: string; + sessionId?: string; + expiresAt?: string; +} + +function tokenMac(secret: ConfirmationSecret, claims: TokenClaims): Buffer { + const message = canonicalize([ + TOKEN_VERSION, + claims.id, + claims.callHash, + claims.sessionId ?? null, + claims.expiresAt ?? null, + ]); + return createHmac('sha256', secret).update(message, 'utf8').digest(); +} + +/** + * Issues a self-verifying token: an HMAC over the request id, callHash, + * session id and expiry. The validator needs no pending-request state to + * check it, so a confirmation survives a reconnect. + */ +export function createConfirmationToken(secret: ConfirmationSecret, claims: TokenClaims): string { + const expiresAt = Buffer.from(claims.expiresAt ?? '', 'utf8').toString('base64url'); + const mac = tokenMac(secret, claims).toString('base64url'); + return `${TOKEN_VERSION}.${expiresAt}.${mac}`; +} + +/** + * Checks a token issued by createConfirmationToken for the given id, + * callHash and session, and that its embedded expiry has not passed. + */ +export function verifyConfirmationToken( + secret: ConfirmationSecret, + token: string, + claims: Omit, + now: Date = new Date(), +): boolean { + const parts = token.split('.'); + if (parts.length !== 3 || parts[0] !== TOKEN_VERSION) { + return false; + } + const expiresAt = Buffer.from(parts[1], 'base64url').toString('utf8') || undefined; + const expected = tokenMac(secret, { ...claims, expiresAt }); + const actual = Buffer.from(parts[2], 'base64url'); + if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) { + return false; + } + return expiresAt === undefined || now.getTime() < Date.parse(expiresAt); +} + +export interface ConfirmationOptions { + /** Human-readable explanation, shown to the person asked. */ + reason: string; + /** + * When set, the request carries a self-verifying token and only + * confirmations returning a valid token are accepted. Recommended. + */ + secret?: ConfirmationSecret; + /** Lifetime of the request. Default: 5 minutes. */ + ttlMs?: number; + /** Additional messages for the blocking result. */ + messages?: ValidationMessage[]; + now?: () => Date; +} + +const DEFAULT_TTL_MS = 5 * 60 * 1000; + +/** + * Returns the blocking result a validator sends to ask for confirmation of + * this invocation: `valid: false`, `severity: "error"` and `confirmation`. + * An invoker that does not implement confirmation simply blocks. + */ +export function confirmationRequired( + params: InterceptorInvocationParams, + options: ConfirmationOptions, +): ValidationResult { + const now = options.now?.() ?? new Date(); + const id = randomUUID(); + const callHash = computeCallHash(callBinding(params)); + const expiresAt = new Date(now.getTime() + (options.ttlMs ?? DEFAULT_TTL_MS)).toISOString(); + const token = + options.secret === undefined + ? undefined + : createConfirmationToken(options.secret, { + id, + callHash, + sessionId: params.context?.sessionId, + expiresAt, + }); + return { + interceptor: params.name, + type: 'validation', + phase: params.phase, + valid: false, + severity: 'error', + messages: [{ message: options.reason, severity: 'error' }, ...(options.messages ?? [])], + confirmation: { id, reason: options.reason, callHash, expiresAt, token }, + }; +} + +/** + * Returns the confirmation in `context.confirmations` that this validator + * can accept for this invocation, if any: one addressed to this validator + * whose callHash matches the invocation's call binding (including target) + * and, if `secret` is given, whose token is valid and unexpired. + */ +export function findConfirmation( + params: InterceptorInvocationParams, + options: Pick = {}, +): Confirmation | undefined { + const confirmations = params.context?.confirmations; + if (!confirmations?.length) { + return undefined; + } + const callHash = computeCallHash(callBinding(params)); + const now = options.now?.() ?? new Date(); + return confirmations.find((c) => { + if (c.interceptor !== params.name || c.callHash !== callHash) { + return false; + } + if (options.secret === undefined) { + return true; + } + return ( + c.token !== undefined && + verifyConfirmationToken( + options.secret, + c.token, + { id: c.id, callHash, sessionId: params.context?.sessionId }, + now, + ) + ); + }); +} + +/** + * Convenience for validators: passes if the invocation carries an + * acceptable confirmation, otherwise asks for one. + */ +export function requireConfirmation( + params: InterceptorInvocationParams, + options: ConfirmationOptions, +): ValidationResult { + if (findConfirmation(params, options)) { + return { interceptor: params.name, type: 'validation', phase: params.phase, valid: true }; + } + return confirmationRequired(params, options); +} diff --git a/typescript/sdk/src/index.ts b/typescript/sdk/src/index.ts index d85f0db..6d47ac4 100644 --- a/typescript/sdk/src/index.ts +++ b/typescript/sdk/src/index.ts @@ -10,3 +10,7 @@ */ export * from './interceptors.js'; +export * from './types.js'; +export * from './jcs.js'; +export * from './confirmation.js'; +export * from './validation.js'; diff --git a/typescript/sdk/src/jcs.ts b/typescript/sdk/src/jcs.ts new file mode 100644 index 0000000..2854e35 --- /dev/null +++ b/typescript/sdk/src/jcs.ts @@ -0,0 +1,50 @@ +// Copyright 2025 The MCP Interceptors Authors. All rights reserved. +// Use of this source code is governed by a Apache-2.0 +// license that can be found in the LICENSE file. + +/** + * JSON Canonicalization Scheme (RFC 8785). + * + * RFC 8785 defines number and string serialization in terms of ECMAScript + * JSON.stringify, and property order as UTF-16 code unit order, which is + * what Array.prototype.sort compares by default. + */ + +const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?; + const keys = Object.keys(obj) + .filter((k) => obj[k] !== undefined) + .sort(); + return `{${keys.map((k) => `${canonicalize(k)}:${canonicalize(obj[k])}`).join(',')}}`; + } + default: + throw new TypeError(`JCS: cannot serialize ${typeof value}`); + } +} diff --git a/typescript/sdk/src/types.ts b/typescript/sdk/src/types.ts new file mode 100644 index 0000000..e7b8768 --- /dev/null +++ b/typescript/sdk/src/types.ts @@ -0,0 +1,101 @@ +// Copyright 2025 The MCP Interceptors Authors. All rights reserved. +// Use of this source code is governed by a Apache-2.0 +// license that can be found in the LICENSE file. + +/** + * Wire types from SEP-2624 (docs/sep.md) used by this SDK. + */ + +/** A Lifecycle Event, e.g. "tools/call", or a wildcard such as "*". */ +export type InterceptionEvent = string; + +export type InterceptorPhase = 'request' | 'response'; + +export type InterceptorMode = 'active' | 'audit'; + +export type ValidationSeverity = 'info' | 'warn' | 'error'; + +/** + * Returned by a validator, together with `valid: false` and + * `severity: "error"`, when the payload may proceed only if a human + * confirms it. See "Requiring Confirmation" in the SEP. + */ +export interface ConfirmationRequest { + /** Unique per request; identifies the pending invocation this confirmation is for. */ + id: string; + /** Human-readable explanation, suitable to show to the person asked. */ + reason: string; + /** Call binding hash: "sha256:" + lowercase hex SHA-256 of the JCS form of the binding. */ + callHash: string; + /** ISO 8601; after this, a confirmation for this request MUST NOT be accepted. */ + expiresAt?: string; + /** Opaque value the invoker MUST return unchanged with the confirmation. */ + token?: string; +} + +/** A human decision, returned to the validator in `context.confirmations`. */ +export interface Confirmation { + /** Name of the validator that requested it. */ + interceptor: string; + /** From the ConfirmationRequest. */ + id: string; + /** From the ConfirmationRequest. */ + callHash: string; + /** From the ConfirmationRequest, unchanged. */ + token?: string; + /** ISO 8601. */ + decidedAt: string; + /** Who confirmed, if known. */ + principal?: { type: 'user' | 'service'; id?: string }; +} + +export interface ValidationMessage { + path?: string; + message: string; + severity: ValidationSeverity; +} + +export interface ValidationResult { + interceptor: string; + type: 'validation'; + phase: InterceptorPhase; + durationMs?: number; + info?: Record; + + valid: boolean; + severity?: ValidationSeverity; + messages?: ValidationMessage[]; + suggestions?: Array<{ path: string; value: unknown }>; + /** Only meaningful with `valid: false` and `severity: "error"`. */ + confirmation?: ConfirmationRequest; +} + +export interface InvocationContext { + principal?: { + type: 'user' | 'service' | 'anonymous'; + id?: string; + claims?: Record; + }; + traceId?: string; + spanId?: string; + /** ISO 8601. */ + timestamp: string; + sessionId?: string; + /** + * Peer the payload is sent to or, when receiving, came from (e.g. a server + * name). Part of the call binding for confirmations. + */ + target?: string; + /** Human confirmations for this exact payload. */ + confirmations?: Confirmation[]; +} + +export interface InterceptorInvocationParams { + name: string; + event: InterceptionEvent; + phase: InterceptorPhase; + payload: unknown; + config?: Record; + timeoutMs?: number; + context?: InvocationContext; +} diff --git a/typescript/sdk/src/validation.test.ts b/typescript/sdk/src/validation.test.ts new file mode 100644 index 0000000..6b173c5 --- /dev/null +++ b/typescript/sdk/src/validation.test.ts @@ -0,0 +1,233 @@ +// Copyright 2025 The MCP Interceptors Authors. All rights reserved. +// Use of this source code is governed by a Apache-2.0 +// license that can be found in the LICENSE file. + +import { describe, it, expect, vi } from 'vitest'; + +import { requireConfirmation } from './confirmation.js'; +import type { InterceptorInvocationParams, ValidationResult } from './types.js'; +import { + isConfirmedCall, + runValidationPhase, + type ConfirmationHandler, + type ValidationPhaseOptions, + type ValidatorEntry, +} from './validation.js'; + +const SECRET = 'test-secret'; +const PAYLOAD = { name: 'post_issue', arguments: { repo: 'acme/site', body: 'hi' } }; + +function pass(name: string): ValidatorEntry { + return { + name, + invoke: (p) => Promise.resolve({ interceptor: name, type: 'validation', phase: p.phase, valid: true }), + }; +} + +function reject(name: string, extra: Partial = {}): ValidatorEntry { + return { + name, + invoke: (p): Promise => + Promise.resolve({ + interceptor: name, + type: 'validation', + phase: p.phase, + valid: false, + severity: 'error', + messages: [{ message: 'no', severity: 'error' }], + }), + ...extra, + }; +} + +function confirming(name = 'public-write-guard', ttlMs?: number): ValidatorEntry & { calls: InterceptorInvocationParams[] } { + const calls: InterceptorInvocationParams[] = []; + return { + name, + calls, + invoke: (p) => { + calls.push(p); + return Promise.resolve(requireConfirmation(p, { reason: 'Post to a public repo?', secret: SECRET, ttlMs })); + }, + }; +} + +function options(overrides: Partial): ValidationPhaseOptions { + return { + event: 'tools/call', + phase: 'request', + payload: PAYLOAD, + target: 'github', + context: { sessionId: 's1' }, + validators: [], + ...overrides, + }; +} + +const approve: ConfirmationHandler = () => Promise.resolve({ approved: true, principal: { type: 'user', id: 'u1' } }); + +describe('runValidationPhase', () => { + it('allows when every validator passes, without asking', async () => { + const confirm = vi.fn(approve); + const out = await runValidationPhase(options({ validators: [pass('a'), pass('b')], confirm })); + expect(out.allowed).toBe(true); + expect(out.confirmedCallHash).toBeUndefined(); + expect(confirm).not.toHaveBeenCalled(); + }); + + it('sets context.target on every invocation', async () => { + const v = confirming(); + await runValidationPhase(options({ validators: [v] })); + expect(v.calls[0].context?.target).toBe('github'); + }); + + it('blocks a confirmation request when the invoker has no handler (fail-closed)', async () => { + const out = await runValidationPhase(options({ validators: [confirming()] })); + expect(out.allowed).toBe(false); + expect(out.reason).toMatch(/confirmation required/); + }); + + it('asks, re-validates with the confirmation, and allows on approval', async () => { + const v = confirming(); + const confirm = vi.fn(approve); + const out = await runValidationPhase(options({ validators: [v, pass('other')], confirm })); + + expect(out.allowed).toBe(true); + expect(confirm).toHaveBeenCalledOnce(); + const prompt = confirm.mock.calls[0][0]; + expect(prompt).toMatchObject({ interceptor: v.name, target: 'github', payload: PAYLOAD }); + expect(prompt.request.reason).toBe('Post to a public repo?'); + + expect(v.calls).toHaveLength(2); + expect(v.calls[1].context?.confirmations).toEqual([ + expect.objectContaining({ + interceptor: v.name, + id: prompt.request.id, + callHash: prompt.request.callHash, + token: prompt.request.token, + principal: { type: 'user', id: 'u1' }, + }), + ]); + expect(out.confirmations).toHaveLength(1); + expect(out.confirmedCallHash).toBe(prompt.request.callHash); + expect(out.results.every((r) => r.valid)).toBe(true); + }); + + it('blocks without asking when another validator blocks plainly', async () => { + const confirm = vi.fn(approve); + const out = await runValidationPhase(options({ validators: [confirming(), reject('pii')], confirm })); + expect(out.allowed).toBe(false); + expect(out.reason).toMatch(/"pii" blocked/); + expect(confirm).not.toHaveBeenCalled(); + }); + + it('blocks without asking when another validator fails closed', async () => { + const confirm = vi.fn(approve); + const broken: ValidatorEntry = { name: 'broken', invoke: () => Promise.reject(new Error('boom')) }; + const out = await runValidationPhase(options({ validators: [confirming(), broken], confirm })); + expect(out.allowed).toBe(false); + expect(out.failures).toHaveLength(1); + expect(confirm).not.toHaveBeenCalled(); + }); + + it('blocks when the person denies', async () => { + const out = await runValidationPhase( + options({ validators: [confirming()], confirm: () => Promise.resolve({ approved: false }) }), + ); + expect(out.allowed).toBe(false); + expect(out.reason).toMatch(/denied/); + }); + + it('blocks when the handler cannot reach anyone', async () => { + const out = await runValidationPhase( + options({ validators: [confirming()], confirm: () => Promise.reject(new Error('no client')) }), + ); + expect(out.allowed).toBe(false); + expect(out.reason).toMatch(/could not be obtained/); + }); + + it('blocks and aborts the handler when no decision arrives before expiry', async () => { + let signal: AbortSignal | undefined; + const out = await runValidationPhase( + options({ + validators: [confirming('public-write-guard', 20)], + confirm: (_p, s) => { + signal = s; + return new Promise(() => {}); + }, + }), + ); + expect(out.allowed).toBe(false); + expect(out.reason).toMatch(/expired/); + expect(signal?.aborted).toBe(true); + }); + + it('blocks a request whose callHash is not bound to this call', async () => { + const confirm = vi.fn(approve); + const wrongTarget: ValidatorEntry = { + name: 'v', + invoke: (p) => + Promise.resolve( + requireConfirmation({ ...p, context: { ...p.context!, target: 'elsewhere' } }, { reason: 'r' }), + ), + }; + const out = await runValidationPhase(options({ validators: [wrongTarget], confirm })); + expect(out.allowed).toBe(false); + expect(out.reason).toMatch(/not bound/); + expect(confirm).not.toHaveBeenCalled(); + }); + + it('blocks when the validator still blocks after confirmation', async () => { + const confirm = vi.fn(approve); + let calls = 0; + const stubborn: ValidatorEntry = { + name: 'stubborn', + invoke: (p) => + calls++ === 0 + ? Promise.resolve(requireConfirmation(p, { reason: 'r', secret: SECRET })) + : reject('stubborn').invoke(p), + }; + const out = await runValidationPhase(options({ validators: [stubborn], confirm })); + expect(out.allowed).toBe(false); + expect(out.reason).toMatch(/"stubborn" blocked/); + expect(out.confirmedCallHash).toBeUndefined(); + expect(confirm).toHaveBeenCalledOnce(); + }); + + it('does not re-use a confirmation across phases', async () => { + const v = confirming(); + const first = await runValidationPhase(options({ validators: [v], confirm: approve })); + expect(first.allowed).toBe(true); + const second = await runValidationPhase(options({ validators: [v] })); + expect(second.allowed).toBe(false); + }); + + it('attaches each confirmation only to the validator that asked', async () => { + const a = confirming('a'); + const b = confirming('b'); + const out = await runValidationPhase(options({ validators: [a, b], confirm: approve })); + expect(out.allowed).toBe(true); + expect(a.calls[1].context?.confirmations?.map((c) => c.interceptor)).toEqual(['a']); + expect(b.calls[1].context?.confirmations?.map((c) => c.interceptor)).toEqual(['b']); + }); + + it('in audit mode, records the request but neither asks nor blocks', async () => { + const confirm = vi.fn(approve); + const v = { ...confirming(), mode: 'audit' as const }; + const out = await runValidationPhase(options({ validators: [v], confirm })); + expect(out.allowed).toBe(true); + expect(confirm).not.toHaveBeenCalled(); + expect(out.results[0].confirmation).toBeDefined(); + }); +}); + +describe('isConfirmedCall', () => { + it('detects a payload changed after confirmation', async () => { + const out = await runValidationPhase(options({ validators: [confirming()], confirm: approve })); + const hash = out.confirmedCallHash!; + const same = { event: 'tools/call', phase: 'request' as const, target: 'github', payload: PAYLOAD }; + expect(isConfirmedCall(same, hash)).toBe(true); + expect(isConfirmedCall({ ...same, payload: { ...PAYLOAD, arguments: { repo: 'x' } } }, hash)).toBe(false); + expect(isConfirmedCall({ ...same, target: 'gitlab' }, hash)).toBe(false); + }); +}); diff --git a/typescript/sdk/src/validation.ts b/typescript/sdk/src/validation.ts new file mode 100644 index 0000000..763dcc6 --- /dev/null +++ b/typescript/sdk/src/validation.ts @@ -0,0 +1,291 @@ +// Copyright 2025 The MCP Interceptors Authors. All rights reserved. +// Use of this source code is governed by a Apache-2.0 +// license that can be found in the LICENSE file. + +/** + * Invoker-side validation phase, including validator confirmations. + * + * Validators run in parallel. Only `severity: "error"` from an active + * validator blocks. A blocking result that carries a `confirmation` may be + * unblocked by a human; every other path that cannot reach a decision + * blocks. + */ + +import { callBinding, computeCallHash, type CallBinding } from './confirmation.js'; +import type { + Confirmation, + ConfirmationRequest, + InterceptionEvent, + InterceptorInvocationParams, + InterceptorMode, + InterceptorPhase, + InvocationContext, + ValidationResult, +} from './types.js'; + +/** A validator as seen by the invoker. */ +export interface ValidatorEntry { + name: string; + /** Default: "active". */ + mode?: InterceptorMode; + /** Default: false (fail-closed). */ + failOpen?: boolean; + config?: Record; + invoke(params: InterceptorInvocationParams): Promise; +} + +/** What the invoker should show the person asked. */ +export interface ConfirmationPrompt { + interceptor: string; + request: ConfirmationRequest; + event: InterceptionEvent; + phase: InterceptorPhase; + target: string; + /** The exact payload that will be sent or processed if approved. */ + payload: unknown; +} + +export interface ConfirmationDecision { + approved: boolean; + principal?: Confirmation['principal']; +} + +/** + * Obtains a human decision, e.g. through an MCP `elicitation/create` + * request. `signal` aborts when the request expires. Throwing blocks. + */ +export type ConfirmationHandler = ( + prompt: ConfirmationPrompt, + signal: AbortSignal, +) => Promise; + +export interface ValidationPhaseOptions { + event: InterceptionEvent; + phase: InterceptorPhase; + payload: unknown; + /** Peer the payload is sent to or came from. Set on every invocation. */ + target: string; + context?: Omit; + timeoutMs?: number; + validators: ValidatorEntry[]; + /** Omit to treat every confirmation request as a plain block. */ + confirm?: ConfirmationHandler; + now?: () => Date; +} + +export interface ValidatorFailure { + interceptor: string; + mode: InterceptorMode; + error: unknown; +} + +export interface ValidationPhaseOutcome { + allowed: boolean; + /** Why the payload was blocked. */ + reason?: string; + /** Latest result from each validator that returned one, audit included. */ + results: ValidationResult[]; + failures: ValidatorFailure[]; + /** Confirmations given in this phase. */ + confirmations: Confirmation[]; + /** + * Set when the payload was allowed because of confirmations. Before + * sending or processing, check the final payload with isConfirmedCall: + * mutations that run after validation must not change it. + */ + confirmedCallHash?: string; +} + +interface Attempt { + entry: ValidatorEntry; + result?: ValidationResult; + error?: unknown; +} + +function isActive(entry: ValidatorEntry): boolean { + return (entry.mode ?? 'active') === 'active'; +} + +function isBlockingResult(result: ValidationResult): boolean { + // A failed validation with no severity is treated as an error (fail-closed). + return result.severity === 'error' || (!result.valid && result.severity === undefined); +} + +function blocks(attempt: Attempt): boolean { + if (!isActive(attempt.entry)) { + return false; + } + if (attempt.error !== undefined) { + return !attempt.entry.failOpen; + } + return attempt.result !== undefined && isBlockingResult(attempt.result); +} + +function describe(attempt: Attempt): string { + if (attempt.error !== undefined) { + return `validator "${attempt.entry.name}" failed`; + } + const message = attempt.result?.messages?.[0]?.message; + return `validator "${attempt.entry.name}" blocked${message ? `: ${message}` : ''}`; +} + +/** + * Runs the validators of one phase and decides whether the payload may + * proceed, asking for confirmation when a validator requires it. + */ +export async function runValidationPhase(options: ValidationPhaseOptions): Promise { + const now = options.now ?? (() => new Date()); + const { event, phase, payload, target } = options; + const callHash = computeCallHash({ event, phase, target, payload }); + + const invoke = async (entry: ValidatorEntry, confirmations?: Confirmation[]): Promise => { + const params: InterceptorInvocationParams = { + name: entry.name, + event, + phase, + payload, + config: entry.config, + timeoutMs: options.timeoutMs, + context: { + ...options.context, + timestamp: now().toISOString(), + target, + confirmations, + }, + }; + try { + return { entry, result: await entry.invoke(params) }; + } catch (error) { + return { entry, error }; + } + }; + + const outcome = ( + attempts: Attempt[], + allowed: boolean, + reason?: string, + confirmations: Confirmation[] = [], + ): ValidationPhaseOutcome => ({ + allowed, + reason, + results: attempts.flatMap((a) => (a.result ? [a.result] : [])), + failures: attempts.flatMap((a) => + a.error !== undefined ? [{ interceptor: a.entry.name, mode: a.entry.mode ?? 'active', error: a.error }] : [], + ), + confirmations, + confirmedCallHash: allowed && confirmations.length > 0 ? callHash : undefined, + }); + + const first = await Promise.all(options.validators.map((entry) => invoke(entry))); + const blocking = first.filter(blocks); + if (blocking.length === 0) { + return outcome(first, true); + } + + // A confirmation never overrides a plain block. + const plain = blocking.find((a) => !a.result?.confirmation); + if (plain) { + return outcome(first, false, describe(plain)); + } + if (!options.confirm) { + return outcome(first, false, `${describe(blocking[0])} (confirmation required)`); + } + + const confirmations: Confirmation[] = []; + for (const attempt of blocking) { + const request = attempt.result!.confirmation!; + const decision = await ask(options.confirm, { + interceptor: attempt.entry.name, + request, + event, + phase, + target, + payload, + }, callHash, now); + if (typeof decision === 'string') { + return outcome(first, false, `validator "${attempt.entry.name}": ${decision}`, confirmations); + } + confirmations.push({ + interceptor: attempt.entry.name, + id: request.id, + callHash: request.callHash, + token: request.token, + decidedAt: now().toISOString(), + principal: decision.principal, + }); + } + + // Re-validate with each validator's own confirmation attached. + const retried = await Promise.all( + blocking.map((attempt) => + invoke( + attempt.entry, + confirmations.filter((c) => c.interceptor === attempt.entry.name), + ), + ), + ); + const attempts = first.map((a) => retried.find((r) => r.entry === a.entry) ?? a); + const stillBlocking = retried.find(blocks); + if (stillBlocking) { + return outcome(attempts, false, describe(stillBlocking), confirmations); + } + return outcome(attempts, true, undefined, confirmations); +} + +/** Asks for one confirmation. Returns the approval, or why it was not obtained. */ +async function ask( + handler: ConfirmationHandler, + prompt: ConfirmationPrompt, + callHash: string, + now: () => Date, +): Promise { + const { request } = prompt; + if (request.callHash !== callHash) { + return 'confirmation request is not bound to this call'; + } + const expiresAt = request.expiresAt === undefined ? undefined : Date.parse(request.expiresAt); + if (expiresAt !== undefined && (Number.isNaN(expiresAt) || now().getTime() >= expiresAt)) { + return 'confirmation request expired'; + } + + const controller = new AbortController(); + let timer: ReturnType | undefined; + const expired = new Promise((resolve) => { + if (expiresAt !== undefined) { + timer = setTimeout(() => { + controller.abort(); + resolve('no decision before the confirmation request expired'); + }, expiresAt - now().getTime()); + } + }); + try { + const decision = await Promise.race([handler(prompt, controller.signal), expired]); + if (typeof decision === 'string') { + return decision; + } + if (!decision.approved) { + return 'confirmation denied'; + } + if (expiresAt !== undefined && now().getTime() >= expiresAt) { + return 'confirmation given after the request expired'; + } + return decision; + } catch { + return 'confirmation could not be obtained'; + } finally { + clearTimeout(timer); + } +} + +/** + * Checks that the payload about to be sent or processed, after any + * mutations that ran after validation, is the one that was confirmed. + * If this returns false the invoker MUST block. + */ +export function isConfirmedCall( + call: CallBinding | Pick, + confirmedCallHash: string, +): boolean { + const binding = 'target' in call ? call : callBinding(call); + return computeCallHash(binding) === confirmedCallHash; +} From 2abde8f068db2a5886457cc39cbfc75de25658e9 Mon Sep 17 00:00:00 2001 From: Murthy G Date: Fri, 16 Oct 2026 21:41:35 -0500 Subject: [PATCH 2/9] TypeScript SDK: Address review feedback on validator confirmation - JCS: reject array holes and non-plain objects (e.g. Date) instead of collapsing them, so a call hash cannot hide a wire-visible change. - Tokens: include the issuing validator's name in the MAC, so a confirmation cannot be relabeled for another validator sharing a secret. - Validation phase: track validator failure separately from the error value, so rejecting with undefined still fails closed. - Validation phase: recheck every request's expiry before re-validating, since an earlier approval can expire while a later prompt is pending. - README: check outcome.allowed in the example. Co-Authored-By: Claude Opus 5.5 --- typescript/sdk/README.md | 5 +++++ typescript/sdk/src/confirmation.test.ts | 25 +++++++++++++++++++++++-- typescript/sdk/src/confirmation.ts | 14 +++++++++----- typescript/sdk/src/jcs.ts | 12 ++++++++++-- typescript/sdk/src/validation.test.ts | 25 +++++++++++++++++++++++++ typescript/sdk/src/validation.ts | 23 +++++++++++++++++++---- 6 files changed, 91 insertions(+), 13 deletions(-) diff --git a/typescript/sdk/README.md b/typescript/sdk/README.md index d37a0b8..7c53032 100644 --- a/typescript/sdk/README.md +++ b/typescript/sdk/README.md @@ -45,6 +45,11 @@ const outcome = await runValidationPhase({ confirm: async (prompt, signal) => ({ approved: await askUser(prompt, signal) }), }); +// Denials, timeouts, failures and plain blocks all come back as not allowed. +if (!outcome.allowed) { + // block with outcome.reason +} + // After any later mutations, the payload must still match what was approved. if (outcome.confirmedCallHash && !isConfirmedCall(finalCall, outcome.confirmedCallHash)) { // block diff --git a/typescript/sdk/src/confirmation.test.ts b/typescript/sdk/src/confirmation.test.ts index 52bb955..f819a8e 100644 --- a/typescript/sdk/src/confirmation.test.ts +++ b/typescript/sdk/src/confirmation.test.ts @@ -54,6 +54,17 @@ describe('canonicalize', () => { expect(canonicalize({ a: undefined, b: 1 })).toBe('{"b":1}'); }); + it('rejects array holes instead of collapsing them', () => { + expect(() => canonicalize(new Array(1))).toThrow(TypeError); + expect(() => canonicalize(Object.assign([], { 1: 2 }))).toThrow(TypeError); + }); + + it('rejects non-plain objects such as Date', () => { + expect(() => canonicalize({ at: new Date(0) })).toThrow(TypeError); + expect(() => canonicalize(new Map())).toThrow(TypeError); + expect(canonicalize(Object.create(null))).toBe('{}'); + }); + it('rejects values without an I-JSON form', () => { expect(() => canonicalize(NaN)).toThrow(TypeError); expect(() => canonicalize(1n)).toThrow(TypeError); @@ -92,7 +103,7 @@ describe('computeCallHash', () => { }); describe('confirmation tokens', () => { - const claims = { id: 'r1', callHash: 'sha256:aa', sessionId: 's1', expiresAt: '2026-10-16T00:05:00.000Z' }; + const claims = { interceptor: 'v', id: 'r1', callHash: 'sha256:aa', sessionId: 's1', expiresAt: '2026-10-16T00:05:00.000Z' }; const before = new Date('2026-10-16T00:00:00Z'); it('verifies for the same id, callHash and session before expiry', () => { @@ -105,8 +116,9 @@ describe('confirmation tokens', () => { expect(verifyConfirmationToken(SECRET, token, claims, new Date('2026-10-16T00:05:00Z'))).toBe(false); }); - it('rejects other ids, calls, sessions and secrets', () => { + it('rejects other validators, ids, calls, sessions and secrets', () => { const token = createConfirmationToken(SECRET, claims); + expect(verifyConfirmationToken(SECRET, token, { ...claims, interceptor: 'w' }, before)).toBe(false); expect(verifyConfirmationToken(SECRET, token, { ...claims, id: 'r2' }, before)).toBe(false); expect(verifyConfirmationToken(SECRET, token, { ...claims, callHash: 'sha256:bb' }, before)).toBe(false); expect(verifyConfirmationToken(SECRET, token, { ...claims, sessionId: 's2' }, before)).toBe(false); @@ -184,6 +196,15 @@ describe('findConfirmation / requireConfirmation', () => { expect(findConfirmation(withC, { secret: SECRET })).toBeUndefined(); }); + it('rejects a confirmation relabeled for another validator sharing the secret', () => { + const c = confirmationFor(params({ name: 'a' })); + const asB = params({ + name: 'b', + context: { timestamp: 't', target: 'github', sessionId: 's1', confirmations: [{ ...c, interceptor: 'b' }] }, + }); + expect(findConfirmation(asB, { secret: SECRET })).toBeUndefined(); + }); + it('rejects an expired confirmation', () => { const c = confirmationFor(params(), new Date('2026-10-16T00:00:00Z')); const withC = params({ context: { timestamp: 't', target: 'github', sessionId: 's1', confirmations: [c] } }); diff --git a/typescript/sdk/src/confirmation.ts b/typescript/sdk/src/confirmation.ts index b67f49b..ea0f1f5 100644 --- a/typescript/sdk/src/confirmation.ts +++ b/typescript/sdk/src/confirmation.ts @@ -55,6 +55,8 @@ export type ConfirmationSecret = string | Uint8Array; const TOKEN_VERSION = 'v1'; interface TokenClaims { + /** Name of the validator that issued the request. */ + interceptor: string; id: string; callHash: string; sessionId?: string; @@ -64,6 +66,7 @@ interface TokenClaims { function tokenMac(secret: ConfirmationSecret, claims: TokenClaims): Buffer { const message = canonicalize([ TOKEN_VERSION, + claims.interceptor, claims.id, claims.callHash, claims.sessionId ?? null, @@ -73,8 +76,8 @@ function tokenMac(secret: ConfirmationSecret, claims: TokenClaims): Buffer { } /** - * Issues a self-verifying token: an HMAC over the request id, callHash, - * session id and expiry. The validator needs no pending-request state to + * Issues a self-verifying token: an HMAC over the issuing validator's name, + * the request id, callHash, session id and expiry. The validator needs no pending-request state to * check it, so a confirmation survives a reconnect. */ export function createConfirmationToken(secret: ConfirmationSecret, claims: TokenClaims): string { @@ -84,8 +87,8 @@ export function createConfirmationToken(secret: ConfirmationSecret, claims: Toke } /** - * Checks a token issued by createConfirmationToken for the given id, - * callHash and session, and that its embedded expiry has not passed. + * Checks a token issued by createConfirmationToken for the given validator, + * id, callHash and session, and that its embedded expiry has not passed. */ export function verifyConfirmationToken( secret: ConfirmationSecret, @@ -140,6 +143,7 @@ export function confirmationRequired( options.secret === undefined ? undefined : createConfirmationToken(options.secret, { + interceptor: params.name, id, callHash, sessionId: params.context?.sessionId, @@ -184,7 +188,7 @@ export function findConfirmation( verifyConfirmationToken( options.secret, c.token, - { id: c.id, callHash, sessionId: params.context?.sessionId }, + { interceptor: params.name, id: c.id, callHash, sessionId: params.context?.sessionId }, now, ) ); diff --git a/typescript/sdk/src/jcs.ts b/typescript/sdk/src/jcs.ts index 2854e35..eb3628d 100644 --- a/typescript/sdk/src/jcs.ts +++ b/typescript/sdk/src/jcs.ts @@ -15,7 +15,8 @@ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?; const keys = Object.keys(obj) diff --git a/typescript/sdk/src/validation.test.ts b/typescript/sdk/src/validation.test.ts index 6b173c5..3ad0e5e 100644 --- a/typescript/sdk/src/validation.test.ts +++ b/typescript/sdk/src/validation.test.ts @@ -130,6 +130,14 @@ describe('runValidationPhase', () => { expect(confirm).not.toHaveBeenCalled(); }); + it('blocks when a validator rejects with an undefined reason (fail-closed)', async () => { + const broken: ValidatorEntry = { name: 'broken', invoke: () => Promise.reject(undefined) }; + const out = await runValidationPhase(options({ validators: [broken] })); + expect(out.allowed).toBe(false); + expect(out.reason).toMatch(/"broken" failed/); + expect(out.failures).toEqual([{ interceptor: 'broken', mode: 'active', error: undefined }]); + }); + it('blocks when the person denies', async () => { const out = await runValidationPhase( options({ validators: [confirming()], confirm: () => Promise.resolve({ approved: false }) }), @@ -162,6 +170,23 @@ describe('runValidationPhase', () => { expect(signal?.aborted).toBe(true); }); + it('blocks when an earlier approval expires while a later prompt is pending', async () => { + const out = await runValidationPhase( + options({ + validators: [confirming('a', 30), confirming('b', 1_000)], + confirm: async (prompt) => { + if (prompt.interceptor === 'b') { + await new Promise((r) => setTimeout(r, 60)); + } + return { approved: true }; + }, + }), + ); + expect(out.allowed).toBe(false); + expect(out.reason).toMatch(/"a": confirmation expired before re-validation/); + expect(out.confirmedCallHash).toBeUndefined(); + }); + it('blocks a request whose callHash is not bound to this call', async () => { const confirm = vi.fn(approve); const wrongTarget: ValidatorEntry = { diff --git a/typescript/sdk/src/validation.ts b/typescript/sdk/src/validation.ts index 763dcc6..28aaaf7 100644 --- a/typescript/sdk/src/validation.ts +++ b/typescript/sdk/src/validation.ts @@ -99,6 +99,8 @@ export interface ValidationPhaseOutcome { interface Attempt { entry: ValidatorEntry; result?: ValidationResult; + /** Set when invoke threw or rejected, even with an undefined reason. */ + failed?: true; error?: unknown; } @@ -115,14 +117,14 @@ function blocks(attempt: Attempt): boolean { if (!isActive(attempt.entry)) { return false; } - if (attempt.error !== undefined) { + if (attempt.failed) { return !attempt.entry.failOpen; } return attempt.result !== undefined && isBlockingResult(attempt.result); } function describe(attempt: Attempt): string { - if (attempt.error !== undefined) { + if (attempt.failed) { return `validator "${attempt.entry.name}" failed`; } const message = attempt.result?.messages?.[0]?.message; @@ -156,7 +158,7 @@ export async function runValidationPhase(options: ValidationPhaseOptions): Promi try { return { entry, result: await entry.invoke(params) }; } catch (error) { - return { entry, error }; + return { entry, failed: true, error }; } }; @@ -170,7 +172,7 @@ export async function runValidationPhase(options: ValidationPhaseOptions): Promi reason, results: attempts.flatMap((a) => (a.result ? [a.result] : [])), failures: attempts.flatMap((a) => - a.error !== undefined ? [{ interceptor: a.entry.name, mode: a.entry.mode ?? 'active', error: a.error }] : [], + a.failed ? [{ interceptor: a.entry.name, mode: a.entry.mode ?? 'active', error: a.error }] : [], ), confirmations, confirmedCallHash: allowed && confirmations.length > 0 ? callHash : undefined, @@ -215,6 +217,19 @@ export async function runValidationPhase(options: ValidationPhaseOptions): Promi }); } + // An earlier approval may have expired while a later prompt was pending. + for (const attempt of blocking) { + const { expiresAt } = attempt.result!.confirmation!; + if (expiresAt !== undefined && now().getTime() >= Date.parse(expiresAt)) { + return outcome( + first, + false, + `validator "${attempt.entry.name}": confirmation expired before re-validation`, + confirmations, + ); + } + } + // Re-validate with each validator's own confirmation attached. const retried = await Promise.all( blocking.map((attempt) => From d09d24bf545a2c9240fcb7668145527354fefbbd Mon Sep 17 00:00:00 2001 From: Murthy G <31370582+murthy-g@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:49:32 -0500 Subject: [PATCH 3/9] Prevent serialization of objects with custom toJSON Add a check for custom toJSON methods in objects to prevent serialization errors. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Murthy G <31370582+murthy-g@users.noreply.github.com> --- typescript/sdk/src/jcs.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/typescript/sdk/src/jcs.ts b/typescript/sdk/src/jcs.ts index eb3628d..7f1d156 100644 --- a/typescript/sdk/src/jcs.ts +++ b/typescript/sdk/src/jcs.ts @@ -36,6 +36,9 @@ export function canonicalize(value: unknown): string { } return JSON.stringify(value); case 'object': { + if (typeof (value as { toJSON?: unknown }).toJSON === 'function') { + throw new TypeError('JCS: cannot serialize an object with a custom toJSON method'); + } if (Array.isArray(value)) { // Visit every index: map() skips holes, which JSON sends as null. return `[${Array.from(value, canonicalize).join(',')}]`; From 7f7f509d74c4d7d58bb9f6e74b7e1ba3d8259a54 Mon Sep 17 00:00:00 2001 From: Murthy G <31370582+murthy-g@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:31:42 -0500 Subject: [PATCH 4/9] Check for undefined validation result Ensure validation result is defined before returning. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Murthy G <31370582+murthy-g@users.noreply.github.com> --- typescript/sdk/src/validation.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/typescript/sdk/src/validation.ts b/typescript/sdk/src/validation.ts index 28aaaf7..754484f 100644 --- a/typescript/sdk/src/validation.ts +++ b/typescript/sdk/src/validation.ts @@ -156,7 +156,11 @@ export async function runValidationPhase(options: ValidationPhaseOptions): Promi }, }; try { - return { entry, result: await entry.invoke(params) }; + const result = await entry.invoke(params); + if (result === undefined) { + throw new TypeError(`validator "${entry.name}" returned no validation result`); + } + return { entry, result }; } catch (error) { return { entry, failed: true, error }; } From 8fa73e94e6bf5ca09a61661c15916b1617599b27 Mon Sep 17 00:00:00 2001 From: Murthy G <31370582+murthy-g@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:29:53 -0500 Subject: [PATCH 5/9] Add timeout feature to validation function Implement timeout handling for validation invocation. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Murthy G <31370582+murthy-g@users.noreply.github.com> --- typescript/sdk/src/validation.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/typescript/sdk/src/validation.ts b/typescript/sdk/src/validation.ts index 754484f..9857f00 100644 --- a/typescript/sdk/src/validation.ts +++ b/typescript/sdk/src/validation.ts @@ -156,7 +156,23 @@ export async function runValidationPhase(options: ValidationPhaseOptions): Promi }, }; try { - const result = await entry.invoke(params); + let timer: ReturnType | undefined; + let result: ValidationResult; + try { + result = await Promise.race([ + entry.invoke(params), + new Promise((_, reject) => { + if (options.timeoutMs !== undefined) { + timer = setTimeout( + () => reject(new Error(`validator "${entry.name}" timed out`)), + options.timeoutMs, + ); + } + }), + ]); + } finally { + clearTimeout(timer); + } if (result === undefined) { throw new TypeError(`validator "${entry.name}" returned no validation result`); } From e25e3f1d6e30d1ac570f605ce5557626dc63de7e Mon Sep 17 00:00:00 2001 From: Murthy G <31370582+murthy-g@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:37:01 -0500 Subject: [PATCH 6/9] Refactor array handling in canonicalize function Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Murthy G <31370582+murthy-g@users.noreply.github.com> --- typescript/sdk/src/jcs.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/typescript/sdk/src/jcs.ts b/typescript/sdk/src/jcs.ts index 7f1d156..96c75b0 100644 --- a/typescript/sdk/src/jcs.ts +++ b/typescript/sdk/src/jcs.ts @@ -41,7 +41,11 @@ export function canonicalize(value: unknown): string { } if (Array.isArray(value)) { // Visit every index: map() skips holes, which JSON sends as null. - return `[${Array.from(value, canonicalize).join(',')}]`; + const elements: string[] = []; + for (let i = 0; i < value.length; i++) { + elements.push(canonicalize(value[i])); + } + return `[${elements.join(',')}]`; } // Class instances such as Date have a wire form that differs from // their own enumerable properties, so only plain objects are accepted. From b102fe855b6a4a1c9408cc62d741f46bb82ecc0f Mon Sep 17 00:00:00 2001 From: Murthy G Date: Thu, 22 Oct 2026 18:14:11 -0500 Subject: [PATCH 7/9] Let validators accept each confirmation once requireConfirmation takes an optional UsedConfirmations store. With it, a confirmation passes once; a retry gets a new confirmation request instead of reusing the old one. MemoryUsedConfirmations keeps accepted ids, keyed by validator, until they expire. Without a store the SDK stays stateless. --- typescript/sdk/README.md | 20 +++++-- typescript/sdk/src/confirmation.test.ts | 55 +++++++++++++++++++ typescript/sdk/src/confirmation.ts | 72 +++++++++++++++++++++++-- 3 files changed, 140 insertions(+), 7 deletions(-) diff --git a/typescript/sdk/README.md b/typescript/sdk/README.md index 7c53032..9c386c9 100644 --- a/typescript/sdk/README.md +++ b/typescript/sdk/README.md @@ -26,12 +26,18 @@ bound to the exact `{ event, phase, target, payload }`, so an approval cannot be replayed for a different call: ```typescript -import { requireConfirmation, runValidationPhase, isConfirmedCall } from '@ext-modelcontextprotocol/interceptors'; - -// Validator: passes only with a valid confirmation for this exact call. +import { + isConfirmedCall, + MemoryUsedConfirmations, + requireConfirmation, + runValidationPhase, +} from '@ext-modelcontextprotocol/interceptors'; + +// Validator: passes only with a valid confirmation for this exact call, once. +const used = new MemoryUsedConfirmations(); const guard = { name: 'public-write-guard', - invoke: async (params) => requireConfirmation(params, { reason: 'Post to a public repo?', secret }), + invoke: async (params) => requireConfirmation(params, { reason: 'Post to a public repo?', secret, used }), }; // Invoker: asks the person (e.g. via elicitation) and re-validates. @@ -56,6 +62,12 @@ if (outcome.confirmedCallHash && !isConfirmedCall(finalCall, outcome.confirmedCa } ``` +A confirmation is single use. Invokers must not attach it to a second +request; with `used`, the validator enforces that too, so a retry asks for a +new confirmation instead of reusing the old one. `MemoryUsedConfirmations` +keeps accepted ids until they expire; validators in several processes need a +shared `UsedConfirmations` store. + ## Development ```bash diff --git a/typescript/sdk/src/confirmation.test.ts b/typescript/sdk/src/confirmation.test.ts index f819a8e..bb28f47 100644 --- a/typescript/sdk/src/confirmation.test.ts +++ b/typescript/sdk/src/confirmation.test.ts @@ -11,6 +11,7 @@ import { confirmationRequired, createConfirmationToken, findConfirmation, + MemoryUsedConfirmations, requireConfirmation, verifyConfirmationToken, } from './confirmation.js'; @@ -223,3 +224,57 @@ describe('findConfirmation / requireConfirmation', () => { expect(findConfirmation(forged, { secret: SECRET })).toBeUndefined(); }); }); + +describe('single use', () => { + const at = new Date('2026-10-16T00:00:00Z'); + const withC = (c: Confirmation) => params({ context: { timestamp: 't', target: 'github', sessionId: 's1', confirmations: [c] } }); + + it('accepts the same confirmation again without a store (stateless by default)', () => { + const c = confirmationFor(params(), at); + const opts = { reason: 'r', secret: SECRET, now: () => at }; + expect(requireConfirmation(withC(c), opts).valid).toBe(true); + expect(requireConfirmation(withC(c), opts).valid).toBe(true); + }); + + it('accepts a confirmation once with a store, and asks again on a retry', () => { + const c = confirmationFor(params(), at); + const opts = { reason: 'r', secret: SECRET, now: () => at, used: new MemoryUsedConfirmations(() => at.getTime()) }; + expect(requireConfirmation(withC(c), opts).valid).toBe(true); + const retry = requireConfirmation(withC(c), opts); + expect(retry.valid).toBe(false); + expect(retry.confirmation?.id).not.toBe(c.id); + }); + + it('accepts each new confirmation once', () => { + const used = new MemoryUsedConfirmations(() => at.getTime()); + const opts = { reason: 'r', secret: SECRET, now: () => at, used }; + expect(requireConfirmation(withC(confirmationFor(params(), at)), opts).valid).toBe(true); + expect(requireConfirmation(withC(confirmationFor(params(), at)), opts).valid).toBe(true); + expect(used.size).toBe(2); + }); + + it('keys used confirmations by validator, so validators sharing a store do not block each other', () => { + const used = new MemoryUsedConfirmations(() => at.getTime()); + const a = { ...confirmationFor(params({ name: 'a' }), at), id: 'same' }; + expect(used.claim(`a\n${a.id}`, at.getTime() + 1000)).toBe(true); + expect(used.claim(`b\n${a.id}`, at.getTime() + 1000)).toBe(true); + expect(used.claim(`a\n${a.id}`, at.getTime() + 1000)).toBe(false); + }); + + it('forgets confirmations once they expire', () => { + let now = at.getTime(); + const used = new MemoryUsedConfirmations(() => now); + used.claim('v\n1', now + 1000); + now += 2000; + used.claim('v\n2', now + 1000); + expect(used.size).toBe(1); + }); + + it('keeps a confirmation without a token for a full request lifetime', () => { + const c = { ...confirmationFor(params(), at), token: undefined }; + const used = new MemoryUsedConfirmations(() => at.getTime()); + const opts = { reason: 'r', now: () => at, used }; + expect(requireConfirmation(withC(c), opts).valid).toBe(true); + expect(requireConfirmation(withC(c), opts).valid).toBe(false); + }); +}); diff --git a/typescript/sdk/src/confirmation.ts b/typescript/sdk/src/confirmation.ts index ea0f1f5..a7d5ca0 100644 --- a/typescript/sdk/src/confirmation.ts +++ b/typescript/sdk/src/confirmation.ts @@ -9,6 +9,10 @@ * ValidationResult carrying a ConfirmationRequest. The request is bound to * the exact call through `callHash`, so an approval for one call cannot be * replayed for another. + * + * A confirmation is single use. The SEP makes invokers responsible for not + * attaching it to a second request; a validator that also enforces it passes + * a UsedConfirmations store, which remembers accepted ids until they expire. */ import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto'; @@ -109,6 +113,52 @@ export function verifyConfirmationToken( return expiresAt === undefined || now.getTime() < Date.parse(expiresAt); } +/** Reads the expiry embedded in a token from createConfirmationToken, without verifying it. */ +function tokenExpiry(token: string | undefined): number | undefined { + const part = token?.split('.')[1]; + if (!part) { + return undefined; + } + const ms = Date.parse(Buffer.from(part, 'base64url').toString('utf8')); + return Number.isNaN(ms) ? undefined : ms; +} + +/** + * Remembers the confirmations a validator has accepted, so each allows one + * invocation. Entries are kept until the confirmation expires, which bounds + * the state. + */ +export interface UsedConfirmations { + /** Records the key as used until `expiresAt` (ms since the epoch). Returns false if it was already used. */ + claim(key: string, expiresAt: number): boolean; +} + +/** In-process UsedConfirmations. Use a shared store if several validator processes accept the same confirmations. */ +export class MemoryUsedConfirmations implements UsedConfirmations { + private readonly used = new Map(); + + constructor(private readonly now: () => number = Date.now) {} + + claim(key: string, expiresAt: number): boolean { + const now = this.now(); + for (const [k, until] of this.used) { + if (until <= now) { + this.used.delete(k); + } + } + if (this.used.has(key)) { + return false; + } + this.used.set(key, expiresAt); + return true; + } + + /** Number of confirmations remembered (for tests and monitoring). */ + get size(): number { + return this.used.size; + } +} + export interface ConfirmationOptions { /** Human-readable explanation, shown to the person asked. */ reason: string; @@ -121,6 +171,12 @@ export interface ConfirmationOptions { ttlMs?: number; /** Additional messages for the blocking result. */ messages?: ValidationMessage[]; + /** + * Makes requireConfirmation accept each confirmation once: a second use, + * for example when an invoker retries, asks for a new confirmation. + * Recommended. + */ + used?: UsedConfirmations; now?: () => Date; } @@ -164,7 +220,8 @@ export function confirmationRequired( * Returns the confirmation in `context.confirmations` that this validator * can accept for this invocation, if any: one addressed to this validator * whose callHash matches the invocation's call binding (including target) - * and, if `secret` is given, whose token is valid and unexpired. + * and, if `secret` is given, whose token is valid and unexpired. It does not + * record the confirmation as used; requireConfirmation does, given `used`. */ export function findConfirmation( params: InterceptorInvocationParams, @@ -197,14 +254,23 @@ export function findConfirmation( /** * Convenience for validators: passes if the invocation carries an - * acceptable confirmation, otherwise asks for one. + * acceptable confirmation, otherwise asks for one. With `used`, each + * confirmation passes once. */ export function requireConfirmation( params: InterceptorInvocationParams, options: ConfirmationOptions, ): ValidationResult { - if (findConfirmation(params, options)) { + const c = findConfirmation(params, options); + if (c && (!options.used || claim(options.used, params.name, c, options))) { return { interceptor: params.name, type: 'validation', phase: params.phase, valid: true }; } return confirmationRequired(params, options); } + +function claim(used: UsedConfirmations, interceptor: string, c: Confirmation, options: ConfirmationOptions): boolean { + const now = (options.now?.() ?? new Date()).getTime(); + // Without a token there is no embedded expiry; keep the id for a full request lifetime. + const expiresAt = tokenExpiry(c.token) ?? now + (options.ttlMs ?? DEFAULT_TTL_MS); + return used.claim(`${interceptor}\n${c.id}`, expiresAt); +} From 0f76e637fad492ce8088ce72d620275a9f9487ec Mon Sep 17 00:00:00 2001 From: Murthy G Date: Thu, 22 Oct 2026 18:14:11 -0500 Subject: [PATCH 8/9] Block when the payload changes while a confirmation is pending The payload is shared by reference across the awaited confirmation handler. Recheck its call hash before re-validating, so confirmedCallHash never names a payload other than the one that was approved. --- typescript/sdk/src/validation.test.ts | 14 ++++++++++++++ typescript/sdk/src/validation.ts | 5 +++++ 2 files changed, 19 insertions(+) diff --git a/typescript/sdk/src/validation.test.ts b/typescript/sdk/src/validation.test.ts index 3ad0e5e..4ee2a40 100644 --- a/typescript/sdk/src/validation.test.ts +++ b/typescript/sdk/src/validation.test.ts @@ -247,6 +247,20 @@ describe('runValidationPhase', () => { }); describe('isConfirmedCall', () => { + it('blocks when the payload changes while the person is deciding', async () => { + const payload = { name: 'post_issue', arguments: { repo: 'acme/site', body: 'hi' } }; + const v = confirming(); + const confirm: ConfirmationHandler = () => { + payload.arguments.body = 'secrets'; + return approve({} as never, new AbortController().signal); + }; + const out = await runValidationPhase(options({ payload, validators: [v], confirm })); + expect(out.allowed).toBe(false); + expect(out.reason).toBe('payload changed while confirmation was pending'); + expect(out.confirmedCallHash).toBeUndefined(); + expect(v.calls).toHaveLength(1); + }); + it('detects a payload changed after confirmation', async () => { const out = await runValidationPhase(options({ validators: [confirming()], confirm: approve })); const hash = out.confirmedCallHash!; diff --git a/typescript/sdk/src/validation.ts b/typescript/sdk/src/validation.ts index 9857f00..bcf2dba 100644 --- a/typescript/sdk/src/validation.ts +++ b/typescript/sdk/src/validation.ts @@ -237,6 +237,11 @@ export async function runValidationPhase(options: ValidationPhaseOptions): Promi }); } + // The payload is shared by reference; it must not have changed while the person was deciding. + if (computeCallHash({ event, phase, target, payload }) !== callHash) { + return outcome(first, false, 'payload changed while confirmation was pending', confirmations); + } + // An earlier approval may have expired while a later prompt was pending. for (const attempt of blocking) { const { expiresAt } = attempt.result!.confirmation!; From 5d70c1e0dfbf5a0717e006533aa43ec4ef28d29d Mon Sep 17 00:00:00 2001 From: Murthy G Date: Mon, 26 Oct 2026 20:00:28 -0500 Subject: [PATCH 9/9] Test single use against approval-binding fixtures Adds the allow-bound and approval-already-consumed records from probityai/agent-evidence-vectors at 50d3d31 (Apache-2.0), unchanged and checked by sha256. They differ only in consumedApprovalIds, which the test loads into the used store: the first passes, the second asks again. Co-Authored-By: Claude Opus 5.5 --- typescript/sdk/src/confirmation.test.ts | 56 ++++++++++++++++++ .../sdk/testdata/approval-binding/README.md | 14 +++++ .../approval-binding/allow-bound.json | 58 ++++++++++++++++++ .../approval-already-consumed.json | 59 +++++++++++++++++++ 4 files changed, 187 insertions(+) create mode 100644 typescript/sdk/testdata/approval-binding/README.md create mode 100644 typescript/sdk/testdata/approval-binding/allow-bound.json create mode 100644 typescript/sdk/testdata/approval-binding/approval-already-consumed.json diff --git a/typescript/sdk/src/confirmation.test.ts b/typescript/sdk/src/confirmation.test.ts index bb28f47..9902330 100644 --- a/typescript/sdk/src/confirmation.test.ts +++ b/typescript/sdk/src/confirmation.test.ts @@ -3,6 +3,7 @@ // license that can be found in the LICENSE file. import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; import { describe, it, expect } from 'vitest'; import { @@ -278,3 +279,58 @@ describe('single use', () => { expect(requireConfirmation(withC(c), opts).valid).toBe(false); }); }); + +// Fixtures from probityai/agent-evidence-vectors; see testdata/approval-binding/README.md. +describe('approval-binding fixtures', () => { + interface ApprovalRecord { + approval: { approvalId: string; expiresAt: string }; + consumedApprovalIds: string[]; + decisionTime: string; + use: { event: string; target: string; tool: string }; + } + + const cases = [ + { file: 'allow-bound.json', sha256: 'edf9debc8dd96359f8865324d47ac9ae8bc400cfadfc4ce524d33efae9bf11f8', allowed: true }, + { file: 'approval-already-consumed.json', sha256: '9f14975c6032d782a096fc15388ab26d9d42574b206098627ba4675e12c23bd6', allowed: false }, + ]; + + it.each(cases)('$file: allowed=$allowed', ({ file, sha256, allowed }) => { + const raw = readFileSync(new URL(`../testdata/approval-binding/${file}`, import.meta.url)); + expect(createHash('sha256').update(raw).digest('hex')).toBe(sha256); + const record = JSON.parse(raw.toString('utf8')) as ApprovalRecord; + + const at = new Date(record.decisionTime); + const expiresAt = Date.parse(record.approval.expiresAt); + const context = { timestamp: record.decisionTime, target: record.use.target, sessionId: 's1' }; + const p = params({ event: record.use.event, payload: { name: record.use.tool, arguments: {} }, context }); + const callHash = computeCallHash(callBinding(p)); + const c: Confirmation = { + interceptor: p.name, + id: record.approval.approvalId, + callHash, + token: createConfirmationToken(SECRET, { + interceptor: p.name, + id: record.approval.approvalId, + callHash, + sessionId: 's1', + expiresAt: record.approval.expiresAt, + }), + decidedAt: record.decisionTime, + }; + + // consumedApprovalIds are the ids already in the used store. + const used = new MemoryUsedConfirmations(() => at.getTime()); + for (const id of record.consumedApprovalIds) { + used.claim(`${p.name}\n${id}`, expiresAt); + } + + const result = requireConfirmation( + { ...p, context: { ...context, confirmations: [c] } }, + { reason: 'r', secret: SECRET, now: () => at, used }, + ); + expect(result.valid).toBe(allowed); + if (!allowed) { + expect(result.confirmation?.id).not.toBe(c.id); + } + }); +}); diff --git a/typescript/sdk/testdata/approval-binding/README.md b/typescript/sdk/testdata/approval-binding/README.md new file mode 100644 index 0000000..70b95cc --- /dev/null +++ b/typescript/sdk/testdata/approval-binding/README.md @@ -0,0 +1,14 @@ +# Approval-binding fixtures + +Copied unchanged from +[probityai/agent-evidence-vectors](https://github.com/probityai/agent-evidence-vectors) +(Apache-2.0) at commit `50d3d312974688297569d62eebe5f45c3139053d`, from +`vectors-approval-binding/cases//record.json`. `confirmation.test.ts` +checks each file's sha256 against that commit. + +| File | Expected | +|---|---| +| `allow-bound.json` | allow | +| `approval-already-consumed.json` | deny (`approval_replayed`) | + +The two records differ only in `consumedApprovalIds`. diff --git a/typescript/sdk/testdata/approval-binding/allow-bound.json b/typescript/sdk/testdata/approval-binding/allow-bound.json new file mode 100644 index 0000000..9345d81 --- /dev/null +++ b/typescript/sdk/testdata/approval-binding/allow-bound.json @@ -0,0 +1,58 @@ +{ + "approval": { + "approvalId": "appr-0001", + "approver": "human:reviewer-1", + "clientId": "client-ide-7", + "event": "tools/call", + "expiresAt": "2026-10-21T12:05:00Z", + "issuedAt": "2026-10-21T11:55:00Z", + "keyId": "reviewer-1-key-2026-10", + "payloadSha256": "37e4855e2707561919f5ff0de10785061073a919f892b6d974bb35320877c974", + "phase": "pre-execution", + "principal": "user:alice", + "target": "workspace/selection.json", + "tool": "file.save" + }, + "consumedApprovalIds": [ + "appr-0000" + ], + "contractId": "https://probityai.github.io/agent-evidence-vectors/contract/approval-binding/v1", + "decisionTime": "2026-10-21T12:00:00Z", + "policyRelease": { + "firstServedAt": "2026-10-21T09:00:00Z", + "graceSeconds": 300, + "lastServedAt": "2026-10-21T12:30:00Z", + "lookup": "ok", + "releaseId": "policy-release-41" + }, + "presentedApprovals": [ + { + "approvalId": "appr-0001", + "approver": "human:reviewer-1", + "clientId": "client-ide-7", + "event": "tools/call", + "expiresAt": "2026-10-21T12:05:00Z", + "issuedAt": "2026-10-21T11:55:00Z", + "keyId": "reviewer-1-key-2026-10", + "payloadSha256": "37e4855e2707561919f5ff0de10785061073a919f892b6d974bb35320877c974", + "phase": "pre-execution", + "principal": "user:alice", + "target": "workspace/selection.json", + "tool": "file.save" + } + ], + "trustedKeys": { + "human:reviewer-1": "reviewer-1-key-2026-10" + }, + "use": { + "approvalId": "appr-0001", + "clientId": "client-ide-7", + "event": "tools/call", + "payloadFile": "payload.bin", + "phase": "pre-execution", + "principal": "user:alice", + "target": "workspace/selection.json", + "tool": "file.save", + "usedAt": "2026-10-21T12:00:00Z" + } +} diff --git a/typescript/sdk/testdata/approval-binding/approval-already-consumed.json b/typescript/sdk/testdata/approval-binding/approval-already-consumed.json new file mode 100644 index 0000000..cc2eaef --- /dev/null +++ b/typescript/sdk/testdata/approval-binding/approval-already-consumed.json @@ -0,0 +1,59 @@ +{ + "approval": { + "approvalId": "appr-0001", + "approver": "human:reviewer-1", + "clientId": "client-ide-7", + "event": "tools/call", + "expiresAt": "2026-10-21T12:05:00Z", + "issuedAt": "2026-10-21T11:55:00Z", + "keyId": "reviewer-1-key-2026-10", + "payloadSha256": "37e4855e2707561919f5ff0de10785061073a919f892b6d974bb35320877c974", + "phase": "pre-execution", + "principal": "user:alice", + "target": "workspace/selection.json", + "tool": "file.save" + }, + "consumedApprovalIds": [ + "appr-0000", + "appr-0001" + ], + "contractId": "https://probityai.github.io/agent-evidence-vectors/contract/approval-binding/v1", + "decisionTime": "2026-10-21T12:00:00Z", + "policyRelease": { + "firstServedAt": "2026-10-21T09:00:00Z", + "graceSeconds": 300, + "lastServedAt": "2026-10-21T12:30:00Z", + "lookup": "ok", + "releaseId": "policy-release-41" + }, + "presentedApprovals": [ + { + "approvalId": "appr-0001", + "approver": "human:reviewer-1", + "clientId": "client-ide-7", + "event": "tools/call", + "expiresAt": "2026-10-21T12:05:00Z", + "issuedAt": "2026-10-21T11:55:00Z", + "keyId": "reviewer-1-key-2026-10", + "payloadSha256": "37e4855e2707561919f5ff0de10785061073a919f892b6d974bb35320877c974", + "phase": "pre-execution", + "principal": "user:alice", + "target": "workspace/selection.json", + "tool": "file.save" + } + ], + "trustedKeys": { + "human:reviewer-1": "reviewer-1-key-2026-10" + }, + "use": { + "approvalId": "appr-0001", + "clientId": "client-ide-7", + "event": "tools/call", + "payloadFile": "payload.bin", + "phase": "pre-execution", + "principal": "user:alice", + "target": "workspace/selection.json", + "tool": "file.save", + "usedAt": "2026-10-21T12:00:00Z" + } +}