← back to AbramsEgo

lib/a2a-client.js

268 lines

'use strict';
/**
 * A2A (Agent2Agent) CLIENT — AbramsEgo (TK-10381, Phase A)
 *
 * Client-only. Zero dependencies — uses Node ≥18 built-in fetch.
 * Egress rules: HTTPS/443 only, 15 s timeout, no cross-host redirects,
 * matching the shape egress-sentinel treats as benign (C2-lesson 2026-07-29).
 *
 * Spec reference: a2a-protocol.org/latest/specification/ (v1.0, JSON-RPC binding)
 *
 * Activation gate: the allowlist in data/a2a-agents.json starts EMPTY.
 * Nothing is consulted until Steve adds an entry AND enables the route.
 */

const { readFileSync, appendFileSync } = require('fs');
const path = require('path');

const DATA_DIR = path.join(__dirname, '..', 'data');
const AGENTS_FILE = path.join(DATA_DIR, 'a2a-agents.json');
const CONSULTS_LOG = path.join(DATA_DIR, 'a2a-consults.jsonl');

const TIMEOUT_MS = 15_000;
const MAX_POLL_ATTEMPTS = 8;
const POLL_INTERVAL_MS = 2_000;
const CARD_CACHE_TTL_MS = 60 * 60 * 1000; // 1 h
const MAX_RESPONSE_BYTES = 1_000_000; // 1 MB — a hostile peer must not OOM us
const MAX_LOGGED_ANSWER = 20_000;     // cap what an untrusted answer writes to disk

// payload linter — fields that must never leave this box
const BANNED_PAYLOAD_PATTERNS = [
  /shopify[_-]admin[_-]token/i,
  /DATABASE_URL/i,
  /\bsecret\b.*=.*[A-Za-z0-9]{16,}/i,
  /sk_live_/,
  /pk_live_/,
  /ghp_[A-Za-z0-9]{36}/,
  /ANTHROPIC_API_KEY/i,
  /OPENAI_API_KEY/i,
  /GEMINI_API_KEY/i,
  /REPLICATE_API_TOKEN/i,
];

/** Throw if the outgoing question contains banned patterns (secrets, fleet internals). */
function lintPayload(text) {
  for (const re of BANNED_PAYLOAD_PATTERNS) {
    if (re.test(text)) throw new Error(`a2a payload blocked: contains sensitive pattern (${re})`);
  }
}

/** Enforce HTTPS/443 only. Throws on http:// or non-443 https://. */
function assertSafeUrl(urlStr) {
  let u;
  try { u = new URL(urlStr); } catch { throw new Error(`a2a: invalid URL: ${urlStr}`); }
  if (u.protocol !== 'https:') throw new Error(`a2a: only HTTPS allowed, got ${u.protocol}`);
  const port = u.port ? Number(u.port) : 443;
  if (port !== 443) throw new Error(`a2a: only port 443 allowed, got ${port}`);
  // Reject embedded credentials (https://user:pass@host) — a hostile card could
  // otherwise ship userinfo outbound while still passing the same-host check.
  if (u.username || u.password) throw new Error('a2a: URL must not contain embedded credentials (userinfo)');
}

/**
 * Read a fetch response body as JSON with a hard size cap so a hostile peer can't
 * OOM the process with a giant body. Rejects on Content-Length over the cap and
 * also truncates-and-fails if the streamed body exceeds it.
 */
async function readJsonCapped(res, maxBytes = MAX_RESPONSE_BYTES) {
  const len = Number(res.headers.get('content-length') || 0);
  if (len && len > maxBytes) throw new Error(`a2a: response too large (${len} bytes > ${maxBytes} cap)`);
  const text = await res.text();
  if (text.length > maxBytes) throw new Error(`a2a: response body exceeded ${maxBytes} byte cap`);
  return JSON.parse(text);
}

