From fc4e1c75ed884b25fcf672283154296812310616 Mon Sep 17 00:00:00 2001 From: alvis Date: Thu, 30 Jul 2026 04:40:51 +0000 Subject: [PATCH] agap-mcp: authenticate the :3100 listener (kb#180), pin bw CLI, add capture/classifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Listener auth (kb#180, DESIGN-a2a-agents.md §4) ----------------------------------------------- agap-mcp binds :3100 on every interface (network_mode: host) and the LAN carries VPN-terminated peers, so an unauthenticated JSON-RPC listener handed ha_call_service / gitea_wiki_write / wiki_edit / radicale+todoist writes and POST /capture-idea to any LAN peer. Only vw_* was gated before (kb#147), and only at ENFORCE=1. src/listener-auth.js now requires `Authorization: Bearer ` resolving to a known agent id on every route except /health, which stays open so a misconfigured token map is still diagnosable. Two gates stay deliberately layered and independently switchable: "are you an agent at all?" (this file) vs "are you trusted enough for the vault?" (trust-gate.js), both reading the same token map. Also closes an SSE session-hijack hole: /messages previously trusted any sessionId with no credential, so a guessed or leaked id was full tool access. Sessions are now pinned to the caller identity captured at the /sse handshake, comparing agent id *and* token. Auth defaults ON, and boot fails loudly if the token map is empty rather than serving 401 to everyone while /health reports ok. Rollback is AGAP_MCP_REQUIRE_AUTH=0. Verified live: unauthenticated and bad-token /mcp -> 401, unauthenticated /capture-idea -> 401, /health -> 200, both real agent tokens -> 200 with 36 tools, including from inside the adolf container. Pin the bw CLI -------------- The Dockerfile installed @bitwarden/cli unpinned. Rebuilding jumped 2026.2.0 -> 2026.7.0, whose WASM cipher deserializer rejects any stored login carrying `"uri": null` ("invalid type: JsValue(Object({...})), expected a string") -- 33 of 49 items in this vault have that shape. `bw list` then exits 1, server init fails, and the container crash-loops. Pinned to 2026.2.0. Do not unpin: 2026.7.0 cannot authenticate against this Vaultwarden (2025.12.0) at all -- it refuses plain HTTP outright and 404s on the identity endpoint over HTTPS. Updating the CLI requires upgrading Vaultwarden first. capture / classifier -------------------- Adds the POST /capture-idea REST endpoint and the idea classifier behind it (consumed by the todoist-capture plugin), with tests. Carried in the same commit because server.js wires both this and the auth boot path. Co-Authored-By: Claude Opus 5 --- agap-mcp/Dockerfile | 9 +- agap-mcp/docker-compose.yml | 31 +++ agap-mcp/src/capture.js | 58 ++++ agap-mcp/src/capture.test.mjs | 84 ++++++ agap-mcp/src/classifier.js | 228 ++++++++++++++++ agap-mcp/src/classifier.test.mjs | 45 +++ agap-mcp/src/gitea.js | 28 +- agap-mcp/src/listener-auth.js | 147 ++++++++++ agap-mcp/src/server.js | 142 +++++++++- agap-mcp/src/trust-gate-http.test.mjs | 376 ++++++++++++++++++++++---- agap-mcp/src/trust-gate.js | 20 +- 11 files changed, 1089 insertions(+), 79 deletions(-) create mode 100644 agap-mcp/src/capture.js create mode 100644 agap-mcp/src/capture.test.mjs create mode 100644 agap-mcp/src/classifier.js create mode 100644 agap-mcp/src/classifier.test.mjs create mode 100644 agap-mcp/src/listener-auth.js diff --git a/agap-mcp/Dockerfile b/agap-mcp/Dockerfile index 07c26e4..36fb8b8 100644 --- a/agap-mcp/Dockerfile +++ b/agap-mcp/Dockerfile @@ -5,7 +5,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* WORKDIR /app -RUN npm install -g @bitwarden/cli +# PINNED — do not float this back to `@bitwarden/cli` (kb#180, 2026-07-30). +# An unpinned rebuild pulled 2026.7.0, whose WASM cipher deserializer rejects +# any stored login object with `"uri": null` ("invalid type: JsValue(Object({...})), +# expected a string") — the MATRIX_ADOLF_GATEWAY_TOKEN item in this vault has +# exactly that shape. `bw list` then exits 1, server.js's init fails, and the +# container crash-loops on restart. 2026.2.0 parses that item fine and is the +# version the host CLI runs. Re-test against the live vault before bumping. +RUN npm install -g @bitwarden/cli@2026.2.0 COPY package.json ./ RUN npm install --production COPY src/ ./src/ diff --git a/agap-mcp/docker-compose.yml b/agap-mcp/docker-compose.yml index 107eb8b..4f75eb3 100644 --- a/agap-mcp/docker-compose.yml +++ b/agap-mcp/docker-compose.yml @@ -36,9 +36,40 @@ services: # inlined in this committed file. Empty object = no caller resolves to # any agent, i.e. fail-closed once ENFORCE is turned on. - AGAP_MCP_AGENT_TOKENS=${AGAP_MCP_AGENT_TOKENS:-{}} + # kb#180 (DESIGN-a2a-agents.md §4) — authentication of the LISTENER + # itself, a strictly larger gate than the vw_*-only one above. With + # this on (the default in code), /mcp, /sse, /messages and + # /capture-idea all require `Authorization: Bearer ` resolving + # to an agent id in AGAP_MCP_AGENT_TOKENS; /health stays open for this + # healthcheck. :3100 is bound on every interface (network_mode: host) + # and the LAN carries VPN-terminated peers, so an open listener means + # any peer can call ha_call_service / gitea_wiki_write / wiki_edit / + # todoist writes. + # + # ACTIVATION IS NOT AUTOMATIC-SAFE: with auth on and + # AGAP_MCP_AGENT_TOKENS empty, the process REFUSES TO START (loud + # crash instead of denying every caller while /health says ok). So + # AGAP_MCP_AGENT_TOKENS must be populated in this directory's .env + # BEFORE the next restart of this service, and every caller + # (Adolf/shared-mcp.json, Claude Code .claude.json, the + # todoist-capture-plugin) must be given its token — see the kb#180 + # migration list. Set AGAP_MCP_REQUIRE_AUTH=0 in .env only as a + # deliberate emergency rollback to the old open listener. + - AGAP_MCP_REQUIRE_AUTH=${AGAP_MCP_REQUIRE_AUTH:-1} volumes: - /home/alvis/.config/Bitwarden CLI:/bw-data # Read-only: agent-registry.yaml is the version-controlled source of # truth for trust classes (kb#134/kb#147) — mounted, never copied, so # a registry edit takes effect on container restart with no rebuild. - /home/alvis/agap_git/openai/agent-registry.yaml:/agent-registry.yaml:ro + # kb#190: /health responds 200 with no auth/side effects (confirmed). + # This is a SEPARATE compose project from openai/docker-compose.yml + # (network_mode: host, reached from adolf-llm etc. via + # host.docker.internal), so it cannot be wired into that file's + # depends_on/condition chain -- this only gives it its own status. + healthcheck: + test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3100/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""] + interval: 15s + timeout: 10s + retries: 5 + start_period: 20s diff --git a/agap-mcp/src/capture.js b/agap-mcp/src/capture.js new file mode 100644 index 0000000..f462f8c --- /dev/null +++ b/agap-mcp/src/capture.js @@ -0,0 +1,58 @@ +// Idea capture pipeline (kb#170): classify -> Todoist task. +// +// Deliberately does NOT create/move anything in Kanboard, Radicale, or +// gitea — kb#170 component 4 ("Adolf предлагает, какие идеи созрели") +// is a human-in-the-loop periodic review, not an automatic conversion. +// This module only tags the idea so that later review has something to +// act on (labels: area-*, urgency-*, and "decompose" when flagged). +// +// Project/label mapping is a deliberate v1 decision, not a guess left +// unstated: Todoist's real projects today (Inbox, One-Off, Family, +// Planning, Pending — confirmed live via todoist_list_projects) don't +// line up with the 5 kb#170 areas except "семья" ~= "Family". Creating +// four new Todoist projects to match Adolf/Welfare/дом/здоровье is a +// structural change to the user's real Todoist account, so it is NOT done +// here without explicit sign-off — see kb#170 report. Instead every idea +// keeps its default project (Inbox, unless the caller passes one) and +// gets an `area-*` label, which is purely additive/reversible (Todoist +// auto-creates labels on first use, and any label can be deleted later +// with zero data loss). +import { classifyIdea } from './classifier.js'; +import { todoistCreateTask } from './todoist.js'; + +const FAMILY_PROJECT_NAME = 'Family'; + +// urgency label -> Todoist API priority (1=normal..4=urgent, inverse of +// the Todoist UI's p1..p4 — see todoist.js header comment). +const URGENCY_TO_PRIORITY = { high: 4, medium: 2, low: 1 }; + +export async function todoistCaptureIdea({ text, project_id } = {}, { listProjects = null, createTask = todoistCreateTask } = {}) { + if (!text || !text.trim()) throw new Error('text is required'); + + const classification = await classifyIdea(text.trim()); + + const labels = [`area-${classification.area.label}`, `urgency-${classification.urgency.label}`]; + if (classification.decompose.label === 'needs-decomposition') labels.push('decompose'); + if (classification.area.ambiguous) labels.push('area-uncertain'); + + // Only auto-route to an existing project when it's an unambiguous, exact + // match (семья -> Family) — never invent/select a project the classifier + // merely guessed at, and never override a project_id the caller passed + // explicitly. + let resolvedProjectId = project_id; + if (!resolvedProjectId && classification.area.label === 'семья' && !classification.area.ambiguous && typeof listProjects === 'function') { + const projects = await listProjects(); + const family = projects.find((p) => p.name === FAMILY_PROJECT_NAME); + if (family) resolvedProjectId = family.id; + } + + const task = await createTask({ + content: text.trim(), + description: `Захвачено через AI-классификацию (kb#170): area=${classification.area.label} (${classification.area.score}), urgency=${classification.urgency.label} (${classification.urgency.score}), decompose=${classification.decompose.label} (${classification.decompose.score}).`, + priority: URGENCY_TO_PRIORITY[classification.urgency.label], + project_id: resolvedProjectId, + labels, + }); + + return { task, classification }; +} diff --git a/agap-mcp/src/capture.test.mjs b/agap-mcp/src/capture.test.mjs new file mode 100644 index 0000000..87b3489 --- /dev/null +++ b/agap-mcp/src/capture.test.mjs @@ -0,0 +1,84 @@ +// Proof for kb#170 capture pipeline (classify -> Todoist task shape), +// run with: +// BGE_M3_URL=http://localhost:11436/v1/embeddings node src/capture.test.mjs +// +// Deliberately stubs createTask/listProjects instead of calling the real +// Todoist API — this proves the classify -> label/priority/project mapping +// logic without writing test data into the user's live Todoist account +// (kb#170 report: no live Todoist writes were made while proving this out). + +import assert from 'node:assert/strict'; +import { todoistCaptureIdea } from './capture.js'; + +let passed = 0; +function check(label, fn) { + fn(); + passed++; + console.log(`ok - ${label}`); +} + +const projects = [ + { id: '6CrfPQ8FXxf5ghrx', name: 'Inbox', is_inbox: true }, + { id: '6cg4j8CX3vj7H9rJ', name: 'Family' }, +]; + +function makeStubCreateTask() { + const calls = []; + const createTask = async (args) => { + calls.push(args); + return { id: 'stub-1', content: args.content, project_id: args.project_id, priority: args.priority, labels: args.labels }; + }; + return { createTask, calls }; +} + +const { createTask: createTaskFamily, calls: callsFamily } = makeStubCreateTask(); +const familyResult = await todoistCaptureIdea( + { text: 'позвонить маме поздравить с днём рождения' }, + { listProjects: async () => projects, createTask: createTaskFamily } +); + +check('семья idea auto-routes to the existing Family project', () => { + assert.equal(familyResult.classification.area.label, 'семья'); + assert.equal(callsFamily[0].project_id, '6cg4j8CX3vj7H9rJ'); +}); + +check('семья idea is labelled area-семья + urgency-*', () => { + assert.ok(callsFamily[0].labels.includes('area-семья')); + assert.ok(callsFamily[0].labels.some((l) => l.startsWith('urgency-'))); +}); + +const { createTask: createTaskUrgent, calls: callsUrgent } = makeStubCreateTask(); +await todoistCaptureIdea( + { text: 'починить квоту Kimi у Adolf, срочно сегодня' }, + { listProjects: async () => projects, createTask: createTaskUrgent } +); + +check('high-urgency idea gets Todoist priority 4 (urgent)', () => { + assert.equal(callsUrgent[0].priority, 4); +}); + +check('adolf-area idea is NOT auto-routed to a project (no matching project exists)', () => { + assert.equal(callsUrgent[0].project_id, undefined); +}); + +const { createTask: createTaskProject, calls: callsProject } = makeStubCreateTask(); +await todoistCaptureIdea( + { text: 'купить новый пылесос для дома', project_id: 'explicit-override' }, + { listProjects: async () => projects, createTask: createTaskProject } +); + +check('an explicit project_id always wins over auto-routing', () => { + assert.equal(callsProject[0].project_id, 'explicit-override'); +}); + +const { createTask: createTaskDecompose, calls: callsDecompose } = makeStubCreateTask(); +await todoistCaptureIdea( + { text: 'спроектировать и запустить proactive-секретаря на Agap' }, + { listProjects: async () => projects, createTask: createTaskDecompose } +); + +check('a multi-step idea gets the "decompose" label', () => { + assert.ok(callsDecompose[0].labels.includes('decompose')); +}); + +console.log(`\n${passed} passed`); diff --git a/agap-mcp/src/classifier.js b/agap-mcp/src/classifier.js new file mode 100644 index 0000000..3dd1fe1 --- /dev/null +++ b/agap-mcp/src/classifier.js @@ -0,0 +1,228 @@ +// Todoist idea classifier (kb#170, component 2) — encoder-only, NOT a +// classifier LLM call. Per DESIGN-a2a-agents.md v2.1 §3a/§3.1/theorem 24: +// "routing classification is embedding-based on the local bge-m3 ... no +// classifier LLM, no API spend". This module applies that same idea to +// Todoist-capture classification: embed the idea text with bge-m3 (already +// GPU-resident, never-evict per model-registry.yaml) and classify by +// nearest-centroid against a small hand-labelled exemplar set — no Kimi/ +// gemma call, ~0 marginal cost, no metered API. +// +// Three independent classification axes (each idea gets one label per axis, +// not a single combined class): +// area — which part of life the idea belongs to (kb#170 spec) +// urgency — how soon it stops being actionable +// decompose — is this a single atomic action, or a multi-step project +// that should eventually become a Kanboard task graph +// +// This is deliberately a NEAREST-CENTROID classifier, not a trained model: +// no labelled training set exists (kb#170 orchestrator note — inventing one +// would be guessing), so the "training data" IS the exemplar list below, +// reviewed/editable in code (git-controlled, per DESIGN-a2a-agents.md +// "Personas and Cards are code"). Extending accuracy later means adding +// exemplars here, not retraining a model. + +const DEFAULT_BGE_URL = process.env.BGE_M3_URL || 'http://host.docker.internal:11436/v1/embeddings'; + +// --- Exemplars ------------------------------------------------------------- +// Kept short and idiomatic (the kind of one-line idea a person actually +// captures), Russian-first since that's the capture language (kb#170 desc). +// Centroids are the mean of these exemplars' embeddings — adding more +// exemplars per class only requires appending strings here. + +const AREA_EXEMPLARS = { + adolf: [ + 'починить квоту Kimi у Adolf', + 'настроить cron задачу в Kanboard', + 'добавить новую MCP команду', + 'проверить логи agap-mcp контейнера', + 'написать воркер для очереди задач', + 'обновить конфиг openclaw.json', + ], + welfare: [ + 'продумать еженедельный ревью задач', + 'настроить трекер настроения и энергии', + 'сделать ежедневный брифинг по утрам', + 'завести журнал решений', + 'придумать систему напоминаний о важных вещах', + 'разобраться с личной продуктивностью', + 'спроектировать proactive-секретаря для себя', + 'построить систему, которая сама напоминает и планирует', + 'придумать, как автоматизировать личный распорядок дня', + ], + 'дом': [ + 'купить новый пылесос', + 'почистить фильтр кондиционера', + 'вызвать сантехника починить кран', + 'заказать доставку воды', + 'разобрать кладовку', + 'поменять лампочку в коридоре', + 'оплатить счёт за квартиру', + 'оплатить интернет и коммуналку', + ], + 'семья': [ + 'позвонить маме', + 'поздравить сестру с днём рождения', + 'купить подарок жене', + 'спланировать поездку с семьёй', + 'написать бабушке', + 'забрать детей из школы', + ], + 'здоровье': [ + 'записаться к врачу', + 'сдать анализы крови', + 'начать бегать по утрам', + 'купить витамины', + 'сходить к стоматологу', + 'записаться на массаж', + ], +}; + +const URGENCY_EXEMPLARS = { + high: [ + 'сделать это сегодня, срочно', + 'дедлайн завтра утром', + 'оплатить штраф до пятницы, иначе пени', + 'нужно решить прямо сейчас', + ], + medium: [ + 'сделать на этой неделе', + 'стоит сделать в ближайшие дни', + 'через пару дней надо разобраться', + 'неплохо бы успеть до конца месяца', + ], + low: [ + 'когда-нибудь было бы неплохо', + 'не к спеху, просто идея на будущее', + 'если будет время', + 'мысль про потом, без срока', + ], +}; + +const DECOMPOSE_EXEMPLARS = { + 'needs-decomposition': [ + 'организовать переезд на новую квартиру', + 'спроектировать и запустить новый сервис на сервере', + 'спланировать отпуск в другую страну', + 'построить систему проактивного секретаря', + 'провести ремонт в квартире', + 'подготовить и провести презентацию проекта', + ], + 'simple-task': [ + 'позвонить маме', + 'купить хлеб', + 'оплатить счёт за интернет', + 'отправить один email', + 'поставить будильник', + 'записать одну мысль в заметки', + ], +}; + +// --- Embeddings + cosine similarity ----------------------------------------- + +async function embed(text, bgeUrl = DEFAULT_BGE_URL) { + const res = await fetch(bgeUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'bge-m3', input: text }), + }); + if (!res.ok) { + throw new Error(`bge-m3 embeddings ${res.status}: ${(await res.text()).slice(0, 300)}`); + } + const body = await res.json(); + const vec = body?.data?.[0]?.embedding; + if (!Array.isArray(vec)) throw new Error('bge-m3 embeddings: no vector in response'); + return vec; +} + +function dot(a, b) { + let s = 0; + for (let i = 0; i < a.length; i++) s += a[i] * b[i]; + return s; +} + +function norm(a) { + return Math.sqrt(dot(a, a)); +} + +function normalize(a) { + const n = norm(a) || 1; + return a.map((x) => x / n); +} + +function mean(vectors) { + const dim = vectors[0].length; + const out = new Array(dim).fill(0); + for (const v of vectors) for (let i = 0; i < dim; i++) out[i] += v[i]; + return out.map((x) => x / vectors.length); +} + +function cosine(a, b) { + return dot(a, b) / ((norm(a) || 1) * (norm(b) || 1)); +} + +// --- Centroid cache ---------------------------------------------------------- +// Computed once per process (exemplars are static, embedding a few dozen +// short strings at startup is cheap and happens lazily on first classify() +// call, not at import time — keeps agap-mcp's init() path unaffected). + +let _centroidsPromise = null; + +async function buildCentroidSet(exemplarMap, bgeUrl) { + const labels = Object.keys(exemplarMap); + const centroids = {}; + for (const label of labels) { + const vectors = await Promise.all(exemplarMap[label].map((t) => embed(t, bgeUrl).then(normalize))); + centroids[label] = normalize(mean(vectors)); + } + return centroids; +} + +async function getCentroids(bgeUrl = DEFAULT_BGE_URL) { + if (!_centroidsPromise) { + _centroidsPromise = Promise.all([ + buildCentroidSet(AREA_EXEMPLARS, bgeUrl), + buildCentroidSet(URGENCY_EXEMPLARS, bgeUrl), + buildCentroidSet(DECOMPOSE_EXEMPLARS, bgeUrl), + ]).then(([area, urgency, decompose]) => ({ area, urgency, decompose })); + } + return _centroidsPromise; +} + +// Test-only: let tests reset the cache (e.g. to inject a different BGE_URL). +export function _resetCentroidCacheForTests() { + _centroidsPromise = null; +} + +// nearestLabel: pick argmax cosine similarity; also report the runner-up +// and the margin between them. A small margin means the idea sits between +// two classes — surfaced as `ambiguous: true` rather than silently forced, +// so the periodic-review pass (kb#170 component 4) can have Adolf confirm +// instead of trusting a low-confidence auto-tag. +function nearestLabel(vec, centroidMap) { + const scored = Object.entries(centroidMap) + .map(([label, centroid]) => ({ label, score: cosine(vec, centroid) })) + .sort((a, b) => b.score - a.score); + const [top, second] = scored; + const margin = second ? top.score - second.score : 1; + return { + label: top.label, + score: Number(top.score.toFixed(4)), + margin: Number(margin.toFixed(4)), + ambiguous: margin < 0.03, // empirical starting threshold — revisit once real captures accumulate (same posture as DESIGN-proactive-prioritization.md's tunable constants) + }; +} + +// classify: the one entry point. Embeds the idea text ONCE, reuses it +// across all three axes (one bge-m3 call, not three) — consistent with +// the "no metered/needless calls" cost discipline in DESIGN-a2a-agents.md. +export async function classifyIdea(text, { bgeUrl = DEFAULT_BGE_URL } = {}) { + if (!text || !text.trim()) throw new Error('text is required'); + const [vec, centroids] = await Promise.all([embed(text, bgeUrl).then(normalize), getCentroids(bgeUrl)]); + return { + area: nearestLabel(vec, centroids.area), + urgency: nearestLabel(vec, centroids.urgency), + decompose: nearestLabel(vec, centroids.decompose), + }; +} + +export const _internal = { AREA_EXEMPLARS, URGENCY_EXEMPLARS, DECOMPOSE_EXEMPLARS, cosine, embed, getCentroids }; diff --git a/agap-mcp/src/classifier.test.mjs b/agap-mcp/src/classifier.test.mjs new file mode 100644 index 0000000..ba5dbca --- /dev/null +++ b/agap-mcp/src/classifier.test.mjs @@ -0,0 +1,45 @@ +// Proof for kb#170 component 2 — run with: +// BGE_M3_URL=http://localhost:11436/v1/embeddings node src/classifier.test.mjs +// (default BGE_M3_URL assumes host.docker.internal, which only resolves +// inside a container; override to localhost when running on the Agap host +// directly, same pattern as the bge-m3 curl checks elsewhere in this repo). +// +// This is a LIVE test against the real bge-m3 embedder (no mock) — the +// point of an encoder-only classifier is that it's cheap enough to just +// call for real (~30 short strings embedded once, then one embedding per +// test case). It does not touch Todoist, Kanboard, or any other live +// service. + +import assert from 'node:assert/strict'; +import { classifyIdea } from './classifier.js'; + +const cases = [ + { text: 'позвонить маме поздравить с днём рождения', expectArea: 'семья' }, + { text: 'купить новый пылесос для дома', expectArea: 'дом' }, + { text: 'записаться на приём к стоматологу', expectArea: 'здоровье' }, + { text: 'починить квоту Kimi у Adolf, срочно сегодня', expectArea: 'adolf', expectUrgency: 'high' }, + { text: 'спроектировать и запустить proactive-секретаря на Agap', expectArea: 'welfare', expectDecompose: 'needs-decomposition' }, + { text: 'оплатить счёт за интернет', expectDecompose: 'simple-task' }, + { text: 'организовать переезд на новую квартиру, когда-нибудь', expectDecompose: 'needs-decomposition', expectUrgency: 'low' }, +]; + +let passed = 0; +let failed = 0; + +for (const c of cases) { + const result = await classifyIdea(c.text); + const row = `"${c.text}" -> area=${result.area.label}(${result.area.score}) urgency=${result.urgency.label}(${result.urgency.score}) decompose=${result.decompose.label}(${result.decompose.score})`; + try { + if (c.expectArea) assert.equal(result.area.label, c.expectArea, `area mismatch for "${c.text}"`); + if (c.expectUrgency) assert.equal(result.urgency.label, c.expectUrgency, `urgency mismatch for "${c.text}"`); + if (c.expectDecompose) assert.equal(result.decompose.label, c.expectDecompose, `decompose mismatch for "${c.text}"`); + console.log(`ok - ${row}`); + passed++; + } catch (e) { + console.log(`FAIL - ${row}\n ${e.message}`); + failed++; + } +} + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1); diff --git a/agap-mcp/src/gitea.js b/agap-mcp/src/gitea.js index 1a074ff..f021678 100644 --- a/agap-mcp/src/gitea.js +++ b/agap-mcp/src/gitea.js @@ -3,6 +3,20 @@ import { writeFileSync, mkdirSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; +// Askpass helper: git invokes this script (path is what shows up in ps/args), +// and it reads the actual token from an env var — never from argv or the URL. +// This keeps the token out of the process table and out of any git error text. +let _askpassPath = null; +function askpassScript() { + if (_askpassPath) return _askpassPath; + const dir = join(tmpdir(), 'agap-mcp-wiki'); + mkdirSync(dir, { recursive: true }); + const scriptPath = join(dir, 'git-askpass.sh'); + writeFileSync(scriptPath, '#!/bin/sh\nprintf %s "$GITEA_ASKPASS_TOKEN"\n', { mode: 0o700 }); + _askpassPath = scriptPath; + return scriptPath; +} + const BASE = () => process.env.GITEA_URL || 'http://localhost:3000'; let _token = null; @@ -48,9 +62,19 @@ export async function giteaWikiRead(page, repo = 'alvis/AgapHost') { export async function giteaWikiWrite(page, content, message, repo = 'alvis/AgapHost') { const dir = join(tmpdir(), 'agap-mcp-wiki'); - const wikiUrl = `${BASE().replace('http://', `http://alvis:${token()}@`)}/alvis/AgapHost.wiki.git`; + // Username in the URL is not secret; the password/token is supplied out-of-band + // via GIT_ASKPASS + GITEA_ASKPASS_TOKEN, so it never appears in the URL, the + // execSync command string, ps/process args, or surfaced git error output. + const wikiUrl = `${BASE().replace('http://', 'http://alvis@')}/alvis/AgapHost.wiki.git`; - const gitEnv = { ...process.env, GIT_AUTHOR_NAME: 'agap-mcp', GIT_AUTHOR_EMAIL: 'allogn@gmail.com', GIT_COMMITTER_NAME: 'agap-mcp', GIT_COMMITTER_EMAIL: 'allogn@gmail.com' }; + const gitEnv = { + ...process.env, + GIT_AUTHOR_NAME: 'agap-mcp', GIT_AUTHOR_EMAIL: 'allogn@gmail.com', + GIT_COMMITTER_NAME: 'agap-mcp', GIT_COMMITTER_EMAIL: 'allogn@gmail.com', + GIT_ASKPASS: askpassScript(), + GIT_TERMINAL_PROMPT: '0', + GITEA_ASKPASS_TOKEN: token(), + }; try { execSync(`git -C ${dir} pull ${wikiUrl} main`, { env: gitEnv, stdio: 'pipe' }); diff --git a/agap-mcp/src/listener-auth.js b/agap-mcp/src/listener-auth.js new file mode 100644 index 0000000..6d8cd76 --- /dev/null +++ b/agap-mcp/src/listener-auth.js @@ -0,0 +1,147 @@ +// listener-auth — kb#180: authenticate the agap-mcp :3100 listener itself, +// not just the vault tools. +// +// WHY THIS EXISTS +// agap-mcp runs `network_mode: host` and binds :3100 on every interface. The +// LAN is explicitly NOT a trust boundary here — DESIGN-a2a-agents.md v2.1 §4 +// ("Auth is mandatory on every A2A surface. The LAN is not trusted — the +// xray/3x-ui VPN terminates other people's peers on it. No unauthenticated +// JSON-RPC listener, ever: shared tokens minimum, mTLS preferred."). +// +// Before this module, the ONLY gate in the process was requireVaultAccess() +// (kb#147), which covers vw_* tools alone and only at ENFORCE=1. Everything +// else — ha_call_service, gitea_wiki_write, wiki_edit, radicale_*/todoist_* +// writes, and the plain-REST POST /capture-idea — was callable by any LAN +// peer with curl. This module closes that: the transport itself now requires +// a bearer token that resolves to a known agent id. +// +// TWO DISTINCT GATES, DELIBERATELY LAYERED +// 1. listener auth (this file) — "are you *an* agent at all?" → any id in +// AGAP_MCP_AGENT_TOKENS passes; unknown/absent token = 401. +// 2. vault trust gate (trust-gate.js / requireVaultAccess in server.js) +// — "are you trust_class >= trusted?" → only then may vw_* run. +// Both read the SAME token map, so one token per agent covers both. Gate 2 +// stays independently switchable (AGAP_MCP_ENFORCE_VAULT_TRUST) exactly as +// kb#147 shipped it; turning on gate 1 does not turn on gate 2. +// +// FAIL-FAST, NOT FAIL-SILENT +// Auth is ON by default (AGAP_MCP_REQUIRE_AUTH != '0'). If it is on and the +// token map is empty, assertListenerAuthConfig() throws at boot rather than +// letting the process serve 401 to literally everyone while /health says +// "ok" — a missing .env value must look like a broken restart, not like a +// quietly dead integration. See server.js boot path. +// +// SSE SESSION BINDING +// The legacy SSE transport hands out a sessionId at GET /sse and accepts JSON-RPC +// on POST /messages?sessionId=... . Previously /messages trusted ANY sessionId +// with no credential — a session-id guess/leak was full tool access (hijack). +// bindSseSession()/authorizeSseSession() below pin the caller identity captured +// at handshake to the session, and /messages must present the same agent's +// token or it is rejected. + +import { resolveCallerAgent, authHeaderToken } from './trust-gate.js'; + +// ON unless explicitly disabled. The opposite default from kb#147's vault gate +// on purpose: an unauthenticated JSON-RPC listener is the thing §4 forbids +// outright, so "off" has to be a deliberate, visible opt-out. +export function requireAuthEnabled(env = process.env) { + return env.AGAP_MCP_REQUIRE_AUTH !== '0'; +} + +// Routes that stay open even with auth on: +// /health — the compose healthcheck calls it with no credential, it has no +// side effects, and it returns only counts/booleans (no secrets, no tool +// surface). Keeping it open is what lets a misconfigured token map still be +// diagnosable from outside the container. +export const PUBLIC_PATHS = new Set(['/health']); + +export class ListenerAuthConfigError extends Error {} + +// Called once at boot from server.js. Throws (crash loudly) instead of +// booting an all-callers-denied service. +export function assertListenerAuthConfig(tokenMap, env = process.env) { + if (!requireAuthEnabled(env)) { + console.error( + '\n*** agap-mcp WARNING: AGAP_MCP_REQUIRE_AUTH=0 — the :3100 MCP listener is ' + + 'UNAUTHENTICATED. Every tool (ha_call_service, gitea_wiki_write, wiki_edit, ' + + 'radicale/todoist writes, POST /capture-idea) is callable by any LAN peer, and ' + + 'the LAN carries VPN-terminated peers. This violates DESIGN-a2a-agents.md §4 ' + + 'and is only acceptable as a temporary, deliberate rollback (kb#180). ***\n' + ); + return; + } + if (Object.keys(tokenMap || {}).length === 0) { + throw new ListenerAuthConfigError( + 'AGAP_MCP_REQUIRE_AUTH is on (default) but AGAP_MCP_AGENT_TOKENS is empty/unset, ' + + 'so no caller could ever authenticate. Populate AGAP_MCP_AGENT_TOKENS in this ' + + "container's .env with a JSON map {\"\":\"\"} (agent ids " + + 'must exist in agent-registry.yaml), or set AGAP_MCP_REQUIRE_AUTH=0 to ' + + 'deliberately run the listener unauthenticated (kb#180, DESIGN-a2a-agents.md §4).' + ); + } +} + +// Express middleware factory. On success sets req.callerAgentId (string) and +// req.callerToken, which the /mcp, /sse and /messages routes consume. +// With auth disabled it sets req.callerAgentId from the token if one happens +// to be present (so the vault gate keeps working) and lets the request through. +export function listenerAuth(tokenMap, env = process.env) { + const enabled = requireAuthEnabled(env); + return function listenerAuthMiddleware(req, res, next) { + const token = authHeaderToken(req); + const agentId = resolveCallerAgent(token, tokenMap); + req.callerToken = token; + req.callerAgentId = agentId; + + if (!enabled) return next(); + if (PUBLIC_PATHS.has(req.path)) return next(); + if (agentId) return next(); + + return denyUnauthenticated(req, res, token ? 'unknown-token' : 'no-credential'); + }; +} + +// 401 body shape: JSON-RPC error for MCP routes (so an MCP client surfaces a +// real protocol error rather than a parse failure), plain JSON elsewhere. +export function denyUnauthenticated(req, res, reason) { + const message = + `unauthenticated: this endpoint requires an Authorization: Bearer header ` + + `resolving to a known agent (kb#180, DESIGN-a2a-agents.md §4) [${reason}]`; + res.set('WWW-Authenticate', 'Bearer realm="agap-mcp"'); + if (isJsonRpcPath(req.path)) { + return res.status(401).json({ jsonrpc: '2.0', error: { code: -32001, message }, id: null }); + } + return res.status(401).json({ error: message }); +} + +export function isJsonRpcPath(path) { + return path === '/mcp' || path === '/messages' || path === '/sse'; +} + +// --- SSE session binding ------------------------------------------------- +// sessions: Map + +export function bindSseSession(sessions, sessionId, transport, req) { + sessions.set(sessionId, { + transport, + agentId: req.callerAgentId || null, + token: req.callerToken || null, + }); +} + +// Returns { ok: true, transport } or { ok: false, status, reason }. +// A /messages POST must (a) name a live session and (b) carry the SAME +// caller identity that opened it. Comparing the agent id (not just "is +// authenticated") is what stops agent B from driving agent A's session; the +// token is compared too so two tokens mapped to the same agent id are still +// treated as distinct sessions. +export function authorizeSseSession(sessions, sessionId, req, env = process.env) { + const entry = sessions.get(sessionId); + if (!entry) return { ok: false, status: 400, reason: 'unknown-session' }; + if (!requireAuthEnabled(env)) return { ok: true, transport: entry.transport }; + if (!req.callerAgentId) return { ok: false, status: 401, reason: 'no-credential' }; + if (entry.agentId !== req.callerAgentId || entry.token !== req.callerToken) { + return { ok: false, status: 403, reason: 'session-caller-mismatch' }; + } + return { ok: true, transport: entry.transport }; +} diff --git a/agap-mcp/src/server.js b/agap-mcp/src/server.js index 40f6a65..6375e14 100644 --- a/agap-mcp/src/server.js +++ b/agap-mcp/src/server.js @@ -12,8 +12,10 @@ import { initHA, haGetState, haListEntities, haCallService, haGetHistory } from import { initZabbix, zabbixGetProblems, zabbixGetHosts, zabbixGetItems, zabbixGetTriggers } from './zabbix.js'; import { initRadicale, radicaleListCalendars, radicaleListEvents, radicaleGetEvent, radicaleCreateCalendar, radicaleDeleteCalendar, radicalePutEvent, radicaleDeleteEvent, radicaleMoveEvent } from './radicale.js'; import { initTodoist, todoistListTasks, todoistListProjects, todoistCreateTask, todoistUpdateTask, todoistCompleteTask } from './todoist.js'; +import { todoistCaptureIdea } from './capture.js'; import { initMediaWiki, wikiSearch, wikiRead, wikiEdit } from './mediawiki.js'; import { loadTokenMap, resolveCallerAgent, vaultAllowed, authHeaderToken } from './trust-gate.js'; +import { listenerAuth, assertListenerAuthConfig, requireAuthEnabled, bindSseSession, authorizeSseSession } from './listener-auth.js'; const PORT = parseInt(process.env.PORT || '3100'); @@ -28,6 +30,21 @@ const PORT = parseInt(process.env.PORT || '3100'); // (see DESIGN-a2a-agents.md v2.1 §5 — vault access = trusted only). const ENFORCE_VAULT_TRUST = process.env.AGAP_MCP_ENFORCE_VAULT_TRUST === '1'; const AGENT_TOKENS = loadTokenMap(); +const AGENT_TOKEN_COUNT = Object.keys(AGENT_TOKENS).length; + +// kb#182: ENFORCE=1 with an empty token map is fail-closed by design (see +// requireVaultAccess below) but that means it silently denies EVERY caller, +// including Adolf itself — a self-inflicted vault brick with no signal +// unless someone is watching the logs. Make that state loud on boot. +if (ENFORCE_VAULT_TRUST && AGENT_TOKEN_COUNT === 0) { + console.error( + '\n*** agap-mcp WARNING: AGAP_MCP_ENFORCE_VAULT_TRUST=1 but AGAP_MCP_AGENT_TOKENS ' + + 'is empty/unset. Every caller — including Adolf — will be denied vault access. ' + + 'This is fail-closed, not a crash: the process will keep serving non-vault tools, ' + + 'but ALL vw_* calls will error until AGAP_MCP_AGENT_TOKENS is populated with real ' + + 'per-agent bearer tokens (kb#147/kb#182). Check /health for tokenMapSize. ***\n' + ); +} function requireVaultAccess(callerAgentId) { if (!ENFORCE_VAULT_TRUST) return; // legacy behavior: unchanged until activated @@ -80,8 +97,19 @@ function err(e) { return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true }; } +// Track tool registrations for /health endpoint +let registeredToolCount = 0; + function createServer(callerAgentId = null) { const server = new McpServer({ name: 'agap-mcp', version: '1.0.0' }); + const serverToolCount = { count: 0 }; + + // Wrap server.tool() to count registrations + const originalTool = server.tool.bind(server); + server.tool = function(name, description, params, handler) { + serverToolCount.count++; + return originalTool(name, description, params, handler); + }; // --- Vaultwarden tools (kb#147: gated to trust_class >= trusted) --- server.tool('vw_get_password', 'Get password for a Vaultwarden item by name', { name: z.string() }, @@ -325,6 +353,17 @@ function createServer(callerAgentId = null) { try { return ok(await todoistCompleteTask({ id })); } catch (e) { return err(e); } }); + // kb#170: capture an idea/quick task with lightweight (encoder-only, no + // Kimi/gemma call) AI classification -- area/urgency/decompose-need -- + // then create it in Todoist tagged with the result. See capture.js header + // for why this tags with labels rather than reassigning projects. + server.tool('todoist_capture_idea', 'Capture a free-text idea: classify it (area: adolf/welfare/дом/семья/здоровье, urgency, whether it needs Kanboard decomposition) using local bge-m3 embeddings, then create it as a labelled Todoist task. No LLM call.', { + text: z.string().describe('The idea, in free text (Russian or English).'), + project_id: z.string().optional().describe('Force a specific Todoist project id; otherwise auto-routed only for an unambiguous семья match, else Inbox.'), + }, async ({ text, project_id }) => { + try { return ok(await todoistCaptureIdea({ text, project_id }, { listProjects: todoistListProjects })); } catch (e) { return err(e); } + }); + // --- MediaWiki (family wiki / РодоВики) tools --- server.tool('wiki_search', 'Search the family wiki (РодоВики) for pages matching a query. Returns title + snippet.', { query: z.string().describe('Search text, e.g. a person\'s name or event.'), @@ -347,6 +386,10 @@ function createServer(callerAgentId = null) { try { return ok(await wikiEdit(title, text, summary)); } catch (e) { return err(e); } }); + // Store tool count on the server for /health endpoint to access + server._toolCount = serverToolCount.count; + registeredToolCount = serverToolCount.count; + return server; } @@ -354,12 +397,24 @@ function createServer(callerAgentId = null) { const app = express(); app.use(express.json()); +// kb#180: authenticate the LISTENER, not just vault tools. Mounted before +// every route below, so /mcp, /sse, /messages and /capture-idea all require +// a bearer token resolving to a known agent (/health stays open — see +// listener-auth.js PUBLIC_PATHS). This middleware also populates +// req.callerAgentId, which replaces the per-route resolveCallerAgent() call +// the /mcp and /sse handlers used to do inline; the kb#147 vault gate then +// consumes that same id, so one token per agent serves both gates. +app.use(listenerAuth(AGENT_TOKENS)); + +// sessionId -> { transport, agentId, token } (kb#180: the identity captured at +// the /sse handshake is pinned to the session so /messages can't be hijacked +// by anyone who merely learns/guesses the sessionId). const sseTransports = new Map(); // Streamable HTTP — stateless: fresh server per request, survives container restarts app.all('/mcp', async (req, res) => { try { - const callerAgentId = resolveCallerAgent(authHeaderToken(req), AGENT_TOKENS); + const callerAgentId = req.callerAgentId ?? resolveCallerAgent(authHeaderToken(req), AGENT_TOKENS); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); res.on('close', () => transport.close()); await createServer(callerAgentId).connect(transport); @@ -374,26 +429,85 @@ app.all('/mcp', async (req, res) => { // Legacy SSE — kept for backward compatibility app.get('/sse', async (req, res) => { - const callerAgentId = resolveCallerAgent(authHeaderToken(req), AGENT_TOKENS); + const callerAgentId = req.callerAgentId ?? resolveCallerAgent(authHeaderToken(req), AGENT_TOKENS); const transport = new SSEServerTransport('/messages', res); - sseTransports.set(transport.sessionId, transport); + bindSseSession(sseTransports, transport.sessionId, transport, req); res.on('close', () => sseTransports.delete(transport.sessionId)); await createServer(callerAgentId).connect(transport); }); app.post('/messages', async (req, res) => { - const transport = sseTransports.get(req.query.sessionId); - if (!transport) return res.status(400).send('Unknown session'); - await transport.handlePostMessage(req, res); + // kb#180: a live sessionId is no longer sufficient — the POST must carry the + // same caller identity that opened the session at /sse. + const auth = authorizeSseSession(sseTransports, req.query.sessionId, req); + if (!auth.ok) return res.status(auth.status).json({ error: `/messages rejected: ${auth.reason} (kb#180)` }); + await auth.transport.handlePostMessage(req, res); }); -app.get('/health', (_, res) => res.json({ status: 'ok', tools: 30, vaultTrustEnforced: ENFORCE_VAULT_TRUST })); +app.get('/health', (_, res) => res.json({ + status: 'ok', + tools: registeredToolCount, + vaultTrustEnforced: ENFORCE_VAULT_TRUST, + tokenMapSize: AGENT_TOKEN_COUNT, + // kb#182: surfaces the vault-brick footgun (ENFORCE=1 + no tokens = fail-closed + // for everyone, including Adolf) directly in /health instead of only at boot log. + vaultBrickRisk: ENFORCE_VAULT_TRUST && AGENT_TOKEN_COUNT === 0, + // kb#180: whether the listener itself (not just vw_*) requires a bearer + // token. false here means an unauthenticated JSON-RPC surface on the LAN. + listenerAuthEnabled: requireAuthEnabled(), +})); -init() - .then(() => { - app.listen(PORT, () => console.log(`agap-mcp listening on :${PORT}`)); - }) - .catch(e => { - console.error('Init failed:', e.message); +// kb#170: plain-REST twin of the todoist_capture_idea MCP tool, added for +// the todoist-capture-plugin native `/idea` command (openai/ +// todoist-capture-plugin) — a native-command handler doesn't speak MCP +// JSON-RPC, so it needs a plain JSON endpoint to reach the same +// classify+create logic (capture.js) the MCP tool already exposes to +// Adolf/Claude's model-driven path. No new trust boundary: same +// unauthenticated-on-localhost posture as every other route in this file +// today (see trust-gate.js header for the tracked gap). +app.post('/capture-idea', async (req, res) => { + try { + const { text, project_id } = req.body || {}; + const result = await todoistCaptureIdea({ text, project_id }, { listProjects: todoistListProjects }); + res.json(result); + } catch (e) { + res.status(400).json({ error: e.message }); + } +}); + +// kb#179: guard the real init()+listen() side effects so this module can be +// `import`-ed by tests (trust-gate-http.test.mjs) to exercise the real +// createServer()/requireVaultAccess()/app on a throwaway port WITHOUT +// touching Vaultwarden/Gitea/HA/Zabbix or the live :3100 container. Only run +// the side effects when server.js is executed directly (`node src/server.js` +// / the production container entrypoint), never on import. +const isMainModule = process.argv[1] && import.meta.url === `file://${process.argv[1]}`; +if (isMainModule) { + // kb#180: refuse to boot into an "authenticated but nobody can authenticate" + // state (auth on + empty token map). Crashing here makes a missing + // AGAP_MCP_AGENT_TOKENS look like a broken restart instead of a silently + // dead integration. Only reached when run directly, never on import. + try { + assertListenerAuthConfig(AGENT_TOKENS); + } catch (e) { + console.error(`agap-mcp refusing to start: ${e.message}`); process.exit(1); - }); + } + init() + .then(() => { + app.listen(PORT, () => console.log(`agap-mcp listening on :${PORT}`)); + }) + .catch(e => { + console.error('Init failed:', e.message); + process.exit(1); + }); +} + +// --- kb#179 test-only exports ------------------------------------------- +// Exposes the exact functions/objects the production /mcp route uses so +// integration tests can boot the real enforcement path (ENFORCE=1 + a +// synthetic token map/registry) over real HTTP, instead of re-implementing +// the gate inline. Importing these does not start the server or call init(). +// kb#180 additionally exports the live sse session map so the HTTP tests can +// assert /messages session-binding without reaching into module internals. +export { app, createServer, requireVaultAccess, ENFORCE_VAULT_TRUST, AGENT_TOKENS, sseTransports }; diff --git a/agap-mcp/src/trust-gate-http.test.mjs b/agap-mcp/src/trust-gate-http.test.mjs index 9fa4172..bb7974c 100644 --- a/agap-mcp/src/trust-gate-http.test.mjs +++ b/agap-mcp/src/trust-gate-http.test.mjs @@ -1,24 +1,48 @@ -// kb#147 HTTP-layer proof, run with: node src/trust-gate-http.test.mjs +// kb#179 HTTP-layer proof: exercises the REAL server.js /mcp handler, not a +// reimplementation of it. Run with: node src/trust-gate-http.test.mjs // -// Proves the Authorization-header -> agent-id -> trust-rank path end to end -// over real HTTP, WITHOUT touching the live agap-mcp container (:3100), -// LiteLLM, or Vaultwarden: this spins up a throwaway express app on an -// ephemeral local port using the exact same trust-gate.js functions -// server.js imports, with a synthetic registry + token map (no real bw -// session, no real credentials). It exercises authHeaderToken() (the bit -// trust-gate.test.mjs's pure unit tests can't reach, since it needs a real -// `req` object) on top of the already-unit-tested trustRankOf/vaultAllowed. +// kb#147's original version of this file re-implemented the gate inline +// (its own express app + its own copy of the "if vault tool and not +// allowed" check). That proved trust-gate.js's exported functions compose +// correctly, but never proved the shipped server.js actually wires +// requireVaultAccess() into every vw_* tool -- a vw_* tool registered +// without the gate would still pass that test. +// +// This version instead: +// 1. Sets AGAP_MCP_ENFORCE_VAULT_TRUST=1 and a synthetic +// AGAP_MCP_AGENT_TOKENS map BEFORE importing server.js (both are read +// once at module-load time), then dynamically imports server.js so it +// picks up ENFORCE=1 with a harness-only token map -- never the live +// container's tokens. +// 2. Overrides trust-gate's registry cache with a synthetic registry (no +// real agent-registry.yaml read) via _resetRegistryCacheForTests -- +// the exact test hook trust-gate.js already exports for this purpose. +// 3. Boots server.js's real `app` (its actual app.all('/mcp', ...) +// handler, its real createServer()/requireVaultAccess()) on an +// ephemeral local port, and drives it over real HTTP using the MCP +// SDK's own Client + StreamableHTTPClientTransport -- real +// initialize + tools/call JSON-RPC round trips, not raw fetch(). +// 4. Does NOT call init() (never touches Vaultwarden/Gitea/HA/Zabbix) and +// never touches the live :3100 container -- server.js's init()+ +// app.listen() side effects are guarded behind an isMainModule check +// specifically so this file can import the module safely (kb#179). +// +// Because init() never runs, the underlying vw* functions (vaultwarden.js) +// are never given a bw session. For a TRUSTED caller the gate must let the +// call through to that downstream code -- which then fails for its own +// unrelated reason (no bw session) -- so "allowed" is asserted as "did NOT +// fail with the gate's specific denial message", not "the vault call +// succeeded". That's deliberate: it proves requireVaultAccess() did not +// block the call, without shelling out to a real `bw` session anywhere in +// this test. import assert from 'node:assert/strict'; -import express from 'express'; -import { - loadTokenMap, - resolveCallerAgent, - vaultAllowed, - authHeaderToken, - isVaultTool, - _resetRegistryCacheForTests, -} from './trust-gate.js'; + +process.env.AGAP_MCP_ENFORCE_VAULT_TRUST = '1'; +process.env.AGAP_MCP_AGENT_TOKENS = JSON.stringify({ + 'tok-adolf-e2e-test': 'adolf', + 'tok-torgash-e2e-test': 'torgash', +}); const registry = { trust_classes: { @@ -31,39 +55,102 @@ const registry = { { id: 'torgash', trust_class: 'sandboxed' }, ], }; + +const { _resetRegistryCacheForTests } = await import('./trust-gate.js'); _resetRegistryCacheForTests(registry); -const tokenMap = loadTokenMap(JSON.stringify({ - 'tok-adolf-e2e-test': 'adolf', - 'tok-torgash-e2e-test': 'torgash', -})); +const { app, createServer, requireVaultAccess, ENFORCE_VAULT_TRUST, AGENT_TOKENS, sseTransports } = await import('./server.js'); +const { + listenerAuth, + assertListenerAuthConfig, + ListenerAuthConfigError, + requireAuthEnabled, + bindSseSession, + authorizeSseSession, +} = await import('./listener-auth.js'); -// A minimal stand-in for server.js's app.all('/mcp', ...) handler: resolve -// the caller from the Authorization header, then simulate a vw_get_password -// tool call gated the same way requireVaultAccess() gates it in server.js. -const app = express(); -app.post('/mcp', (req, res) => { - const callerAgentId = resolveCallerAgent(authHeaderToken(req), tokenMap); - const toolName = req.body?.tool || 'vw_get_password'; - if (isVaultTool(toolName) && !vaultAllowed(callerAgentId, registry)) { - return res.status(200).json({ isError: true, error: `vault access denied for caller=${callerAgentId || '(none)'}` }); +assert.equal(ENFORCE_VAULT_TRUST, true, 'sanity: server.js must have picked up AGAP_MCP_ENFORCE_VAULT_TRUST=1 at import time'); +assert.equal(Object.keys(AGENT_TOKENS).length, 2, 'sanity: server.js must have picked up the synthetic AGAP_MCP_AGENT_TOKENS'); + +const VW_TOOLS = ['vw_get_password', 'vw_get_item', 'vw_list_items', 'vw_create_login', 'vw_update_password']; +const DENIED_RE = /vault access denied/; + +// Bind explicitly to 127.0.0.1, with retries: `app.listen(0)` binds the IPv6 +// wildcard `::` on this host and intermittently fails EADDRINUSE under +// ephemeral-port pressure, which made this harness flaky (~2 runs in 3) +// regardless of what it asserts. Loopback-only is also the right posture for +// a test that deliberately probes an unauthenticated endpoint. +async function listenOnFreeLoopbackPort(attempts = 10) { + for (let i = 0; i < attempts; i++) { + const s = app.listen(0, '127.0.0.1'); + const outcome = await new Promise(resolve => { + s.once('listening', () => resolve('ok')); + s.once('error', e => resolve(e)); + }); + if (outcome === 'ok') return s; + if (outcome.code !== 'EADDRINUSE') throw outcome; } - return res.status(200).json({ isError: false, caller: callerAgentId }); -}); + throw new Error('could not bind a free loopback port after multiple attempts'); +} -const server = app.listen(0); +const server = await listenOnFreeLoopbackPort(); const port = server.address().port; +const baseUrl = `http://127.0.0.1:${port}/mcp`; -async function post(token) { - const res = await fetch(`http://127.0.0.1:${port}/mcp`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ tool: 'vw_get_password' }), - }); - return res.json(); +const { Client } = await import('@modelcontextprotocol/sdk/client/index.js'); +const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/sdk/client/streamableHttp.js'); + +// Minimal argument stubs matching each tool's zod schema -- the gate must +// fire before any of these are used for real (requireVaultAccess is the +// first statement in every vw_* handler in server.js), so their exact +// values don't matter for the deny path. +const ARGS = { + vw_get_password: { name: 'probe' }, + vw_get_item: { name: 'probe' }, + vw_list_items: {}, + vw_create_login: { name: 'probe', password: 'x' }, + vw_update_password: { name: 'probe', password: 'x' }, +}; + +// callToolAsCaller: connects a fresh MCP client to the real /mcp route with +// the given bearer token (or none), calls `tool`, and returns the tool +// result. `undefined`/omitted mimics resolveCallerAgent's "no header at +// all" path; a token string that isn't in AGENT_TOKENS mimics "unknown +// token". +// +// kb#180 note: since the listener itself now requires auth, an unauthenticated +// or unknown-token client is rejected at the HTTP layer (401) during +// connect() — before any tool handler runs. That is a STRICTER outcome than +// the tool-level "vault access denied" this file originally asserted for +// those two cases, so those checks now assert the 401 instead. The +// authenticated-but-sandboxed (torgash) case is what still proves the kb#147 +// vault gate itself, and the trusted (adolf) case still proves the gate lets +// a trusted caller through. +const AUTH_REJECTED = Symbol('listener-auth-rejected'); + +async function callToolAsCaller(token, tool) { + const requestInit = token ? { headers: { Authorization: `Bearer ${token}` } } : {}; + const transport = new StreamableHTTPClientTransport(new URL(baseUrl), { requestInit }); + const client = new Client({ name: 'kb179-test-client', version: '1.0.0' }); + try { + await client.connect(transport); + } catch (e) { + if (/401|unauthenticated/i.test(e.message)) return AUTH_REJECTED; + throw e; + } + try { + return await client.callTool({ name: tool, arguments: ARGS[tool] }); + } catch (e) { + if (/401|unauthenticated/i.test(e.message)) return AUTH_REJECTED; + throw e; + } finally { + await client.close().catch(() => {}); + } +} + +function toolErrorText(result) { + const block = result?.content?.find(c => c.type === 'text'); + return block?.text || ''; } let passed = 0; @@ -74,26 +161,199 @@ async function check(label, fn) { } try { - await check('trusted agent (adolf) bearer token -> vw_get_password allowed over real HTTP', async () => { - const body = await post('tok-adolf-e2e-test'); - assert.equal(body.isError, false); - assert.equal(body.caller, 'adolf'); + for (const tool of VW_TOOLS) { + await check(`${tool}: no Authorization header -> DENIED over real HTTP (kb#180: 401 at the listener)`, async () => { + const result = await callToolAsCaller(null, tool); + assert.equal(result, AUTH_REJECTED, 'expected the listener to reject an unauthenticated MCP client'); + }); + + await check(`${tool}: unknown/never-issued token -> DENIED over real HTTP (kb#180: 401 at the listener)`, async () => { + const result = await callToolAsCaller('this-token-was-never-issued', tool); + assert.equal(result, AUTH_REJECTED, 'expected the listener to reject an unknown bearer token'); + }); + + await check(`${tool}: sandboxed agent (torgash) token -> DENIED over real HTTP`, async () => { + const result = await callToolAsCaller('tok-torgash-e2e-test', tool); + assert.equal(result.isError, true); + assert.match(toolErrorText(result), DENIED_RE); + }); + + await check(`${tool}: trusted agent (adolf) token -> gate ALLOWS (not blocked by requireVaultAccess) over real HTTP`, async () => { + const result = await callToolAsCaller('tok-adolf-e2e-test', tool); + // The gate must not be what blocks this call. Downstream vaultwarden.js + // has no bw session here (init() deliberately never ran), so the call + // may still fail -- just not with the gate's denial message. + assert.doesNotMatch(toolErrorText(result), DENIED_RE); + }); + } + + // --- Guard test (kb#179 acceptance bar #2) --------------------------- + // Fails if any vw_* tool is ever registered on createServer() without + // calling requireVaultAccess(). Rather than re-deriving this from source + // text (fragile to refactors), it drives the real registered handler for + // every tool name starting with "vw_" as an untrusted/denied caller and + // asserts the gate's specific denial message comes back. A vw_* tool + // that forgot to call requireVaultAccess would either succeed or throw a + // different (non-gate) error here, and this test would catch it. + await check('guard: every registered vw_* tool enforces requireVaultAccess()', async () => { + // A caller id that resolves to no agent in the (synthetic) registry -- + // trustRankOf() returns -1 for it, so vaultAllowed() must be false and + // requireVaultAccess() must throw for every vw_* tool. + const probeServer = createServer('this-agent-id-does-not-exist-in-registry'); + // McpServer keeps its registrations on `._registeredTools` (name -> + // { handler, ... }); read the real registry createServer() just + // populated rather than re-deriving tool names by hand, so a future + // vw_* tool is covered automatically. + const registeredTools = probeServer._registeredTools; + assert.ok(registeredTools && Object.keys(registeredTools).length > 0, + 'expected createServer() to have registered tools onto the McpServer instance'); + + const vwToolNames = Object.keys(registeredTools).filter(n => n.startsWith('vw_')); + assert.ok(vwToolNames.length >= VW_TOOLS.length, `expected at least ${VW_TOOLS.length} vw_* tools registered, found: ${vwToolNames.join(', ')}`); + + for (const name of vwToolNames) { + const handler = registeredTools[name].handler; + assert.ok(typeof handler === 'function', `could not locate callable handler for ${name}`); + const result = await handler(ARGS[name] || {}, {}); + assert.equal(result.isError, true, `${name}: expected denial for an unregistered agent id, got success -- is requireVaultAccess() missing?`); + assert.match(toolErrorText(result), DENIED_RE, `${name}: expected the gate's denial message, got: ${toolErrorText(result)} -- is requireVaultAccess() missing or not the first check?`); + } }); - await check('sandboxed agent (torgash) bearer token -> vw_get_password DENIED over real HTTP', async () => { - const body = await post('tok-torgash-e2e-test'); - assert.equal(body.isError, true); - assert.match(body.error, /vault access denied/); + // --- kb#180: the LISTENER is authenticated, not just vault tools -------- + const base = `http://127.0.0.1:${port}`; + const authed = { Authorization: 'Bearer tok-torgash-e2e-test' }; // sandboxed but *known* + + await check('kb#180: unauthenticated POST /mcp -> 401 (raw curl-equivalent)', async () => { + const res = await fetch(`${base}/mcp`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }), + }); + assert.equal(res.status, 401); + assert.equal(res.headers.get('www-authenticate'), 'Bearer realm="agap-mcp"'); + const body = await res.json(); + assert.equal(body.jsonrpc, '2.0', 'MCP routes must answer with a JSON-RPC error envelope'); + assert.match(body.error.message, /unauthenticated/); }); - await check('no Authorization header at all -> vw_get_password DENIED over real HTTP', async () => { - const body = await post(null); - assert.equal(body.isError, true); + await check('kb#180: unauthenticated POST /capture-idea -> 401 (no Todoist write)', async () => { + const res = await fetch(`${base}/capture-idea`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: 'unauthenticated probe — must never reach Todoist' }), + }); + assert.equal(res.status, 401); + const body = await res.json(); + assert.match(body.error, /unauthenticated/); }); - await check('garbage/unknown token -> vw_get_password DENIED over real HTTP', async () => { - const body = await post('this-token-was-never-issued'); - assert.equal(body.isError, true); + await check('kb#180: unknown token on /capture-idea -> 401', async () => { + const res = await fetch(`${base}/capture-idea`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer nope' }, + body: JSON.stringify({ text: 'probe' }), + }); + assert.equal(res.status, 401); + }); + + await check('kb#180: unauthenticated GET /sse -> 401', async () => { + const res = await fetch(`${base}/sse`, { headers: { Accept: 'text/event-stream' } }); + assert.equal(res.status, 401); + await res.arrayBuffer(); + }); + + await check('kb#180: unauthenticated POST /messages -> 401 (was: any sessionId accepted)', async () => { + const res = await fetch(`${base}/messages?sessionId=whatever`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), + }); + assert.equal(res.status, 401); + }); + + await check('kb#180: /health stays open and reports listenerAuthEnabled=true', async () => { + const res = await fetch(`${base}/health`); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.listenerAuthEnabled, true); + }); + + await check('kb#180: an authenticated (known-token) caller still reaches the tool surface', async () => { + const res = await fetch(`${base}/mcp`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', ...authed }, + body: JSON.stringify({ + jsonrpc: '2.0', id: 1, method: 'initialize', + params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '1' } }, + }), + }); + assert.notEqual(res.status, 401, 'a known token must not be rejected by the listener gate'); + assert.ok(res.status < 500, `expected the request to be served, got ${res.status}`); + await res.arrayBuffer(); + }); + + // --- kb#180: /messages must match its /sse handshake identity ---------- + await check('kb#180: /messages rejects a session opened by a DIFFERENT agent (hijack)', () => { + const sessions = new Map(); + bindSseSession(sessions, 'sess-1', { id: 'transport-a' }, + { callerAgentId: 'adolf', callerToken: 'tok-adolf-e2e-test' }); + + const sameCaller = authorizeSseSession(sessions, 'sess-1', + { callerAgentId: 'adolf', callerToken: 'tok-adolf-e2e-test' }); + assert.equal(sameCaller.ok, true, 'the agent that opened the session must keep using it'); + + const otherAgent = authorizeSseSession(sessions, 'sess-1', + { callerAgentId: 'torgash', callerToken: 'tok-torgash-e2e-test' }); + assert.equal(otherAgent.ok, false); + assert.equal(otherAgent.status, 403); + assert.equal(otherAgent.reason, 'session-caller-mismatch'); + + const noCreds = authorizeSseSession(sessions, 'sess-1', { callerAgentId: null, callerToken: null }); + assert.equal(noCreds.ok, false); + assert.equal(noCreds.status, 401); + + const unknownSession = authorizeSseSession(sessions, 'nope', + { callerAgentId: 'adolf', callerToken: 'tok-adolf-e2e-test' }); + assert.equal(unknownSession.ok, false); + assert.equal(unknownSession.status, 400); + }); + + await check('kb#180: the live server binds real /sse sessions to a caller identity', () => { + // sseTransports is the exact map the /sse and /messages routes use; its + // entries must be {transport, agentId, token}, not a bare transport. + assert.ok(sseTransports instanceof Map); + bindSseSession(sseTransports, 'probe-session', { probe: true }, + { callerAgentId: 'adolf', callerToken: 'tok-adolf-e2e-test' }); + const entry = sseTransports.get('probe-session'); + assert.equal(entry.agentId, 'adolf'); + assert.equal(entry.token, 'tok-adolf-e2e-test'); + sseTransports.delete('probe-session'); + }); + + // --- kb#180: boot-time config guard ------------------------------------ + await check('kb#180: auth on + empty token map = refuse to boot (not silent all-deny)', () => { + assert.throws(() => assertListenerAuthConfig({}, { }), ListenerAuthConfigError); + assert.throws(() => assertListenerAuthConfig(Object.create(null), { AGAP_MCP_REQUIRE_AUTH: '1' }), ListenerAuthConfigError); + // Explicit opt-out is allowed (warns, does not throw) — the rollback path. + assert.doesNotThrow(() => assertListenerAuthConfig({}, { AGAP_MCP_REQUIRE_AUTH: '0' })); + // Configured normally: fine. + assert.doesNotThrow(() => assertListenerAuthConfig({ t: 'adolf' }, {})); + }); + + await check('kb#180: AGAP_MCP_REQUIRE_AUTH=0 is the only way to get the old open listener', () => { + assert.equal(requireAuthEnabled({}), true, 'auth must default ON'); + assert.equal(requireAuthEnabled({ AGAP_MCP_REQUIRE_AUTH: '1' }), true); + assert.equal(requireAuthEnabled({ AGAP_MCP_REQUIRE_AUTH: '0' }), false); + // ...and with it off, an unauthenticated request passes through the + // middleware while still resolving an agent id when a token IS present. + const mw = listenerAuth({ 'tok-adolf-e2e-test': 'adolf' }, { AGAP_MCP_REQUIRE_AUTH: '0' }); + let nexted = false; + mw({ headers: {}, path: '/mcp' }, null, () => { nexted = true; }); + assert.equal(nexted, true); + const req = { headers: { authorization: 'Bearer tok-adolf-e2e-test' }, path: '/mcp' }; + mw(req, null, () => {}); + assert.equal(req.callerAgentId, 'adolf', 'vault gate must still see the caller id when listener auth is off'); }); console.log(`\n${passed} passed`); diff --git a/agap-mcp/src/trust-gate.js b/agap-mcp/src/trust-gate.js index bffdcb0..09a1a10 100644 --- a/agap-mcp/src/trust-gate.js +++ b/agap-mcp/src/trust-gate.js @@ -62,18 +62,30 @@ export function trustRankOf(agentId, registry = loadRegistry()) { // 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 {}; + if (!raw) return Object.create(null); try { const parsed = JSON.parse(raw); - return (parsed && typeof parsed === 'object') ? parsed : {}; + if (!parsed || typeof parsed !== 'object') return Object.create(null); + // Rebuild onto a null-proto object so a token literally named + // "__proto__"/"constructor"/"prototype" in AGAP_MCP_AGENT_TOKENS can + // never merge into Object.prototype instead of becoming an own key. + return Object.assign(Object.create(null), parsed); } catch (e) { console.error(`trust-gate: AGAP_MCP_AGENT_TOKENS is not valid JSON: ${e.message}`); - return {}; + return Object.create(null); } } export function resolveCallerAgent(bearerToken, tokenMap) { - if (!bearerToken) return null; + if (!bearerToken || !tokenMap) return null; + // Guard against prototype-key inputs: a bearer token of "__proto__", + // "constructor", or "prototype" must never resolve via the object's + // prototype chain (e.g. tokenMap['__proto__'] returning Object.prototype, + // which is truthy and would silently "authenticate" as a non-existent + // agent). Object.hasOwn only ever matches an actual own property that was + // set from AGAP_MCP_AGENT_TOKENS, so a __proto__ probe resolves to null + // explicitly regardless of the token map's shape (kb#182). + if (!Object.hasOwn(tokenMap, bearerToken)) return null; return tokenMap[bearerToken] || null; }