agap-mcp: authenticate the :3100 listener (kb#180), pin bw CLI, add capture/classifier

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 <token>` 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 04:40:51 +00:00
parent 5b649123a8
commit fc4e1c75ed
11 changed files with 1089 additions and 79 deletions

View File

@@ -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/

View File

@@ -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 <token>` 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

58
agap-mcp/src/capture.js Normal file
View File

@@ -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 };
}

View File

@@ -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`);

228
agap-mcp/src/classifier.js Normal file
View File

@@ -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 };

View File

@@ -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);

View File

@@ -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' });

View File

@@ -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 {\"<bearer-token>\":\"<agent-id>\"} (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 <token> 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<sessionId, { transport, agentId, token }>
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 };
}

View File

@@ -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,21 +429,70 @@ 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(),
}));
// 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}`));
@@ -397,3 +501,13 @@ init()
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 };

View File

@@ -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;
}
throw new Error('could not bind a free loopback port after multiple attempts');
}
return res.status(200).json({ isError: false, caller: callerAgentId });
});
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('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/);
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('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(`${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('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(`${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?`);
}
});
// --- 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('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('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`);

View File

@@ -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;
}