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

Viewing files at commit 89b58109a0e6Commit detailsView latest

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