The AI trust layer between agents: identity, agreements, safeguards, and outcome intelligence for A2A.

Viewing files at commit 66ff6831b5d2Commit detailsView latest

tests/protocol.test.ts 348 lines · 12.0 KB · typescript
1
import { describe, expect, it } from 'vitest';
2
import { createIdentity, TrustEngine } from '@openclasp/core';
3
import {
4
  CounterpartyBriefSchema,
5
  FederatedInteractionSchema,
6
  HostedMessageSchema,
7
  HostedThreadSchema,
8
  InteractionCompletionReportSchema,
9
  InteractionFeedbackSchema,
10
  LearningEligibilityDecisionSchema,
11
  LiveSessionEventSchema,
12
  PublicAgentCardSchema,
13
  canonicalHash,
14
  signObject,
15
  verifyObject,
16
} from '@openclasp/protocol';
17
import { toA2AAgentCard } from '@openclasp/sidecar';
18
19
describe('protocol cryptography and delegation', () => {
20
  it('requires compact structured data for progress checkpoints', () => {
21
    const checkpoint = LiveSessionEventSchema.parse({
22
      eventId: '11111111-1111-4111-8111-111111111111',
23
      interactionId: '22222222-2222-4222-8222-222222222222',
24
      agentId: 'agent:a',
25
      sequence: 5,
26
      type: 'progress_checkpoint',
27
      occurredAt: '2026-08-29T00:00:00.000Z',
28
      checkpoint: {
29
        state: 'active',
30
        progress: 0.5,
31
        criteriaMet: ['price quoted'],
32
        criteriaRemaining: ['delivery confirmed'],
33
        blockerCodes: [],
34
        topicStatus: 'in_scope',
35
        expectedRemainingTurns: 2,
36
        needsHuman: false,
37
        confidence: 0.8,
38
      },
39
      details: {},
40
    });
41
    expect(checkpoint.checkpoint?.progress).toBe(0.5);
42
    expect(() => LiveSessionEventSchema.parse({ ...checkpoint, checkpoint: undefined })).toThrow(
43
      'structured checkpoint data',
44
    );
45
  });
46
47
  it('canonicalizes object key order and rejects tampering', () => {
48
    expect(canonicalHash({ b: 2, a: 1 })).toBe(canonicalHash({ a: 1, b: 2 }));
49
    const agent = createIdentity({
50
      agentId: 'agent:a',
51
      operatorRef: 'operator:a',
52
      capabilities: ['write'],
53
    });
54
    const signed = signObject({ action: 'write', value: 1 }, agent.keyPair);
55
    expect(verifyObject(signed, agent.identity.publicKey)).toBe(true);
56
    expect(verifyObject({ ...signed, value: 2 }, agent.identity.publicKey)).toBe(false);
57
  });
58
59
  it('prevents authority escalation and rejects expired delegation', () => {
60
    const engine = new TrustEngine();
61
    const parent = createIdentity({
62
      agentId: 'agent:parent',
63
      operatorRef: 'operator:x',
64
      capabilities: ['read'],
65
    });
66
    const child = createIdentity({
67
      agentId: 'agent:child',
68
      operatorRef: 'operator:x',
69
      capabilities: ['read'],
70
      parentAgentId: parent.identity.agentId,
71
      rootControllerId: parent.identity.rootControllerId,
72
    });
73
    engine.registerAgent(parent.identity);
74
    engine.registerAgent(child.identity);
75
    expect(() =>
76
      engine.createDelegation(
77
        parent.identity.agentId,
78
        child.identity.agentId,
79
        ['write'],
80
        new Date(Date.now() + 1000).toISOString(),
81
        parent.keyPair,
82
      ),
83
    ).toThrow('exceeds parent authority');
84
    const expired = engine.createDelegation(
85
      parent.identity.agentId,
86
      child.identity.agentId,
87
      ['read'],
88
      new Date(Date.now() - 1).toISOString(),
89
      parent.keyPair,
90
    );
91
    expect(engine.verifyDelegation(expired.delegationId)).toBe(false);
92
  });
93
94
  it('publishes an internet Agent Card and official A2A extension without private owner data', () => {
95
    const card = PublicAgentCardSchema.parse({
96
      protocolVersion: '0.1',
97
      agentId: 'agent:research',
98
      name: 'Research agent',
99
      description: 'Finds primary sources',
100
      framework: 'Codex',
101
      agentVersion: '1.2.0',
102
      capabilities: ['research'],
103
      limitations: ['no purchases'],
104
      assurance: 'oauth_authenticated',
105
      transports: [
106
        {
107
          protocol: 'A2A/1.0',
108
          protocolBinding: 'JSONRPC',
109
          endpoint: 'https://agent.example/a2a',
110
        },
111
      ],
112
      cardUrl: 'https://openclasp.vercel.app/agents/agent%3Aresearch/card.json',
113
      a2aAgentCardUrl: 'https://openclasp.vercel.app/agents/agent%3Aresearch/a2a-agent-card.json',
114
      extensionUri: 'https://openclasp.vercel.app/extensions/trust/v0.1',
115
      publishedAt: '2026-08-29T00:00:00.000Z',
116
      updatedAt: '2026-08-29T00:00:00.000Z',
117
    });
118
    expect(card).not.toHaveProperty('operatorId');
119
    expect(card).not.toHaveProperty('projectId');
120
    const a2a = toA2AAgentCard(card);
121
    expect(a2a.supportedInterfaces[0]).toMatchObject({
122
      url: 'https://agent.example/a2a',
123
      protocolBinding: 'JSONRPC',
124
      protocolVersion: '1.0',
125
    });
126
    expect(a2a.capabilities?.extensions?.[0]?.uri).toBe(card.extensionUri);
127
  });
128
129
  it('binds bilateral acceptances to one immutable contract hash', () => {
130
    const interactionId = crypto.randomUUID();
131
    const contract = {
132
      protocolVersion: '0.1' as const,
133
      interactionId,
134
      purpose: 'Research a market',
135
      parties: ['agent:a', 'agent:b'],
136
      taskCategory: 'research',
137
      requestedOutcome: 'A sourced brief',
138
      successCriteria: ['Primary sources included'],
139
      allowedActions: ['browse'],
140
      prohibitedActions: ['purchase'],
141
      allowedData: ['public'],
142
      prohibitedData: ['secrets'],
143
      evidenceRequirements: ['links'],
144
      delegationRules: ['explicit'],
145
      humanApprovalRequirements: [],
146
      factCheckingPolicy: 'important_claims',
147
      mediationPolicy: 'mutual_consent' as const,
148
      retentionDays: 30,
149
      completionConditions: ['brief delivered'],
150
      cancellationConditions: ['either party'],
151
      signatures: {},
152
    };
153
    const termsHash = canonicalHash(contract);
154
    const now = '2026-08-29T00:00:00.000Z';
155
    const value = FederatedInteractionSchema.parse({
156
      protocolVersion: '0.1',
157
      interactionId,
158
      initiatorAgentId: 'agent:a',
159
      responderAgentId: 'agent:b',
160
      status: 'active',
161
      contract,
162
      termsHash,
163
      acceptances: {
164
        'agent:a': {
165
          agentId: 'agent:a',
166
          method: 'oauth_installation',
167
          termsHash,
168
          acceptedAt: now,
169
        },
170
        'agent:b': {
171
          agentId: 'agent:b',
172
          method: 'oauth_installation',
173
          termsHash,
174
          acceptedAt: now,
175
        },
176
      },
177
      responderTransport: {
178
        protocol: 'A2A/1.0',
179
        protocolBinding: 'JSONRPC',
180
        endpoint: 'https://agent-b.example/a2a',
181
      },
182
      createdAt: now,
183
      updatedAt: now,
184
      expiresAt: '2026-08-30T00:00:00.000Z',
185
    });
186
    expect(Object.values(value.acceptances).every((item) => item.termsHash === termsHash)).toBe(
187
      true,
188
    );
189
    expect(canonicalHash({ ...contract, purpose: 'Changed terms' })).not.toBe(termsHash);
190
  });
191
192
  it('validates bounded hosted temporary-chat history', () => {
193
    const threadId = crypto.randomUUID();
194
    const now = new Date().toISOString();
195
    const thread = HostedThreadSchema.parse({
196
      threadId,
197
      interactionId: threadId,
198
      participantAgentIds: ['agent:temporary', 'agent:persistent'],
199
      status: 'open',
200
      privacyMode: 'openclasp_hosted_temporary',
201
      unreadCount: 1,
202
      createdAt: now,
203
      updatedAt: now,
204
      expiresAt: new Date(Date.now() + 1000).toISOString(),
205
    });
206
    const message = HostedMessageSchema.parse({
207
      messageId: crypto.randomUUID(),
208
      threadId,
209
      interactionId: threadId,
210
      senderAgentId: 'agent:persistent',
211
      recipientAgentId: 'agent:temporary',
212
      contentType: 'text/plain',
213
      content: 'Interview request',
214
      contentHash: canonicalHash('Interview request'),
215
      delivery: 'delivered',
216
      createdAt: now,
217
    });
218
    expect(thread.privacyMode).toBe('openclasp_hosted_temporary');
219
    expect(message.contentHash).toBe(canonicalHash(message.content));
220
    expect(() => HostedMessageSchema.parse({ ...message, content: 'x'.repeat(20_001) })).toThrow();
221
  });
222
223
  it('produces private requirement-specific counterparty briefs', () => {
224
    const now = '2026-08-29T00:00:00.000Z';
225
    const brief = CounterpartyBriefSchema.parse({
226
      briefId: crypto.randomUUID(),
227
      interactionId: crypto.randomUUID(),
228
      contractHash: canonicalHash({ purpose: 'Recruit a backend engineer' }),
229
      recipientAgentId: 'agent:candidate',
230
      subjectAgentId: 'agent:recruiter',
231
      taskCategory: 'recruiting',
232
      decision: 'CHALLENGE',
233
      requirements: [
234
        {
235
          requirementId: 'role-scope',
236
          requirement: 'Backend software engineering positions',
237
          kind: 'scope',
238
          status: 'mismatch',
239
          reason: 'The recruiter currently declares marketing roles only.',
240
          confidence: 0.98,
241
          sources: ['self_declared', 'contract'],
242
        },
243
      ],
244
      insights: [
245
        {
246
          code: 'scope_mismatch',
247
          severity: 'high',
248
          message: 'Confirm role scope before starting the interaction.',
249
          requirementReferences: ['role-scope'],
250
          confidence: 0.98,
251
        },
252
      ],
253
      relevantSampleSize: 0,
254
      historyConfidence: 0,
255
      subjectAgentVersion: '1.0.0',
256
      recommendedContractChanges: ['Require the recruiter to confirm a backend role is available.'],
257
      generatedAt: now,
258
      expiresAt: '2026-08-29T00:10:00.000Z',
259
    });
260
    expect(brief.requirements[0]?.status).toBe('mismatch');
261
    expect(brief.insights[0]?.requirementReferences).toEqual(['role-scope']);
262
  });
263
264
  it('accepts signed structured outcomes and rejects raw conversation fields', () => {
265
    const reporter = createIdentity({
266
      agentId: 'agent:reporter',
267
      operatorRef: 'operator:reporter',
268
      capabilities: ['recruiting'],
269
    });
270
    const interactionId = crypto.randomUUID();
271
    const report = {
272
      reportId: crypto.randomUUID(),
273
      interactionId,
274
      contractHash: canonicalHash({ interactionId, requestedOutcome: 'Find a backend role' }),
275
      reportingAgentId: reporter.identity.agentId,
276
      counterpartyAgentId: 'agent:recruiter',
277
      agentVersion: '1.0.0',
278
      outcome: 'failure' as const,
279
      summary: 'No matching backend positions were available.',
280
      requestedOutcome: 'Identify available backend software engineering positions.',
281
      criteria: [
282
        {
283
          criterion: 'Return at least one backend role',
284
          status: 'missed' as const,
285
          explanation: 'The recruiter only handled marketing roles.',
286
        },
287
      ],
288
      blockers: ['Counterparty scope mismatch'],
289
      completedAt: '2026-08-29T00:05:00.000Z',
290
      confidence: 0.99,
291
      dataSharingMode: 'structured_only' as const,
292
    };
293
    const signed = signObject(report, reporter.keyPair);
294
    expect(InteractionCompletionReportSchema.parse(signed).outcome).toBe('failure');
295
    expect(verifyObject(signed, reporter.identity.publicKey)).toBe(true);
296
    expect(
297
      InteractionCompletionReportSchema.safeParse({
298
        ...report,
299
        rawMessageBody: 'private transcript content',
300
      }).success,
301
    ).toBe(false);
302
  });
303
304
  it('validates bilateral feedback inputs and structured-only learning eligibility', () => {
305
    const interactionId = crypto.randomUUID();
306
    const feedback = InteractionFeedbackSchema.parse({
307
      feedbackId: crypto.randomUUID(),
308
      requestId: crypto.randomUUID(),
309
      interactionId,
310
      reviewerAgentId: 'agent:candidate',
311
      subjectAgentId: 'agent:recruiter',
312
      reviewerAgentVersion: '1.0.0',
313
      ratings: {
314
        outcome_satisfaction: 0.1,
315
        communication: 0.9,
316
        scope_adherence: 0.2,
317
      },
318
      wouldWorkAgain: 'unsure',
319
      reasonCodes: ['scope_mismatch'],
320
      confidence: 0.9,
321
      submittedAt: '2026-08-29T00:06:00.000Z',
322
    });
323
    expect(feedback.ratings.communication).toBe(0.9);
324
    expect(
325
      InteractionFeedbackSchema.safeParse({ ...feedback, ratings: { reliability: 1.1 } }).success,
326
    ).toBe(false);
327
328
    const eligibility = {
329
      decisionId: crypto.randomUUID(),
330
      interactionId,
331
      eligible: true,
332
      reasons: ['Signed completion report and eligible bilateral feedback'],
333
      sampleWeight: 0.8,
334
      reportIds: [crypto.randomUUID(), crypto.randomUUID()],
335
      feedbackIds: [feedback.feedbackId],
336
      contributionMode: 'local_only' as const,
337
      structuredDataOnly: true as const,
338
      decidedAt: '2026-08-29T00:07:00.000Z',
339
    };
340
    expect(LearningEligibilityDecisionSchema.parse(eligibility).eligible).toBe(true);
341
    expect(
342
      LearningEligibilityDecisionSchema.safeParse({
343
        ...eligibility,
344
        structuredDataOnly: false,
345
      }).success,
346
    ).toBe(false);
347
  });
348
});