/**
 * Throw if targetUrl's host differs from baseUrl's host. A fetched Agent Card is
 * UNTRUSTED input: its `url` field could otherwise pivot the RPC call (SSRF) to
 * any other https:443 host. The RPC endpoint must stay on the allowlisted host.
 */
function assertSameHost(baseUrl, targetUrl) {
  let a, b;
  try { a = new URL(baseUrl); b = new URL(targetUrl); }
  catch { throw new Error('a2a: unparseable URL in host check'); }
  if (a.host !== b.host) {
    throw new Error(`a2a: agent card url host "${b.host}" != allowlisted host "${a.host}" (blocked card-driven redirect)`);
  }
}

/** fetch with a hard timeout and no-redirect enforcement. */
async function fetchSafe(url, opts = {}) {
  assertSafeUrl(url);
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
  try {
    const res = await fetch(url, {
      ...opts,
      signal: ctrl.signal,
      redirect: 'error', // never follow redirects to a different host
    });
    return res;
  } finally {
    clearTimeout(timer);
  }
}

/** In-process Agent Card cache: { [url]: { card, fetchedAt } } */
const cardCache = {};

/**
 * Fetch and validate an agent's card from /.well-known/agent-card.json.
 * Returns the parsed card object. Caches for 1 h.
 */
async function fetchAgentCard(baseUrl) {
  const cacheKey = baseUrl.replace(/\/$/, '');
  const cached = cardCache[cacheKey];
  if (cached && Date.now() - cached.fetchedAt < CARD_CACHE_TTL_MS) return cached.card;

  const cardUrl = `${cacheKey}/.well-known/agent-card.json`;
  const res = await fetchSafe(cardUrl, { headers: { Accept: 'application/json' } });
  if (!res.ok) throw new Error(`a2a: agent card fetch failed ${res.status} from ${cardUrl}`);
  const card = await readJsonCapped(res);
  if (!card || typeof card !== 'object') throw new Error('a2a: invalid agent card (not an object)');
  if (!card.name || !card.url) throw new Error('a2a: agent card missing name/url fields');
  cardCache[cacheKey] = { card, fetchedAt: Date.now() };
  return card;
}

/** Build a JSON-RPC 2.0 request body for SendMessage. */
function buildSendMessage(question, taskId) {
  return {
    jsonrpc: '2.0',
    id: taskId,
    method: 'agent/sendMessage',
    params: {
      message: {
        role: 'user',
        parts: [{ kind: 'text', text: question }],
      },
    },
  };
}

/** Build a JSON-RPC 2.0 request for GetTask. */
function buildGetTask(taskId) {
  return {
    jsonrpc: '2.0',
    id: `${taskId}-get`,
    method: 'agent/getTask',
    params: { taskId },
  };
}

/**
 * Send a JSON-RPC request to the agent's primary endpoint.
 * Reads the endpoint from the Agent Card or falls back to baseUrl + '/'.
 */
