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

Viewing files at commit 6b91d3cf5293Commit detailsView latest

tests/security.test.ts 127 lines · 5.1 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(
45
      requiredAgentApiScopes('GET', '/v0.1/federated-interactions/interaction-id/assurance-probes'),
46
    ).toEqual(['profile:read']);
47
    expect(
48
      requiredAgentApiScopes(
49
        'POST',
50
        '/v0.1/federated-interactions/interaction-id/assurance-responses',
51
      ),
52
    ).toEqual(['interaction:write']);
53
    expect(
54
      requiredAgentApiScopes('GET', '/v0.1/federated-interactions/interaction-id/assurance-brief'),
55
    ).toEqual(['profile:read']);
56
    expect(
57
      requiredAgentApiScopes(
58
        'POST',
59
        '/v0.1/federated-interactions/interaction-id/assurance-safeguards/id/decision',
60
      ),
61
    ).toEqual(['interaction:write']);
62
    expect(() => assertScopes(['mcp:access'], ['interaction:write'])).toThrow(ScopeError);
63
  });
64
65
  it('rate limits and rejects oversized function requests', async () => {
66
    const limiter = new FixedWindowRateLimiter();
67
    expect(limiter.consume('operator:one', 1, 60_000, 1).allowed).toBe(true);
68
    expect(limiter.consume('operator:one', 1, 60_000, 2).allowed).toBe(false);
69
    const request = new Request('https://openclasp.example/oauth/token', {
70
      method: 'POST',
71
      headers: { 'x-real-ip': '192.0.2.44' },
72
      body: 'x'.repeat(33),
73
    });
74
    expect(
75
      await guardRequest(request, 'security-test-payload', { limit: 10, maximumBytes: 32 }),
76
    ).toMatchObject({ status: 413 });
77
  });
78
79
  it('fails closed on incomplete production configuration', () => {
80
    const errors = productionConfigurationErrors('api', {
81
      NODE_ENV: 'production',
82
      OPENCLASP_PUBLIC_URL: 'http://openclasp.example',
83
      OPENCLASP_RELAY_ENCRYPTION_KEY: 'short',
84
    });
85
    expect(errors).toContain('DATABASE_URL is required');
86
    expect(errors).toContain('AUTH0_DOMAIN is required');
87
    expect(errors).toContain('OPENCLASP_PUBLIC_URL must use HTTPS');
88
    expect(errors).toContain('OPENCLASP_RELAY_ENCRYPTION_KEY must contain at least 32 characters');
89
  });
90
91
  it('requires the unforgeable internal auth boundary for hosted account data', async () => {
92
    const repository = {
93
      dashboard: async (operatorId: string) => ({ operatorId }),
94
    } as any;
95
    const app = buildApi(undefined, undefined, repository, {
96
      internalAuthSecret: 'internal-secret',
97
    });
98
    await app.ready();
99
    const spoofed = await app.inject({
100
      method: 'GET',
101
      url: '/v0.1/dashboard',
102
      headers: { 'x-openclasp-operator': 'victim' },
103
    });
104
    expect(spoofed.statusCode).toBe(401);
105
    const trusted = await app.inject({
106
      method: 'GET',
107
      url: '/v0.1/dashboard',
108
      headers: {
109
        'x-openclasp-operator': 'owner',
110
        'x-openclasp-internal-auth': 'internal-secret',
111
      },
112
    });
113
    expect(trusted.statusCode).toBe(200);
114
    expect(trusted.json()).toEqual({ operatorId: 'owner' });
115
    await app.close();
116
  });
117
118
  it('returns unknown instead of fixture-backed claims when no provider is configured', async () => {
119
    await expect(
120
      new UnavailableFactCheckProvider().check('The moon is cheese'),
121
    ).resolves.toMatchObject({
122
      status: 'unverified',
123
      confidence: 0,
124
      evidenceReferences: [],
125
    });
126
  });
127
});