From fedef8d432686b06a0c73b8469c1d9d041804621 Mon Sep 17 00:00:00 2001 From: norvalbv Date: Tue, 21 Jul 2026 19:32:00 +0100 Subject: [PATCH] Adapt Claude feature-critique callbacks ## What changed - add a dormant pure adapter for Claude SubagentStop feature-critique callbacks - derive a privacy-preserving turn work ID and collision-safe callback identity from documented provider identifiers - preserve the direct final-message field as UTF-8, while keeping model, prompt hash, completion time, and transcripts explicitly unavailable - return deterministic capability skips and a mandatory work_unbindable quarantine signal when another hook continued the subagent - reject hostile payload shapes and bound identifiers, callback tuples, and final output before capture ## Scope Exactly two new files under gate-engine/critique/provider-adapters: the Claude adapter and its focused tests. This is dormant adaptation only. It does not install or activate hooks, change provider defaults/configuration, read transcripts, write repository/provider artifacts, create bindings, touch repo-graph, alter reviewer inputs, modify feature-critique guidance/prompts/corpora/results, or run a model benchmark. Runtime activation must wait for a later wrapper that durably consumes work_unbindable quarantine signals. ## Validation - 20 focused Claude adapter tests passed - 74 adapter, normalizer, capture, store, and transcript tests passed - TypeScript, Biome, ESLint, structure, and fan-out checks passed - independent correctness, security/privacy, and strict-scope reviews passed after fixes - verified against the official Claude hooks contract: https://code.claude.com/docs/en/hooks - no model benchmark run; no prompt, corpus, or result hash changed --- .../claude-subagent-stop.mts | 147 ++++++++++++ .../claude-subagent-stop.test.mts | 213 ++++++++++++++++++ 2 files changed, 360 insertions(+) create mode 100644 gate-engine/critique/provider-adapters/claude-subagent-stop.mts create mode 100644 gate-engine/critique/provider-adapters/claude-subagent-stop.test.mts diff --git a/gate-engine/critique/provider-adapters/claude-subagent-stop.mts b/gate-engine/critique/provider-adapters/claude-subagent-stop.mts new file mode 100644 index 00000000..2cfa7ff5 --- /dev/null +++ b/gate-engine/critique/provider-adapters/claude-subagent-stop.mts @@ -0,0 +1,147 @@ +import { types as utilTypes } from 'node:util'; +import { + PLAN_CRITIQUE_CALLBACK_IDENTITY_MAX_BYTES, + type PlanCritiqueCompletedCallbackV1, +} from '../capture-normalizer.mts'; +import { PLAN_CRITIQUE_EXACT_RESPONSE_MAX_BYTES, sha256Bytes } from '../evidence-record.mts'; + +export const CLAUDE_PLAN_CRITIQUE_IDENTITY_MAX_BYTES = 1024; + +export const CLAUDE_PLAN_CRITIQUE_SKIP_REASONS = [ + 'invalid_payload', + 'unsupported_event', + 'unsupported_agent_type', + 'continuation_state_unavailable', + 'work_identity_unavailable', + 'callback_identity_unavailable', + 'final_message_unavailable', + 'final_message_too_large', +] as const; + +export type ClaudePlanCritiqueSkipReason = (typeof CLAUDE_PLAN_CRITIQUE_SKIP_REASONS)[number]; + +/** `work_unbindable` must be durably quarantined before any runtime hook activation. */ +export type ClaudePlanCritiqueSubagentStopResultV1 = + | { kind: 'ready'; callback: PlanCritiqueCompletedCallbackV1 } + | { kind: 'work_unbindable'; reason: 'hook_continuation'; workId: string } + | { kind: 'skipped'; reason: ClaudePlanCritiqueSkipReason }; + +export interface ClaudePlanCritiqueAdapterContextV1 { + repository: PlanCritiqueCompletedCallbackV1['repository']; +} + +function plainRecord(value: unknown): Record | null { + if (value === null || typeof value !== 'object') return null; + try { + if (utilTypes.isProxy(value) || Array.isArray(value)) return null; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return null; + return value as Record; + } catch { + return null; + } +} + +function ownDataValue(record: Record, key: string): unknown { + try { + const descriptor = Object.getOwnPropertyDescriptor(record, key); + return descriptor?.enumerable && Object.hasOwn(descriptor, 'value') + ? descriptor.value + : undefined; + } catch { + return undefined; + } +} + +function opaqueIdentifier(value: unknown): value is string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > CLAUDE_PLAN_CRITIQUE_IDENTITY_MAX_BYTES || + Buffer.byteLength(value, 'utf8') > CLAUDE_PLAN_CRITIQUE_IDENTITY_MAX_BYTES || + value.trim().length === 0 + ) + return false; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) return false; + } + return true; +} + +function tupleHash(domain: string, values: readonly string[]): string { + return sha256Bytes(Buffer.from(JSON.stringify([domain, 1, ...values]), 'utf8')); +} + +/** Stable turn identity shared by Claude SubagentStop capture and future Stop observation. */ +export function deriveClaudePlanCritiqueWorkId(sessionId: string, promptId: string): string { + if (!opaqueIdentifier(sessionId) || !opaqueIdentifier(promptId)) + throw new Error('invalid Claude plan critique work identity'); + return `pcw1_${tupleHash('claude_plan_critique_work', [sessionId, promptId])}`; +} + +function callbackIdentity(sessionId: string, promptId: string, agentId: string): string { + return JSON.stringify(['claude_plan_critique_subagent_stop', 1, sessionId, promptId, agentId]); +} + +const skipped = (reason: ClaudePlanCritiqueSkipReason): ClaudePlanCritiqueSubagentStopResultV1 => ({ + kind: 'skipped', + reason, +}); + +/** + * Adapt Claude's documented SubagentStop payload without reading transcripts or provider paths. + * The final message is the exact hook field text encoded as UTF-8, not original wire bytes. + */ +export function adaptClaudeFeatureCritiqueSubagentStop( + payload: unknown, + context: ClaudePlanCritiqueAdapterContextV1, +): ClaudePlanCritiqueSubagentStopResultV1 { + const record = plainRecord(payload); + if (!record) return skipped('invalid_payload'); + if (ownDataValue(record, 'hook_event_name') !== 'SubagentStop') + return skipped('unsupported_event'); + if (ownDataValue(record, 'agent_type') !== 'feature-critique') + return skipped('unsupported_agent_type'); + + const sessionId = ownDataValue(record, 'session_id'); + const promptId = ownDataValue(record, 'prompt_id'); + if (!opaqueIdentifier(sessionId) || !opaqueIdentifier(promptId)) + return skipped('work_identity_unavailable'); + const workId = deriveClaudePlanCritiqueWorkId(sessionId, promptId); + + const stopHookActive = ownDataValue(record, 'stop_hook_active'); + if (typeof stopHookActive !== 'boolean') return skipped('continuation_state_unavailable'); + if (stopHookActive) return { kind: 'work_unbindable', reason: 'hook_continuation', workId }; + + const agentId = ownDataValue(record, 'agent_id'); + if (!opaqueIdentifier(agentId)) return skipped('callback_identity_unavailable'); + const derivedCallbackIdentity = callbackIdentity(sessionId, promptId, agentId); + if ( + Buffer.byteLength(derivedCallbackIdentity, 'utf8') > PLAN_CRITIQUE_CALLBACK_IDENTITY_MAX_BYTES + ) + return skipped('callback_identity_unavailable'); + + const finalMessage = ownDataValue(record, 'last_assistant_message'); + if (typeof finalMessage !== 'string') return skipped('final_message_unavailable'); + if ( + finalMessage.length > PLAN_CRITIQUE_EXACT_RESPONSE_MAX_BYTES || + Buffer.byteLength(finalMessage, 'utf8') > PLAN_CRITIQUE_EXACT_RESPONSE_MAX_BYTES + ) + return skipped('final_message_too_large'); + if (finalMessage.trim().length === 0) return skipped('final_message_unavailable'); + + return { + kind: 'ready', + callback: { + provider: 'claude', + callbackIdentity: derivedCallbackIdentity, + workId, + repository: { ...context.repository }, + model: null, + promptHash: null, + providerCompletedAt: null, + exactResponse: Buffer.from(finalMessage, 'utf8'), + }, + }; +} diff --git a/gate-engine/critique/provider-adapters/claude-subagent-stop.test.mts b/gate-engine/critique/provider-adapters/claude-subagent-stop.test.mts new file mode 100644 index 00000000..e17c7da8 --- /dev/null +++ b/gate-engine/critique/provider-adapters/claude-subagent-stop.test.mts @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'vitest'; +import { temporaryRoot } from '../__tests__/evidence-store-fixture.mts'; +import { REVIEWED_RESPONSE } from '../__tests__/response-fixture.mts'; +import { capturePlanCritiqueCompletedCallback } from '../capture-normalizer.mts'; +import { PLAN_CRITIQUE_EXACT_RESPONSE_MAX_BYTES, sha256Bytes } from '../evidence-record.mts'; +import { + readPlanCritiqueExactResponse, + readPlanCritiqueProjection, + readPlanCritiqueTranscript, +} from '../evidence-store.mts'; +import { + adaptClaudeFeatureCritiqueSubagentStop, + CLAUDE_PLAN_CRITIQUE_IDENTITY_MAX_BYTES, + deriveClaudePlanCritiqueWorkId, +} from './claude-subagent-stop.mts'; + +const response = JSON.stringify(REVIEWED_RESPONSE); +const repository = { + fingerprint: sha256Bytes(Buffer.from('repository', 'utf8')), + fingerprintSource: 'canonical_remote' as const, + branch: 'codex/example', + head: 'a'.repeat(40), +}; + +function payload(overrides: Record = {}): Record { + return { + hook_event_name: 'SubagentStop', + session_id: 'session-1', + prompt_id: 'prompt-1', + stop_hook_active: false, + agent_id: 'agent-1', + agent_type: 'feature-critique', + last_assistant_message: response, + transcript_path: '/untrusted/main.jsonl', + agent_transcript_path: '/untrusted/subagent.jsonl', + permission_mode: 'plan', + model: 'forged-model', + ...overrides, + }; +} + +const adapt = (value: unknown) => adaptClaudeFeatureCritiqueSubagentStop(value, { repository }); + +describe('adaptClaudeFeatureCritiqueSubagentStop', () => { + it('maps only documented capture fields and preserves the final message text as UTF-8', () => { + const result = adapt(payload()); + expect(result.kind).toBe('ready'); + if (result.kind !== 'ready') throw new Error('expected a ready callback'); + + expect(result.callback).toMatchObject({ + provider: 'claude', + workId: deriveClaudePlanCritiqueWorkId('session-1', 'prompt-1'), + repository, + model: null, + promptHash: null, + providerCompletedAt: null, + }); + expect(result.callback.callbackIdentity).toBe( + JSON.stringify(['claude_plan_critique_subagent_stop', 1, 'session-1', 'prompt-1', 'agent-1']), + ); + expect(Buffer.from(result.callback.exactResponse).toString('utf8')).toBe(response); + expect(result.callback).not.toHaveProperty('opaqueTranscript'); + expect(result.callback).not.toHaveProperty('permission_mode'); + }); + + it('shares work identity across a fresh recheck while keeping callback identity distinct', () => { + const first = adapt(payload({ agent_id: 'agent-1' })); + const second = adapt(payload({ agent_id: 'agent-2' })); + expect(first.kind).toBe('ready'); + expect(second.kind).toBe('ready'); + if (first.kind !== 'ready' || second.kind !== 'ready') + throw new Error('expected ready callbacks'); + expect(second.callback.workId).toBe(first.callback.workId); + expect(second.callback.callbackIdentity).not.toBe(first.callback.callbackIdentity); + }); + + it('derives domain-separated, delimiter-safe, deterministic work identities', () => { + const workId = deriveClaudePlanCritiqueWorkId('a:b', 'c'); + expect(workId).toMatch(/^pcw1_[0-9a-f]{64}$/); + expect(deriveClaudePlanCritiqueWorkId('a:b', 'c')).toBe(workId); + expect(deriveClaudePlanCritiqueWorkId('a', 'b:c')).not.toBe(workId); + expect(() => deriveClaudePlanCritiqueWorkId('session\u0000', 'prompt')).toThrow( + /invalid Claude plan critique work identity/, + ); + }); + + it.each([ + { value: null, reason: 'invalid_payload' }, + { value: [], reason: 'invalid_payload' }, + { value: payload({ hook_event_name: 'Stop' }), reason: 'unsupported_event' }, + { value: payload({ agent_type: 'feature-critique-copy' }), reason: 'unsupported_agent_type' }, + { + value: payload({ stop_hook_active: undefined }), + reason: 'continuation_state_unavailable', + }, + { value: payload({ prompt_id: undefined }), reason: 'work_identity_unavailable' }, + { value: payload({ session_id: 'session\n2' }), reason: 'work_identity_unavailable' }, + { value: payload({ agent_id: '' }), reason: 'callback_identity_unavailable' }, + { value: payload({ last_assistant_message: null }), reason: 'final_message_unavailable' }, + { value: payload({ last_assistant_message: ' ' }), reason: 'final_message_unavailable' }, + ])('skips unsupported or incomplete payloads with $reason', ({ value, reason }) => { + expect(adapt(value)).toEqual({ kind: 'skipped', reason }); + }); + + it('marks the whole work unbindable when another hook continued the subagent', () => { + const continued = payload({ stop_hook_active: true }); + for (const key of ['agent_id', 'last_assistant_message']) + Object.defineProperty(continued, key, { + enumerable: true, + get: () => { + throw new Error('continued output must not be inspected'); + }, + }); + expect(adapt(continued)).toEqual({ + kind: 'work_unbindable', + reason: 'hook_continuation', + workId: deriveClaudePlanCritiqueWorkId('session-1', 'prompt-1'), + }); + }); + + it('bounds opaque identities and final output before allocating capture bytes', () => { + expect( + adapt(payload({ session_id: 's'.repeat(CLAUDE_PLAN_CRITIQUE_IDENTITY_MAX_BYTES + 1) })), + ).toEqual({ kind: 'skipped', reason: 'work_identity_unavailable' }); + expect( + adapt( + payload({ + last_assistant_message: 'x'.repeat(PLAN_CRITIQUE_EXACT_RESPONSE_MAX_BYTES + 1), + }), + ), + ).toEqual({ kind: 'skipped', reason: 'final_message_too_large' }); + }); + + it('bounds the serialized callback tuple after JSON escaping', () => { + expect( + adapt( + payload({ + session_id: '\\'.repeat(CLAUDE_PLAN_CRITIQUE_IDENTITY_MAX_BYTES), + prompt_id: '\\'.repeat(CLAUDE_PLAN_CRITIQUE_IDENTITY_MAX_BYTES), + agent_id: '\\'.repeat(CLAUDE_PLAN_CRITIQUE_IDENTITY_MAX_BYTES), + }), + ), + ).toEqual({ kind: 'skipped', reason: 'callback_identity_unavailable' }); + }); + + it('ignores accessors instead of executing untrusted payload code', () => { + const value = Object.create(null) as Record; + Object.defineProperty(value, 'hook_event_name', { + enumerable: true, + get: () => { + throw new Error('must not run'); + }, + }); + expect(adapt(value)).toEqual({ kind: 'skipped', reason: 'unsupported_event' }); + }); + + it('rejects live and revoked Proxies without executing their traps', () => { + let traps = 0; + const live = new Proxy(payload(), { + getPrototypeOf: () => { + traps += 1; + throw new Error('must not run'); + }, + }); + expect(adapt(live)).toEqual({ kind: 'skipped', reason: 'invalid_payload' }); + expect(traps).toBe(0); + + const revoked = Proxy.revocable(payload(), {}); + revoked.revoke(); + expect(adapt(revoked.proxy)).toEqual({ kind: 'skipped', reason: 'invalid_payload' }); + }); + + it('feeds exact valid and malformed responses into the existing capture transaction', () => { + for (const finalMessage of [response, '{']) { + const result = adapt(payload({ last_assistant_message: finalMessage })); + if (result.kind !== 'ready') throw new Error('expected a ready callback'); + const root = temporaryRoot(); + const captured = capturePlanCritiqueCompletedCallback(result.callback, { root }); + + expect( + readPlanCritiqueExactResponse(captured.record.critiqueId, { root })?.toString('utf8'), + ).toBe(finalMessage); + expect(captured.record.execution).toMatchObject({ + provider: 'claude', + model: null, + promptHash: null, + }); + expect(readPlanCritiqueTranscript(captured.record.critiqueId, { root })).toBeNull(); + if (finalMessage === response) { + expect(captured.record.contract.state).toBe('valid'); + expect(readPlanCritiqueProjection(captured.record.critiqueId, { root })).not.toBeNull(); + } else { + expect(captured.record.contract).toMatchObject({ + state: 'invalid', + error: { code: 'INVALID_JSON', path: '$' }, + }); + expect(readPlanCritiqueProjection(captured.record.critiqueId, { root })).toBeNull(); + } + } + }); + + it('replays the same provider callback deterministically', () => { + const first = adapt(payload()); + const replay = adapt(payload()); + if (first.kind !== 'ready' || replay.kind !== 'ready') + throw new Error('expected ready callbacks'); + const root = temporaryRoot(); + const stored = capturePlanCritiqueCompletedCallback(first.callback, { root }); + const repeated = capturePlanCritiqueCompletedCallback(replay.callback, { root }); + expect(repeated.record.critiqueId).toBe(stored.record.critiqueId); + expect(repeated.record.execution.callbackHash).toBe(stored.record.execution.callbackHash); + }); +});