// trust-gate — kb#147 (A2A-15): vault access = trusted-only, enforced here. // // agap-mcp has no per-caller identity today (every MCP client hits the same // unauthenticated /mcp endpoint on :3100) — that's the gap DESIGN-a2a-agents.md // v2.1 §5 flags as the crux of "vault access = trusted only" (DECIDED, alvis). // This module is the enforcement point: it maps a bearer token off the request // to an agent id (via AGAP_MCP_AGENT_TOKENS, a secret never committed to git), // then looks up that agent's trust class in agent-registry.yaml (the // version-controlled source of truth for grants, kb#134) to decide whether // vault tools (vw_*) may run. // // FAIL-CLOSED PRINCIPLE (once enforcement is turned on): no token, an // unrecognized token, or an agent below `trusted` rank all resolve to "no // vault access" — there is no default-allow path once AGAP_MCP_ENFORCE_VAULT_TRUST // is on. See server.js for the off-by-default activation gate: merging this // module changes zero live behavior until an operator deliberately flips that // flag AND supplies real per-agent tokens (kb#147 handover step). import { readFileSync } from 'fs'; import yaml from 'js-yaml'; const VAULT_TOOL_PREFIX = 'vw_'; const DEFAULT_REGISTRY_PATH = '/agent-registry.yaml'; const DEFAULT_TRUSTED_RANK = 2; // matches agent-registry.yaml trust_classes.trusted.rank; used only if the registry can't be read let _registryCache = null; export function loadRegistry(path = process.env.AGENT_REGISTRY_PATH || DEFAULT_REGISTRY_PATH) { if (_registryCache) return _registryCache; try { _registryCache = yaml.load(readFileSync(path, 'utf8')); } catch (e) { // Fail closed, not fail crash: no registry readable means no agent can be // proven trusted, so vaultAllowed() below returns false for everyone // rather than the process refusing to start (agap-mcp serves gitea/ha/ // zabbix/radicale/todoist tools too, which don't depend on this file). console.error(`trust-gate: could not load agent registry from ${path}: ${e.message}`); _registryCache = { agents: [], trust_classes: {} }; } return _registryCache; } // Test-only: let unit tests inject a registry object instead of touching the // filesystem, and let the CLI/tests reset the module-level cache between runs. export function _resetRegistryCacheForTests(registry = null) { _registryCache = registry; } export function trustedRankThreshold(registry = loadRegistry()) { return registry.trust_classes?.trusted?.rank ?? DEFAULT_TRUSTED_RANK; } export function trustRankOf(agentId, registry = loadRegistry()) { if (!agentId) return -1; // unauthenticated caller: rank below every real trust class const agent = (registry.agents || []).find(a => a.id === agentId); if (!agent) return -1; // unknown agent id: fail closed, not "assume trusted" const cls = registry.trust_classes?.[agent.trust_class]; return cls ? cls.rank : -1; } // tokenMap: { "": "" } — parsed once at startup from // AGAP_MCP_AGENT_TOKENS (JSON), itself sourced from per-agent tokens stored in // Vaultwarden and injected via this container's .env, never inlined in git. export function loadTokenMap(raw = process.env.AGAP_MCP_AGENT_TOKENS) { if (!raw) return {}; try { const parsed = JSON.parse(raw); return (parsed && typeof parsed === 'object') ? parsed : {}; } catch (e) { console.error(`trust-gate: AGAP_MCP_AGENT_TOKENS is not valid JSON: ${e.message}`); return {}; } } export function resolveCallerAgent(bearerToken, tokenMap) { if (!bearerToken) return null; return tokenMap[bearerToken] || null; } export function isVaultTool(toolName) { return toolName.startsWith(VAULT_TOOL_PREFIX); } export function vaultAllowed(agentId, registry = loadRegistry()) { return trustRankOf(agentId, registry) >= trustedRankThreshold(registry); } export function authHeaderToken(req) { const header = req.headers?.['authorization'] || req.headers?.['Authorization'] || ''; return header.startsWith('Bearer ') ? header.slice(7).trim() : null; }