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

Viewing files at commit 6df07fcdd05cCommit detailsView latest

tests/security.test.ts 128 lines · 5.2 KB · typescript
1
import { describe, expect, it } from 'vitest';
2
import {
3
  ScopeError,
4
  assertScopes,
5
  requiredAgentApiScopes,
6
  requiredMcpRequestScopes,
7
  requiredMcpToolScope,
8
} from '../apps/api/src/access-control.js';
9
import { productionConfigurationErrors } from '../apps/api/src/production-config.js';
10
import { guardRequest } from '../apps/api/src/request-security.js';
11
import { buildApi } from '../apps/api/src/app.js';
12
import { FixedWindowRateLimiter } from '../apps/api/src/security.js';
13
import { UnavailableFactCheckProvider } from '../packages/core/src/index.js';
14
15
describe('production security boundaries', () => {
16
  it('maps agent and MCP actions to least-privilege scopes', async () => {
17
    expect(requiredMcpToolScope('openclasp_get_profile')).toBe('profile:read');
18
    expect(requiredMcpToolScope('openclasp_connect_to_agent')).toBe('interaction:write');
19
    expect(requiredMcpToolScope('openclasp_submit_interaction_feedback')).toBe('feedback:write');
20
    expect(requiredMcpToolScope('openclasp_update_profile')).toBe('agent:manage');
21
    expect(requiredMcpToolScope('openclasp_list_assurance_probes')).toBe('profile:read');
22
    expect(requiredMcpToolScope('openclasp_generate_assurance_probe')).toBe('interaction:write');
23
    expect(requiredMcpToolScope('openclasp_get_assurance_brief')).toBe('profile:read');
24
    expect(requiredMcpToolScope('openclasp_decide_assurance_safeguard')).toBe('interaction:write');
25
    expect(
26
      await requiredMcpRequestScopes(
27
        new Request('https://openclasp.example/mcp', {
28
          method: 'POST',
29
          body: JSON.stringify({
30
            jsonrpc: '2.0',
31
            method: 'tools/call',
32
            params: { name: 'openclasp_connect_to_agent', arguments: {} },
33
          }),
34
        }),
35
      ),
36
    ).toEqual(['mcp:access', 'interaction:write']);
37
    expect(
38
      requiredAgentApiScopes(
39
        'POST',
40
        '/v0.1/federated-interactions/11111111-1111-4111-8111-111111111111/feedback',
41
      ),
42
    ).toEqual(['feedback:write']);
43
    expect(requiredAgentApiScopes('GET', '/v0.1/dashboard')).toBeUndefined();
44
    expect(requiredAgentApiScopes('POST', '/v0.1/agents/agent-a/shield-tokens')).toBeUndefined();
45
    expect(
46
      requiredAgentApiScopes('GET', '/v0.1/federated-interactions/interaction-id/assurance-probes'),
47
    ).toEqual(['profile:read']);
48
    expect(
49
      requiredAgentApiScopes(
50
        'POST',
51
        '/v0.1/federated-interactions/interaction-id/assurance-responses',
52
      ),
53
    ).toEqual(['interaction:write']);
54
    expect(
55
      requiredAgentApiScopes('GET', '/v0.1/federated-interactions/interaction-id/assurance-brief'),
56
    ).toEqual(['profile:read']);
57
    expect(
58
      requiredAgentApiScopes(
59
        'POST',
60
        '/v0.1/federated-interactions/interaction-id/assurance-safeguards/id/decision',
61
      ),
62
    ).toEqual(['interaction:write']);
63
    expect(() => assertScopes(['mcp:access'], ['interaction:write'])).toThrow(ScopeError);
64
  });
65
66
  it('rate limits and rejects oversized function requests', async () => {
67
    const limiter = new FixedWindowRateLimiter();
68
    expect(limiter.consume('operator:one', 1, 60_000, 1).allowed).toBe(true);
69
    expect(limiter.consume('operator:one', 1, 60_000, 2).allowed).toBe(false);
70
    const request = new Request('https://openclasp.example/oauth/token', {
71
      method: 'POST',
72
      headers: { 'x-real-ip': '192.0.2.44' },
73
      body: 'x'.repeat(33),
74
    });
75
    expect(
76
      await guardRequest(request, 'security-test-payload', { limit: 10, maximumBytes: 32 }),
77
    ).toMatchObject({ status: 413 });
78
  });
79
80
  it('fails closed on incomplete production configuration', () => {
81
    const errors = productionConfigurationErrors('api', {
82
      NODE_ENV: 'production',
83
      OPENCLASP_PUBLIC_URL: 'http://openclasp.example',
84
      OPENCLASP_RELAY_ENCRYPTION_KEY: 'short',
85
    });
86
    expect(errors).toContain('DATABASE_URL is required');
87
    expect(errors).toContain('AUTH0_DOMAIN is required');
88
    expect(errors).toContain('OPENCLASP_PUBLIC_URL must use HTTPS');
89
    expect(errors).toContain('OPENCLASP_RELAY_ENCRYPTION_KEY must contain at least 32 characters');
90
  });
91
92
  it('requires the unforgeable internal auth boundary for hosted account data', async () => {
93
    const repository = {
94
      dashboard: async (operatorId: string) => ({ operatorId }),
95
    } as any;
96
    const app = buildApi(undefined, undefined, repository, {
97
      internalAuthSecret: 'internal-secret',
98
    });
99
    await app.ready();
100
    const spoofed = await app.inject({
101
      method: 'GET',
102
      url: '/v0.1/dashboard',
103
      headers: { 'x-openclasp-operator': 'victim' },
104
    });
105
    expect(spoofed.statusCode).toBe(401);
106
    const trusted = await app.inject({
107
      method: 'GET',
108
      url: '/v0.1/dashboard',
109
      headers: {
110
        'x-openclasp-operator': 'owner',
111
        'x-openclasp-internal-auth': 'internal-secret',
112
      },
113
    });
114
    expect(trusted.statusCode).toBe(200);
115
    expect(trusted.json()).toEqual({ operatorId: 'owner' });
116
    await app.close();
117
  });
118
119
  it('returns unknown instead of fixture-backed claims when no provider is configured', async () => {
120
    await expect(
121
      new UnavailableFactCheckProvider().check('The moon is cheese'),
122
    ).resolves.toMatchObject({
123
      status: 'unverified',
124
      confidence: 0,
125
      evidenceReferences: [],
126
    });
127
  });
128
});