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

Download ZIP
apps/api/src/access-control.ts 126 lines · 4.4 KB · typescript
1
import {
2
  DEFAULT_MCP_AUTH_SCOPES,
3
  OPENCLASP_AUTH_SCOPES,
4
  type OpenClaspAuthScope,
5
} from '../../../packages/protocol/src/index.js';
6
7
export { DEFAULT_MCP_AUTH_SCOPES, OPENCLASP_AUTH_SCOPES, type OpenClaspAuthScope };
8
9
const PROFILE_TOOLS = new Set([
10
  'openclasp_get_profile',
11
  'openclasp_assess_counterparty',
12
  'openclasp_check_claim',
13
  'openclasp_validate_commitment',
14
  'openclasp_suggest_resolution',
15
  'openclasp_verify_receipt',
16
  'openclasp_get_identity',
17
  'openclasp_connection_status',
18
  'openclasp_find_agent',
19
  'openclasp_search_agents',
20
  'openclasp_list_invitations',
21
  'openclasp_get_shared_interaction',
22
  'openclasp_get_live_session',
23
  'openclasp_list_threads',
24
  'openclasp_get_thread',
25
  'openclasp_list_feedback_requests',
26
  'openclasp_resolve_agent',
27
  'openclasp_get_contextual_intelligence',
28
  'openclasp_recommend_agents',
29
  'openclasp_list_assurance_probes',
30
  'openclasp_get_assurance_comparisons',
31
  'openclasp_get_assurance_brief',
32
  'openclasp_shield_get_case',
33
  'openclasp_shield_list_cases',
34
]);
35
36
const AGENT_TOOLS = new Set([
37
  'openclasp_register_agent',
38
  'openclasp_setup',
39
  'openclasp_switch_agent',
40
  'openclasp_update_profile',
41
  'openclasp_register_delegation',
42
  'openclasp_heartbeat',
43
]);
44
45
const FEEDBACK_TOOLS = new Set([
46
  'openclasp_complete_interaction',
47
  'openclasp_submit_feedback',
48
  'openclasp_complete_live_session',
49
  'openclasp_submit_completion_report',
50
  'openclasp_submit_interaction_feedback',
51
  'openclasp_shield_close_case',
52
]);
53
54
export function requiredMcpToolScope(name: string): OpenClaspAuthScope {
55
  if (PROFILE_TOOLS.has(name)) return 'profile:read';
56
  if (AGENT_TOOLS.has(name)) return 'agent:manage';
57
  if (FEEDBACK_TOOLS.has(name)) return 'feedback:write';
58
  return 'interaction:write';
59
}
60
61
export async function requiredMcpRequestScopes(request: Request): Promise<OpenClaspAuthScope[]> {
62
  if (request.method !== 'POST') return ['mcp:access'];
63
  let body: unknown;
64
  try {
65
    body = await request.clone().json();
66
  } catch {
67
    return ['mcp:access'];
68
  }
69
  const messages = Array.isArray(body) ? body : [body];
70
  const scopes = new Set<OpenClaspAuthScope>(['mcp:access']);
71
  for (const message of messages) {
72
    if (!message || typeof message !== 'object') continue;
73
    const rpc = message as { method?: unknown; params?: { name?: unknown } };
74
    if (rpc.method === 'tools/call' && typeof rpc.params?.name === 'string')
75
      scopes.add(requiredMcpToolScope(rpc.params.name));
76
  }
77
  return [...scopes];
78
}
79
80
export function requiredAgentApiScopes(
81
  method: string,
82
  url: string,
83
): OpenClaspAuthScope[] | undefined {
84
  const path = new URL(url, 'https://openclasp.local').pathname;
85
  if (/^\/v0\.1\/runtime(?:\/bootstrap|\/heartbeat)?$/.test(path))
86
    return method === 'GET' ? ['profile:read'] : ['runtime:connect'];
87
  if (path === '/v0.1/runtime/profile') return ['agent:manage'];
88
  if (path === '/v0.1/feedback-requests') return ['profile:read'];
89
  if (/^\/v0\.1\/shield\/cases(?:\/[^/]+)?$/.test(path) && method === 'GET')
90
    return ['profile:read'];
91
  if (/^\/v0\.1\/shield\/cases(?:\/[^/]+\/(?:consult|close))?$/.test(path))
92
    return method === 'POST' ? ['interaction:write'] : undefined;
93
  if (/^\/v0\.1\/federated-interactions\/[^/]+\/(?:brief|session)$/.test(path))
94
    return ['profile:read'];
95
  if (
96
    /^\/v0\.1\/federated-interactions\/[^/]+\/(?:assurance-probes|assurance-comparisons|assurance-brief)$/.test(
97
      path,
98
    ) &&
99
    method === 'GET'
100
  )
101
    return ['profile:read'];
102
  if (/^\/v0\.1\/federated-interactions\/[^/]+\/assurance-responses$/.test(path))
103
    return ['interaction:write'];
104
  if (/^\/v0\.1\/federated-interactions\/[^/]+\/assurance-safeguards\/[^/]+\/decision$/.test(path))
105
    return ['interaction:write'];
106
  if (/^\/v0\.1\/federated-interactions\/[^/]+\/(?:completion-reports|feedback)$/.test(path))
107
    return ['feedback:write'];
108
  if (
109
    /^\/v0\.1\/federated-interactions\/[^/]+\/contract-proposals(?:\/[^/]+\/respond)?$/.test(path)
110
  )
111
    return ['interaction:write'];
112
  return undefined;
113
}
114
115
export function assertScopes(granted: readonly string[], required: readonly string[]): void {
116
  const missing = required.filter((scope) => !granted.includes(scope));
117
  if (missing.length) throw new ScopeError(missing);
118
}
119
120
export class ScopeError extends Error {
121
  readonly statusCode = 403;
122
123
  constructor(readonly missingScopes: string[]) {
124
    super(`Credential is missing required scope: ${missingScopes.join(', ')}`);
125
  }
126
}