async function rpcCall(baseUrl, card, body, authHeader) {
  const rpcUrl = card?.url || `${baseUrl.replace(/\/$/, '')}/`;
  // Card is untrusted: keep the RPC on the allowlisted host (SSRF guard).
  assertSameHost(baseUrl, rpcUrl);

  const headers = { 'Content-Type': 'application/json', Accept: 'application/json' };
  if (authHeader) headers['Authorization'] = authHeader;

  const res = await fetchSafe(rpcUrl, {
    method: 'POST',
    headers,
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`a2a: RPC ${body.method} returned ${res.status}`);
  return readJsonCapped(res);
}

/** Pull trimmed text from an array of A2A message Parts, ignoring malformed entries. */
function collectTextParts(partsArray, out) {
  if (!Array.isArray(partsArray)) return;
  for (const p of partsArray) {
    if (p && p.kind === 'text' && typeof p.text === 'string' && p.text.trim()) out.push(p.text.trim());
  }
}

/**
 * Extract text from a completed Task's artifacts, or from an inline message result.
 * Hardened against malformed/hostile responses: non-array parts/artifacts and null
 * entries are IGNORED rather than throwing — the RPC result is untrusted input.
 */
function extractText(rpcResult) {
  const parts = [];
  // inline result (no task opened)
  collectTextParts(rpcResult?.result?.message?.parts, parts);
  // task result — each artifact carries its own parts array
  const task = rpcResult?.result?.task ?? rpcResult?.result;
  if (Array.isArray(task?.artifacts)) {
    for (const art of task.artifacts) collectTextParts(art?.parts, parts);
  }
  return parts.join('\n').trim() || null;
}

/**
 * High-level consult: send a question to a named agent (by URL) and return the answer.
 * Polls GetTask for up to MAX_POLL_ATTEMPTS if a task is opened.
 *
 * @param {string} agentUrl   base URL of the A2A server (https only, port 443)
 * @param {string} question   the question to ask
 * @param {object} opts
 * @param {string} [opts.authHeader]   optional "Bearer …" / "ApiKey …" header value
 * @param {string} [opts.agentName]    human label for logging
 * @returns {{ answer: string|null, taskId: string, pollCount: number }}
 */
async function consult(agentUrl, question, opts = {}) {
  lintPayload(question);
  const card = await fetchAgentCard(agentUrl);
  const taskId = `abramsego-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;

  let rpcResult = await rpcCall(agentUrl, card, buildSendMessage(question, taskId), opts.authHeader);
  let pollCount = 0;
  let answer = extractText(rpcResult);

  // If a task was opened, poll GetTask
  const openedTaskId = rpcResult?.result?.task?.id ?? (rpcResult?.result?.id);
  if (!answer && openedTaskId) {
    for (let i = 0; i < MAX_POLL_ATTEMPTS; i++) {
      const taskState = rpcResult?.result?.task?.status?.state ?? rpcResult?.result?.status?.state;
      if (taskState === 'completed' || taskState === 'failed' || taskState === 'canceled') break;
      await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
      rpcResult = await rpcCall(agentUrl, card, buildGetTask(openedTaskId), opts.authHeader);
      pollCount++;
      answer = extractText(rpcResult);
      if (answer) break;
    }
  }

  return { answer, taskId, pollCount };
}

/** Load the allowlist from data/a2a-agents.json. Returns [] if file missing/empty. */
function loadAllowlist() {
  try {
    const raw = readFileSync(AGENTS_FILE, 'utf8');
    const arr = JSON.parse(raw);
    return Array.isArray(arr) ? arr : [];
  } catch { return []; }
}

/** Look up a named agent entry from the allowlist. Throws if not found. */
function resolveAgent(name) {
  const list = loadAllowlist();
  const entry = list.find(a => a.name === name);
  if (!entry) throw new Error(`a2a: agent "${name}" not in allowlist (data/a2a-agents.json)`);
  return entry;
}

/** Append a consult result to the JSONL log. Fire-and-forget (errors logged, not thrown). */
function logConsult(entry) {
  try {
    // Truncate an untrusted answer before it lands on disk (unbounded-log guard).
    const safe = { ...entry, ts: new Date().toISOString() };
    if (typeof safe.answer === 'string' && safe.answer.length > MAX_LOGGED_ANSWER) {
      safe.answer = safe.answer.slice(0, MAX_LOGGED_ANSWER) + `…[truncated ${safe.answer.length - MAX_LOGGED_ANSWER} chars]`;
    }
    appendFileSync(CONSULTS_LOG, JSON.stringify(safe) + '\n');
  } catch (e) {
    console.error('a2a: consult log write failed:', e.message);
  }
}

module.exports = { fetchAgentCard, consult, loadAllowlist, resolveAgent, logConsult, lintPayload, assertSafeUrl, assertSameHost, readJsonCapped, MAX_RESPONSE_BYTES, extractText };