Compare commits

...

2 Commits

Author SHA1 Message Date
0f9d83f3db agap-mcp: stop leaking BW master password via bw error messages
vaultwarden.js run() passed BW_PASSWORD as argv to `bw login`/`bw unlock`.
On any non-zero exit, execFileSync throws an Error whose message is
"Command failed: bw unlock <BW_PASSWORD> --raw", which propagated to
server.js's `console.error('Init failed:', e.message)` -> the master password
landed in container stdout / `docker logs` on every Vaultwarden init failure
(wrong password, server down, TLS reset). Empirically confirmed by a security
audit 2026-07-24.

run() now catches the exec error and re-throws with the argv stripped: only the
subcommand, exit code, and stderr survive, and BW_PASSWORD is scrubbed from
stderr defensively. Verified: a forced failure yields "bw unlock failed (exit 1)"
with no password substring.

Not yet active: the running agap-mcp container predates this file; a rebuild
(`docker compose build agap-mcp && docker compose up -d agap-mcp`) is needed to
deploy it. The live container is still vulnerable until then.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 06:07:51 +00:00
b548a8f345 agap-mcp: MediaWiki + Todoist tools, vault trust-gate, registry wiring
Commits a cluster of entangled agap-mcp / Adolf-tooling WIP that had accumulated
uncommitted in shared files (server.js, the three MCP-config layers). Bundled as
one commit because server.js interleaves all of it and cannot be cleanly split;
each stream is named here for the record. Authorized by alvis 2026-07-23.

- **kb#95 — family MediaWiki tools:** new src/mediawiki.js (wiki_search / wiki_read
  / wiki_edit, MediaWiki login->CSRF->edit flow, no new deps), registered in
  server.js and fetched from the family.alogins.net Vaultwarden login item.
  Proven standalone against family.alogins.net (search/read/edit, revid 1520 on a
  bot-userspace page). Wired into all three layers: openai/shared-mcp.json,
  adolf/openclaw.json, openai/agent-registry.yaml.

- **kb#147 — vault trust-gate (A2A-15), DORMANT:** new src/trust-gate.js (+ two
  test files), requireVaultAccess() around the vw_* tools, gated by
  AGAP_MCP_ENFORCE_VAULT_TRUST (docker-compose.yml, default 0). OFF by default —
  vw_* behaviour is byte-for-byte unchanged until an operator sets ENFORCE=1 and
  populates AGAP_MCP_AGENT_TOKENS from Vaultwarden. That activation is a separate
  human step; kb#147 remains escalated for human verification and is NOT verified
  by this commit. js-yaml added to read the registry. agent-registry.yaml mounted
  read-only as the trust-class source of truth.

- **Todoist tools:** new src/todoist.js (initTodoist + 6 todoist_* tools),
  registered in server.js, sourced from the TODOIST_TOKEN Vaultwarden item.

- **kanboard cutover cleanup:** removes src/kanboard.js and its imports — the
  kanboard_* slice moved to the standalone kanboard-mcp on 2026-07-06.

- **openai/validate_capability_grants.py:** cross-checks the registry against the
  live openclaw.json + shared-mcp.json layers; passes (exit 0).

No secrets committed: all tokens come from Vaultwarden via env/.env; the trust
gate's AGAP_MCP_AGENT_TOKENS defaults to `{}` (fail-closed). node_modules/ now
gitignored, package-lock.json tracked.

NOT YET ACTIVATED: agap-mcp has not been rebuilt and adolf-llm/adolf not
restarted, so the wiki/todoist tools are wired but not live. That restart is the
outstanding step on kb#95 (and stays a human/orchestrator action).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 06:48:07 +00:00
16 changed files with 2899 additions and 371 deletions

View File

@@ -122,10 +122,41 @@
// OpenClaw's documented CLI-native alias for transport: "streamable-http".
mcp: {
servers: {
// kb#144 (A2A-12): toolFilter.include is OpenClaw's OWN per-server
// tool-scoping mechanism (mcp.servers.*.toolFilter, zod-validated;
// applied in agent-bundle-mcp-materialize.js when THIS container's own
// tool bundle is built). CORRECTION (kb#144 second pass, 2026-07-22):
// this does NOT reach the model on Adolf's kimi backbone -- Kimi CLI
// (inside the separate adolf-llm container) reads its own
// project-root .mcp.json, seeded from openai/shared-mcp.json, and
// applies ITS OWN enabledTools/disabledTools (McpServerCommonFields,
// computeEnabledNames). Live wire.jsonl verification (restart + one
// real turn) proved OpenClaw's toolFilter alone left Kimi's actual
// tool counts unchanged. This block is still correct for OpenClaw's
// own MCP client surface -- see openai/shared-mcp.json for the layer
// that actually scopes what the model sees.
//
// Scoped to Adolf's CORE memory ops: recall/retain/reflect (the
// hooks below do IMPLICIT recall/retain automatically; these MCP
// tools cover explicit "remember this" / "what do you recall about
// X" turns) plus single-memory read/update and directive CRUD
// (standing instructions). Cuts 20 rarely-used/admin tools: the
// mental-model CRUD (7), document CRUD (3), operation-tracking (3),
// tags (1), bank admin (4), sync_retain, invalidate_memory -- none
// of which Adolf's Matrix persona drives turn-to-turn; reach the
// hindsight MCP directly (unscoped) for that admin work instead of
// paying for it on every Adolf turn. 29 tools -> 9.
hindsight: {
type: "http",
url: "http://hindsight:8888/mcp/adolf/",
toolFilter: {
include: ["recall", "retain", "reflect", "list_memories", "get_memory", "update_memory", "list_directives", "create_directive", "delete_directive"],
},
},
// Already minimal (5 tools -- message_send/cron_create/cron_list/
// nodes_invoke/browser_invoke, all core to Adolf's matrix-chat +
// cron-scheduling + browser capabilities) -- deliberately NO
// toolFilter here, not an oversight.
"openclaw-tools": {
type: "http",
url: "http://openclaw-tools:8020/mcp",
@@ -138,9 +169,18 @@
// instance on :3103 (app-wide jsonrpc token). Not part of the openai
// compose network, so reached via host.docker.internal (already
// extra_hosts-mapped for this container) rather than a service name.
// kb#144: scoped to task-triage/proactive-monitoring core --
// list/read/search/create/update/move/status/assign/comment/
// activity. Cuts subtask CRUD, task-link CRUD, tag CRUD, and
// destructive remove_task/remove_comment (9 tools) -- deep
// task-graph management is claude-coder's job (the actual task
// worker), not Adolf's lighter triage/reporting role. 23 -> 14.
kanboard: {
type: "http",
url: "http://host.docker.internal:3104/mcp",
toolFilter: {
include: ["kanboard_list_projects", "kanboard_get_project", "kanboard_list_tasks", "kanboard_my_tasks", "kanboard_get_task", "kanboard_search_tasks", "kanboard_list_users", "kanboard_project_activity", "kanboard_create_task", "kanboard_update_task", "kanboard_move_task", "kanboard_change_task_status", "kanboard_assign_task", "kanboard_add_comment"],
},
},
// marketplace-mcp (kb task #61) -- the SAME shared marketplace-mcp
// instance Claude Code and OpenWebUI use (single service, port 3101,
@@ -149,21 +189,48 @@
// shared bearer token (MARKETPLACE_MCP_TOKEN in Vaultwarden / this
// container's env, injected via openai/.env -> docker-compose.yml).
// Reached via host.docker.internal, same reasoning as kanboard above.
// kb#144: scoped to READ-ONLY discovery (find_best/search/product/
// recommendations/reviews/compare/status). Cuts the checkout
// surface -- add_to_cart, get_cart, login, open_vnc, save_session,
// submit_sms (6 tools) -- real-money/session-auth actions Adolf
// shouldn't take unattended from background Matrix chat. This
// whole server is also the FIRST candidate to move OUT to Torgash
// (kb#130's marketplace-analyst persona, not yet built) per
// design §2/§8; scoping now both cuts tokens and removes purchase
// risk in the meantime rather than waiting on Torgash to exist.
// 13 -> 7.
marketplace: {
type: "http",
url: "http://host.docker.internal:3101/mcp",
headers: {
Authorization: "Bearer ${MARKETPLACE_MCP_TOKEN}",
},
toolFilter: {
include: ["marketplace_find_best", "marketplace_search", "marketplace_get_product", "marketplace_get_recommendations", "marketplace_get_reviews", "marketplace_compare_prices", "marketplace_status"],
},
},
// agap-mcp (kb#64) -- the SAME shared agap-mcp instance Claude Code uses
// (network_mode: host, :3100, unauthenticated on localhost). Grants Adolf
// the same access as Claude: vault (vw_* for credential fetching) plus
// gitea/ha/zabbix/radicale. Reached via host.docker.internal like
// kanboard/marketplace above.
// kb#144: scoped to Adolf's proactive-auditor/personal-assistant
// core -- vault read/write (trusted, credential help), HA
// (smart-home monitoring), Zabbix (the literal "proactive
// auditor" job), calendar read/write (minus admin
// create/delete_calendar), Todoist. Cuts all 6 gitea_* tools
// (repo/wiki/issue management is claude-coder's infra-ops domain,
// not Adolf's Matrix persona) plus radicale_create_calendar/
// delete_calendar (rare calendar-admin ops) -- 8 tools. 32 -> 24.
// kb#95: added wiki_search/wiki_read/wiki_edit (family MediaWiki,
// family.alogins.net / РодоВики -- source of truth for relatives,
// dates, events) -- squarely Adolf's persona domain. 35 -> 27.
agap: {
type: "http",
url: "http://host.docker.internal:3100/mcp",
toolFilter: {
include: ["vw_get_password", "vw_get_item", "vw_list_items", "vw_create_login", "vw_update_password", "ha_get_state", "ha_list_entities", "ha_call_service", "ha_get_history", "zabbix_get_problems", "zabbix_get_hosts", "zabbix_get_items", "zabbix_get_triggers", "radicale_list_calendars", "radicale_list_events", "radicale_get_event", "radicale_put_event", "radicale_delete_event", "radicale_move_event", "todoist_list_tasks", "todoist_list_projects", "todoist_create_task", "todoist_update_task", "todoist_complete_task", "wiki_search", "wiki_read", "wiki_edit"],
},
},
},
},
@@ -198,6 +265,21 @@
"quota-command": {
enabled: true,
},
// Kimi quota footer (kb #85) — installed external plugin, bind-mounted
// read-only from openai/kimi-quota-footer-plugin (see that project's
// docker-compose.yml adolf.volumes) onto
// .openclaw/extensions/kimi-quota-footer. Appends a compact Kimi
// usage line to every outgoing reply via the reply_payload_sending
// hook (not a raw conversation hook, so no allowConversationAccess/
// allowPromptInjection opt-in needed), reusing the same LLM-free
// adolf-llm:8010/usage route as quota-command above. Verified this
// hook fires on Adolf's actual send path (sendDurableMessageBatch ->
// deliverOutboundPayloadsInternal) as long as channels.matrix.streaming
// stays unset/"off" as it is today — see the plugin's index.js header
// comment for the streaming caveat if that ever changes.
"kimi-quota-footer": {
enabled: true,
},
},
},
}

1
agap-mcp/.gitignore vendored
View File

@@ -1 +1,2 @@
.env
node_modules/

View File

@@ -21,5 +21,24 @@ services:
- ZABBIX_URL=http://192.168.1.4:81
- RADICALE_URL=http://localhost:5232
- RADICALE_USER=alvis
# kb#147 (A2A-15) — vault trust gate, OFF by default (0). Flipping this
# to 1 requires a container restart AND real values for
# AGENT_REGISTRY_PATH/AGAP_MCP_AGENT_TOKENS below to be populated first
# (see src/trust-gate.js) — that restart is the deliberate handover
# step this task does NOT perform (never restart the live agap-mcp
# service unattended). Until both are set, vw_* tools behave exactly
# as before this change.
- AGAP_MCP_ENFORCE_VAULT_TRUST=0
- AGENT_REGISTRY_PATH=/agent-registry.yaml
# JSON map {"<bearer-token>": "<agent-id>"}. Real per-agent tokens must
# be generated, stored in Vaultwarden (e.g. AGAP_MCP_TOKEN_ADOLF,
# AGAP_MCP_TOKEN_CLAUDE_CODER), and referenced here via .env — never
# 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:-{}}
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

1611
agap-mcp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"express": "^4.19.0",
"js-yaml": "^4.1.0",
"zod": "^3.23.0"
}
}

View File

@@ -1,215 +0,0 @@
const BASE = () => process.env.KANBOARD_URL || 'http://localhost:4800';
const BOT_USER = () => process.env.KANBOARD_BOT_USER || 'claude';
let _token = null;
let _botId = null;
export function initKanboard(token) {
_token = token;
console.log('Kanboard: ready');
}
function token() {
if (!_token) throw new Error('Kanboard not initialized');
return _token;
}
// JSON-RPC over the app-wide token (login "jsonrpc:<token>") — full access to all projects.
async function rpc(method, params = {}) {
const auth = Buffer.from(`jsonrpc:${token()}`).toString('base64');
const res = await fetch(`${BASE()}/jsonrpc.php`, {
method: 'POST',
headers: { Authorization: `Basic ${auth}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method, id: 1, params }),
});
if (!res.ok) throw new Error(`Kanboard ${method}: ${res.status} ${await res.text()}`);
const json = await res.json();
if (json.error) throw new Error(`Kanboard ${method}: ${json.error.message || JSON.stringify(json.error)}`);
return json.result;
}
// Resolve + cache the bot user's id (default author for writes, filter for "my tasks").
async function botUserId() {
if (_botId != null) return _botId;
const user = await rpc('getUserByName', { username: BOT_USER() });
if (!user || !user.id) throw new Error(`Bot user not found in Kanboard: ${BOT_USER()}`);
_botId = parseInt(user.id);
return _botId;
}
const STATUS = { open: 1, closed: 0 };
// --- Read / monitor ---
export async function kbListProjects() {
const projects = await rpc('getAllProjects');
return projects.map(p => ({
id: parseInt(p.id), name: p.name, is_active: parseInt(p.is_active),
is_private: parseInt(p.is_private), owner_id: parseInt(p.owner_id),
}));
}
export async function kbGetProject(projectId) {
const [project, columns, swimlanes] = await Promise.all([
rpc('getProjectById', { project_id: projectId }),
rpc('getColumns', { project_id: projectId }),
rpc('getAllSwimlanes', { project_id: projectId }),
]);
if (!project) throw new Error(`Project not found: ${projectId}`);
return {
id: parseInt(project.id), name: project.name, description: project.description,
is_active: parseInt(project.is_active), owner_id: parseInt(project.owner_id),
columns: columns.map(c => ({ id: parseInt(c.id), title: c.title, position: parseInt(c.position) })),
swimlanes: swimlanes.map(s => ({ id: parseInt(s.id), name: s.name, is_active: parseInt(s.is_active) })),
};
}
function compactTask(t) {
return {
id: parseInt(t.id), title: t.title, project_id: parseInt(t.project_id),
column_id: parseInt(t.column_id), swimlane_id: parseInt(t.swimlane_id),
owner_id: parseInt(t.owner_id), is_active: parseInt(t.is_active),
color_id: t.color_id, priority: parseInt(t.priority || 0),
date_due: parseInt(t.date_due) || 0, reference: t.reference || '',
};
}
export async function kbListTasks(projectId, status = 'open') {
let tasks = [];
if (status === 'all') {
const [open, closed] = await Promise.all([
rpc('getAllTasks', { project_id: projectId, status_id: STATUS.open }),
rpc('getAllTasks', { project_id: projectId, status_id: STATUS.closed }),
]);
tasks = [...open, ...closed];
} else {
tasks = await rpc('getAllTasks', { project_id: projectId, status_id: STATUS[status] ?? STATUS.open });
}
return tasks.map(compactTask);
}
// Tasks assigned to the bot across every project. status: open | closed | all
export async function kbMyTasks(status = 'open') {
const botId = await botUserId();
const projects = await rpc('getAllProjects');
const out = [];
for (const p of projects) {
const tasks = await kbListTasks(parseInt(p.id), status);
for (const t of tasks) {
if (t.owner_id === botId) out.push({ ...t, project_name: p.name });
}
}
return out;
}
export async function kbGetTask(taskId) {
const task = await rpc('getTask', { task_id: taskId });
if (!task) throw new Error(`Task not found: ${taskId}`);
const [subtasks, comments] = await Promise.all([
rpc('getAllSubtasks', { task_id: taskId }),
rpc('getAllComments', { task_id: taskId }),
]);
return {
task,
subtasks: (subtasks || []).map(s => ({
id: parseInt(s.id), title: s.title, status: parseInt(s.status),
status_name: s.status_name, user_id: parseInt(s.user_id) || 0, assignee: s.name || s.username || null,
})),
comments: (comments || []).map(c => ({
id: parseInt(c.id), user_id: parseInt(c.user_id), author: c.name || c.username,
date: parseInt(c.date_creation), content: c.comment,
})),
};
}
export async function kbSearchTasks(projectId, query) {
const tasks = await rpc('searchTasks', { project_id: projectId, query });
return (tasks || []).map(compactTask);
}
export async function kbListUsers() {
const users = await rpc('getAllUsers');
return users.map(u => ({
id: parseInt(u.id), username: u.username, name: u.name,
role: u.role, is_active: parseInt(u.is_active), email: u.email,
}));
}
export async function kbProjectActivity(projectId) {
return rpc('getProjectActivity', { project_id: projectId });
}
// --- Write (creation/comments authored by the bot user) ---
export async function kbCreateTask(params) {
const body = { ...params };
if (body.creator_id == null) body.creator_id = await botUserId();
const id = await rpc('createTask', body);
if (!id) throw new Error('createTask returned false (check required fields: title, project_id)');
return { task_id: parseInt(id) };
}
export async function kbUpdateTask(params) {
const ok = await rpc('updateTask', params);
return { updated: !!ok };
}
// Move a card. project_id and swimlane_id are resolved from the task when omitted.
export async function kbMoveTask({ task_id, column_id, position = 1, swimlane_id, project_id }) {
if (project_id == null || swimlane_id == null) {
const task = await rpc('getTask', { task_id });
if (!task) throw new Error(`Task not found: ${task_id}`);
if (project_id == null) project_id = parseInt(task.project_id);
if (swimlane_id == null) swimlane_id = parseInt(task.swimlane_id);
}
const ok = await rpc('moveTaskPosition', { project_id, task_id, column_id, position, swimlane_id });
return { moved: !!ok };
}
export async function kbChangeTaskStatus(taskId, action) {
const method = action === 'close' ? 'closeTask' : 'openTask';
const ok = await rpc(method, { task_id: taskId });
return { [action === 'close' ? 'closed' : 'opened']: !!ok };
}
// Assign a task to a user (username or numeric id).
export async function kbAssignTask(taskId, owner) {
let ownerId = owner;
if (typeof owner === 'string' && !/^\d+$/.test(owner)) {
const user = await rpc('getUserByName', { username: owner });
if (!user || !user.id) throw new Error(`User not found: ${owner}`);
ownerId = parseInt(user.id);
}
const ok = await rpc('updateTask', { id: taskId, owner_id: parseInt(ownerId) });
return { assigned: !!ok, owner_id: parseInt(ownerId) };
}
export async function kbAddComment(taskId, content, userId) {
const uid = userId != null ? userId : await botUserId();
const id = await rpc('createComment', { task_id: taskId, user_id: uid, content });
if (!id) throw new Error('createComment returned false');
return { comment_id: parseInt(id) };
}
export async function kbCreateSubtask(params) {
const body = { ...params };
if (body.user_id == null) body.user_id = await botUserId();
const id = await rpc('createSubtask', body);
if (!id) throw new Error('createSubtask returned false (required: task_id, title)');
return { subtask_id: parseInt(id) };
}
export async function kbUpdateSubtask(params) {
const ok = await rpc('updateSubtask', params);
return { updated: !!ok };
}
export async function kbRemoveTask(taskId) {
const ok = await rpc('removeTask', { task_id: taskId });
return { removed: !!ok };
}
export async function kbRemoveComment(commentId) {
const ok = await rpc('removeComment', { comment_id: commentId });
return { removed: !!ok };
}

160
agap-mcp/src/mediawiki.js Normal file
View File

@@ -0,0 +1,160 @@
// MediaWiki (РодоВики family wiki) tools for agap-mcp — kb#95.
// Target is family.alogins.net (МediaWiki), NOT Gitea. Follows the exact
// login->edit flow documented in the `rodowiki` skill: login token ->
// action=login -> session cookie -> CSRF token -> read/search/edit. Read
// operations also require login ($wgGroupPermissions['*']['read'] = false).
//
// Credentials: Vaultwarden org item "family.alogins.net" (username field
// holds the wiki account name "Claude", password field holds its password),
// fetched once at init like every other agap-mcp integration.
//
// Session handling: Node's fetch does not auto-manage cookies like curl's
// --cookie-jar, so a tiny module-level cookie jar (Map) is kept here and
// reused across calls -- this module is a singleton within the process, so
// one login session serves every MCP request for the process lifetime.
// withAuth() retries once on an auth-shaped failure (expired session) by
// forcing a fresh login, mirroring how a human re-running the skill's shell
// snippet would just log in again.
const BASE = () => (process.env.MEDIAWIKI_URL || 'http://localhost:8099').replace(/\/$/, '');
let _username = null;
let _password = null;
let _cookies = new Map();
let _loggedIn = false;
export function initMediaWiki(username, password) {
_username = username;
_password = password;
console.log('MediaWiki: ready');
}
function cookieHeader() {
return [..._cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
}
function storeCookies(res) {
const setCookies =
typeof res.headers.getSetCookie === 'function'
? res.headers.getSetCookie()
: res.headers.get('set-cookie')
? [res.headers.get('set-cookie')]
: [];
for (const sc of setCookies) {
const pair = sc.split(';')[0];
const idx = pair.indexOf('=');
if (idx > -1) _cookies.set(pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
}
}
async function api(params, { method = 'GET' } = {}) {
const url = new URL(`${BASE()}/api.php`);
const headers = { Cookie: cookieHeader() };
let body;
if (method === 'GET') {
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
} else {
body = new URLSearchParams(params);
headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
const res = await fetch(url, { method, headers, body });
storeCookies(res);
const data = await res.json();
if (data.error) throw new Error(`MediaWiki API error: ${data.error.info || JSON.stringify(data.error)}`);
return data;
}
async function login() {
if (!_username || !_password) throw new Error('MediaWiki not initialized');
const tokenData = await api({ action: 'query', meta: 'tokens', type: 'login', format: 'json' });
const logintoken = tokenData.query.tokens.logintoken;
const loginData = await api(
{ action: 'login', lgname: _username, lgpassword: _password, lgtoken: logintoken, format: 'json' },
{ method: 'POST' }
);
if (loginData.login?.result !== 'Success') {
throw new Error(`MediaWiki login failed: ${loginData.login?.result || JSON.stringify(loginData)}`);
}
}
async function withAuth(fn) {
if (!_loggedIn) {
await login();
_loggedIn = true;
}
try {
return await fn();
} catch (e) {
// Session likely expired mid-process: force a fresh login and retry once.
if (/notloggedin|readapidenied|permissiondenied|mustbeloggedin/i.test(e.message)) {
_loggedIn = false;
await login();
_loggedIn = true;
return await fn();
}
throw e;
}
}
function stripHtml(s) {
return (s || '').replace(/<[^>]+>/g, '');
}
export async function wikiSearch(query, limit = 20) {
if (!query || !query.trim()) throw new Error('query is required');
return withAuth(async () => {
const data = await api({
action: 'query',
list: 'search',
srsearch: query,
srlimit: String(limit),
format: 'json',
});
return data.query.search.map((p) => ({
title: p.title,
snippet: stripHtml(p.snippet),
wordcount: p.wordcount,
}));
});
}
export async function wikiRead(title) {
if (!title || !title.trim()) throw new Error('title is required');
return withAuth(async () => {
const data = await api({
action: 'query',
titles: title,
prop: 'revisions',
rvprop: 'content',
rvslots: 'main',
format: 'json',
});
const page = Object.values(data.query.pages)[0];
if (page.missing !== undefined) throw new Error(`Page not found: ${title}`);
return page.revisions?.[0]?.slots?.main?.['*'] ?? '';
});
}
export async function wikiEdit(title, text, summary) {
if (!title || !title.trim()) throw new Error('title is required');
if (text === undefined || text === null) throw new Error('text is required');
return withAuth(async () => {
const tokenData = await api({ action: 'query', meta: 'tokens', format: 'json' });
const csrftoken = tokenData.query.tokens.csrftoken;
const editData = await api(
{
action: 'edit',
title,
text,
summary: summary || `Update ${title}`,
token: csrftoken,
format: 'json',
},
{ method: 'POST' }
);
if (editData.edit?.result !== 'Success') {
throw new Error(`MediaWiki edit failed: ${JSON.stringify(editData.edit || editData)}`);
}
return { title, result: editData.edit.result, newrevid: editData.edit.newrevid, oldrevid: editData.edit.oldrevid };
});
}

View File

@@ -11,10 +11,34 @@ import { initGitea, giteaListRepos, giteaReadFile, giteaWikiList, giteaWikiRead,
import { initHA, haGetState, haListEntities, haCallService, haGetHistory } from './homeassistant.js';
import { initZabbix, zabbixGetProblems, zabbixGetHosts, zabbixGetItems, zabbixGetTriggers } from './zabbix.js';
import { initRadicale, radicaleListCalendars, radicaleListEvents, radicaleGetEvent, radicaleCreateCalendar, radicaleDeleteCalendar, radicalePutEvent, radicaleDeleteEvent, radicaleMoveEvent } from './radicale.js';
import { initKanboard, kbListProjects, kbGetProject, kbListTasks, kbMyTasks, kbGetTask, kbSearchTasks, kbListUsers, kbProjectActivity, kbCreateTask, kbUpdateTask, kbMoveTask, kbChangeTaskStatus, kbAssignTask, kbAddComment, kbCreateSubtask, kbUpdateSubtask, kbRemoveTask, kbRemoveComment } from './kanboard.js';
import { initTodoist, todoistListTasks, todoistListProjects, todoistCreateTask, todoistUpdateTask, todoistCompleteTask } from './todoist.js';
import { initMediaWiki, wikiSearch, wikiRead, wikiEdit } from './mediawiki.js';
import { loadTokenMap, resolveCallerAgent, vaultAllowed, authHeaderToken } from './trust-gate.js';
const PORT = parseInt(process.env.PORT || '3100');
// --- kb#147: vault trust gate (A2A-15) ---------------------------------
// OFF by default (ENFORCE_VAULT_TRUST=false) so merging this file changes
// NOTHING about the live service's behavior until an operator deliberately
// sets AGAP_MCP_ENFORCE_VAULT_TRUST=1 AND populates AGAP_MCP_AGENT_TOKENS
// with real per-agent bearer tokens (stored in Vaultwarden, injected via
// this container's .env — never committed to git). That two-step
// activation is the kb#147 handover: it requires a docker-compose env
// change + container restart, which this task deliberately does not do
// (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();
function requireVaultAccess(callerAgentId) {
if (!ENFORCE_VAULT_TRUST) return; // legacy behavior: unchanged until activated
if (!vaultAllowed(callerAgentId)) {
throw new Error(
`vault access denied: caller ${callerAgentId ? `'${callerAgentId}'` : '(unauthenticated)'} ` +
`is not trust_class >= trusted (kb#147, DESIGN-a2a-agents.md §5)`
);
}
}
// --- Init services ---
async function init() {
await initVaultwarden();
@@ -31,13 +55,19 @@ async function init() {
const ha_token = orgToken('HA_TOKEN');
const zabbix_token = orgToken('ZABBIX_TOKEN');
const radicale_password = orgToken('RADICALE_PASSWORD');
const kanboard_token = orgToken('KANBOARD_TOKEN');
const todoist_token = orgToken('TODOIST_TOKEN');
// family.alogins.net (РодоВики MediaWiki) — a login item, not a bare
// token: username + password both needed for the login flow.
const wikiItem = orgItems.find(i => i.name === 'family.alogins.net');
if (!wikiItem) throw new Error('Token not found in org: family.alogins.net');
initGitea(gitea_token);
initHA(ha_token);
initZabbix(zabbix_token);
initRadicale(radicale_password);
initKanboard(kanboard_token);
initTodoist(todoist_token);
initMediaWiki(wikiItem.login?.username, wikiItem.login?.password);
}
@@ -50,18 +80,19 @@ function err(e) {
return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
}
function createServer() {
function createServer(callerAgentId = null) {
const server = new McpServer({ name: 'agap-mcp', version: '1.0.0' });
// --- Vaultwarden tools ---
// --- 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() },
async ({ name }) => {
try { return ok(vwGetPassword(name)); } catch (e) { return err(e); }
try { requireVaultAccess(callerAgentId); return ok(vwGetPassword(name)); } catch (e) { return err(e); }
});
server.tool('vw_get_item', 'Get full details of a Vaultwarden item (name, username, password, url, notes)', { name: z.string() },
async ({ name }) => {
try {
requireVaultAccess(callerAgentId);
const item = vwGetItem(name);
return ok({ name: item.name, username: item.login?.username, password: item.login?.password, url: item.login?.uris?.[0]?.uri, notes: item.notes });
} catch (e) { return err(e); }
@@ -72,6 +103,7 @@ function createServer() {
org: z.boolean().optional(),
}, async ({ search, org }) => {
try {
requireVaultAccess(callerAgentId);
const items = org ? vwListOrgItems(search) : vwListItems(search);
return ok(items.map(i => ({ id: i.id, name: i.name, username: i.login?.username, url: i.login?.uris?.[0]?.uri })));
} catch (e) { return err(e); }
@@ -84,14 +116,14 @@ function createServer() {
url: z.string().optional(),
notes: z.string().optional(),
}, async (args) => {
try { return ok(vwCreateLogin(args)); } catch (e) { return err(e); }
try { requireVaultAccess(callerAgentId); return ok(vwCreateLogin(args)); } catch (e) { return err(e); }
});
server.tool('vw_update_password', 'Update the password of an existing Vaultwarden item', {
name: z.string().describe('Item name or ID'),
password: z.string(),
}, async ({ name, password }) => {
try { return ok(vwUpdatePassword(name, password)); } catch (e) { return err(e); }
try { requireVaultAccess(callerAgentId); return ok(vwUpdatePassword(name, password)); } catch (e) { return err(e); }
});
// --- Gitea tools ---
@@ -253,144 +285,67 @@ function createServer() {
try { return ok(await radicaleMoveEvent({ fromCalendar: from_calendar, toCalendar: to_calendar, filename, user })); } catch (e) { return err(e); }
});
// --- Kanboard tools ---
server.tool('kanboard_list_projects', 'List all Kanboard projects (id, name, active, owner)', {},
async () => {
try { return ok(await kbListProjects()); } catch (e) { return err(e); }
// --- Todoist tools (Todoist API v1) ---
server.tool('todoist_list_tasks', 'List active Todoist tasks. Use `query` for a Todoist filter (e.g. "today", "overdue", "#Work & p1"), or `project_id` to scope to one project. Returns compact tasks.', {
query: z.string().optional().describe('Todoist filter query, e.g. "today", "overdue", "#Project & p1". Omit for all active tasks.'),
project_id: z.string().optional().describe('Restrict to a project id (from todoist_list_projects). Ignored when query is set.'),
}, async ({ query, project_id }) => {
try { return ok(await todoistListTasks({ query, project_id })); } catch (e) { return err(e); }
});
server.tool('kanboard_get_project', 'Get a Kanboard project with its columns and swimlanes (needed to move cards)', {
project_id: z.number().describe('Project ID'),
}, async ({ project_id }) => {
try { return ok(await kbGetProject(project_id)); } catch (e) { return err(e); }
});
server.tool('kanboard_list_tasks', 'List tasks in a Kanboard project', {
project_id: z.number(),
status: z.enum(['open', 'closed', 'all']).optional().describe('default: open'),
}, async ({ project_id, status }) => {
try { return ok(await kbListTasks(project_id, status)); } catch (e) { return err(e); }
});
server.tool('kanboard_my_tasks', "List tasks assigned to the bot user (claude) across all projects", {
status: z.enum(['open', 'closed', 'all']).optional().describe('default: open'),
}, async ({ status }) => {
try { return ok(await kbMyTasks(status)); } catch (e) { return err(e); }
});
server.tool('kanboard_get_task', 'Get full detail of a Kanboard task, including its subtasks and comments', {
task_id: z.number(),
}, async ({ task_id }) => {
try { return ok(await kbGetTask(task_id)); } catch (e) { return err(e); }
});
server.tool('kanboard_search_tasks', 'Search tasks in a project using Kanboard query syntax (e.g. "assignee:claude status:open", "due:today", "color:red")', {
project_id: z.number(),
query: z.string(),
}, async ({ project_id, query }) => {
try { return ok(await kbSearchTasks(project_id, query)); } catch (e) { return err(e); }
});
server.tool('kanboard_list_users', 'List all Kanboard users (id, username, name, role)', {},
async () => {
try { return ok(await kbListUsers()); } catch (e) { return err(e); }
server.tool('todoist_list_projects', 'List Todoist projects (id + name).', {}, async () => {
try { return ok(await todoistListProjects()); } catch (e) { return err(e); }
});
server.tool('kanboard_project_activity', 'Get the recent activity stream for a project (who did what)', {
project_id: z.number(),
}, async ({ project_id }) => {
try { return ok(await kbProjectActivity(project_id)); } catch (e) { return err(e); }
});
server.tool('kanboard_create_task', 'Create a Kanboard task (authored by claude). At minimum provide title and project_id', {
title: z.string(),
project_id: z.number(),
description: z.string().optional().describe('Markdown'),
column_id: z.number().optional(),
owner_id: z.number().optional().describe('Assignee user id'),
color_id: z.string().optional().describe('e.g. yellow, blue, red, green'),
date_due: z.string().optional().describe('YYYY-MM-DD'),
priority: z.number().optional(),
swimlane_id: z.number().optional(),
}, async (args) => {
try { return ok(await kbCreateTask(args)); } catch (e) { return err(e); }
});
server.tool('kanboard_update_task', 'Update fields of a Kanboard task', {
id: z.number().describe('Task id'),
title: z.string().optional(),
server.tool('todoist_create_task', 'Create a Todoist task. `due_string` is natural language ("tomorrow 9am", "every monday"). `priority`: API 1=normal..4=urgent (note: Todoist UI p1 = API 4). Defaults to the Inbox project.', {
content: z.string().describe('Task title/content (required).'),
description: z.string().optional(),
owner_id: z.number().optional(),
color_id: z.string().optional(),
date_due: z.string().optional().describe('YYYY-MM-DD'),
priority: z.number().optional(),
category_id: z.number().optional(),
}, async (args) => {
try { return ok(await kbUpdateTask(args)); } catch (e) { return err(e); }
});
due_string: z.string().optional().describe('Natural-language due date, e.g. "today", "tomorrow 9am", "next monday".'),
priority: z.number().int().min(1).max(4).optional().describe('API priority 1=normal .. 4=urgent.'),
project_id: z.string().optional().describe('Target project id (default Inbox).'),
labels: z.array(z.string()).optional(),
}, async (args) => {
try { return ok(await todoistCreateTask(args)); } catch (e) { return err(e); }
});
server.tool('kanboard_move_task', 'Move a task to a column/position (e.g. to "Work in progress" or "Done"). project_id and swimlane_id are auto-resolved if omitted', {
task_id: z.number(),
column_id: z.number().describe('Target column id (see kanboard_get_project)'),
position: z.number().optional().describe('default 1 (top)'),
swimlane_id: z.number().optional(),
project_id: z.number().optional(),
}, async (args) => {
try { return ok(await kbMoveTask(args)); } catch (e) { return err(e); }
});
server.tool('todoist_update_task', 'Update a Todoist task (reschedule/edit). Give the task id plus the fields to change.', {
id: z.string(),
content: z.string().optional(),
description: z.string().optional(),
due_string: z.string().optional(),
priority: z.number().int().min(1).max(4).optional(),
labels: z.array(z.string()).optional(),
}, async (args) => {
try { return ok(await todoistUpdateTask(args)); } catch (e) { return err(e); }
});
server.tool('kanboard_change_task_status', 'Open or close (mark done/archive) a task', {
task_id: z.number(),
action: z.enum(['open', 'close']),
}, async ({ task_id, action }) => {
try { return ok(await kbChangeTaskStatus(task_id, action)); } catch (e) { return err(e); }
});
server.tool('todoist_complete_task', 'Complete (close) a Todoist task by id.', {
id: z.string().describe('Task id from todoist_list_tasks.'),
}, async ({ id }) => {
try { return ok(await todoistCompleteTask({ id })); } catch (e) { return err(e); }
});
server.tool('kanboard_assign_task', 'Assign a task to a user (username or numeric id). Use "claude" to take it', {
task_id: z.number(),
owner: z.union([z.string(), z.number()]).describe('Username or user id'),
}, async ({ task_id, owner }) => {
try { return ok(await kbAssignTask(task_id, owner)); } 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.'),
limit: z.number().int().min(1).max(50).optional().describe('Max results, default 20.'),
}, async ({ query, limit }) => {
try { return ok(await wikiSearch(query, limit)); } catch (e) { return err(e); }
});
server.tool('kanboard_add_comment', 'Add a comment to a task (authored by claude by default)', {
task_id: z.number(),
content: z.string().describe('Markdown'),
user_id: z.number().optional().describe('Override author; defaults to claude'),
}, async ({ task_id, content, user_id }) => {
try { return ok(await kbAddComment(task_id, content, user_id)); } catch (e) { return err(e); }
});
server.tool('wiki_read', 'Read the raw wikitext content of a family wiki page by exact title.', {
title: z.string().describe('Exact page title, e.g. "Антон Логинс".'),
}, async ({ title }) => {
try { return ok(await wikiRead(title)); } catch (e) { return err(e); }
});
server.tool('kanboard_create_subtask', 'Add a subtask to a task (good for tracking execution steps)', {
task_id: z.number(),
title: z.string(),
user_id: z.number().optional().describe('Assignee; defaults to claude'),
status: z.number().optional().describe('0=todo, 1=in progress, 2=done'),
}, async (args) => {
try { return ok(await kbCreateSubtask(args)); } catch (e) { return err(e); }
});
server.tool('kanboard_update_subtask', 'Update a subtask (e.g. mark in-progress or done)', {
id: z.number().describe('Subtask id'),
task_id: z.number(),
title: z.string().optional(),
status: z.number().optional().describe('0=todo, 1=in progress, 2=done'),
user_id: z.number().optional(),
}, async (args) => {
try { return ok(await kbUpdateSubtask(args)); } catch (e) { return err(e); }
});
server.tool('kanboard_remove_task', 'Permanently delete a task (irreversible)', {
task_id: z.number(),
}, async ({ task_id }) => {
try { return ok(await kbRemoveTask(task_id)); } catch (e) { return err(e); }
});
server.tool('kanboard_remove_comment', 'Permanently delete a comment (irreversible)', {
comment_id: z.number(),
}, async ({ comment_id }) => {
try { return ok(await kbRemoveComment(comment_id)); } catch (e) { return err(e); }
});
server.tool('wiki_edit', 'Create or update a family wiki page. Overwrites the page with the given wikitext.', {
title: z.string().describe('Exact page title to create/update.'),
text: z.string().describe('Full wikitext content of the page.'),
summary: z.string().optional().describe('Edit summary, default "Update <title>".'),
}, async ({ title, text, summary }) => {
try { return ok(await wikiEdit(title, text, summary)); } catch (e) { return err(e); }
});
return server;
}
@@ -404,9 +359,10 @@ 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 transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on('close', () => transport.close());
await createServer().connect(transport);
await createServer(callerAgentId).connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (e) {
console.error('MCP request error:', e.message);
@@ -418,10 +374,11 @@ 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 transport = new SSEServerTransport('/messages', res);
sseTransports.set(transport.sessionId, transport);
res.on('close', () => sseTransports.delete(transport.sessionId));
await createServer().connect(transport);
await createServer(callerAgentId).connect(transport);
});
app.post('/messages', async (req, res) => {
@@ -430,7 +387,7 @@ app.post('/messages', async (req, res) => {
await transport.handlePostMessage(req, res);
});
app.get('/health', (_, res) => res.json({ status: 'ok', tools: 45 }));
app.get('/health', (_, res) => res.json({ status: 'ok', tools: 30, vaultTrustEnforced: ENFORCE_VAULT_TRUST }));
init()
.then(() => {

101
agap-mcp/src/todoist.js Normal file
View File

@@ -0,0 +1,101 @@
// Todoist tools for agap-mcp (Adolf + Claude). Uses the unified Todoist API v1
// (https://api.todoist.com/api/v1) — REST v2 / Sync v9 are deprecated (410).
// Bearer auth with a personal API token (Vaultwarden: TODOIST_TOKEN), injected
// at init like the other services. Deliberately a SMALL, curated tool surface:
// every tool schema here is re-sent to Kimi on every Adolf turn (kb#101), so we
// expose only the essentials and return compact task objects.
const BASE = 'https://api.todoist.com/api/v1';
let _token = null;
export function initTodoist(token) {
_token = token;
console.log('Todoist: ready');
}
async function api(method, path, body) {
if (!_token) throw new Error('Todoist not initialized');
const res = await fetch(`${BASE}${path}`, {
method,
headers: {
Authorization: `Bearer ${_token}`,
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
if (!res.ok) throw new Error(`Todoist ${method} ${path}: ${res.status} ${text.slice(0, 300)}`);
return text ? JSON.parse(text) : null;
}
// Keep tool output (and thus Kimi context) small — return only fields an agent
// needs to reason about or act on, not the full Todoist task object.
function slimTask(t) {
if (!t || typeof t !== 'object') return t;
return {
id: t.id,
content: t.content,
project_id: t.project_id,
priority: t.priority, // API 1=normal .. 4=urgent (inverse of the Todoist UI's p1..p4)
due: t.due?.string || t.due?.date || null,
labels: t.labels && t.labels.length ? t.labels : undefined,
};
}
function results(data) {
if (Array.isArray(data?.results)) return data.results;
return Array.isArray(data) ? data : [];
}
// List tasks — either a Todoist filter query (e.g. "today", "overdue",
// "#Work & p1") via the /tasks/filter endpoint, or all active tasks optionally
// scoped to one project.
export async function todoistListTasks({ query, project_id } = {}) {
let path;
if (query && query.trim()) {
path = `/tasks/filter?query=${encodeURIComponent(query.trim())}`;
} else if (project_id) {
path = `/tasks?project_id=${encodeURIComponent(project_id)}`;
} else {
path = '/tasks';
}
return results(await api('GET', path)).map(slimTask);
}
export async function todoistListProjects() {
return results(await api('GET', '/projects')).map((p) => ({
id: p.id,
name: p.name,
is_favorite: p.is_favorite || undefined,
is_inbox: p.inbox_project || p.is_inbox_project || undefined,
}));
}
export async function todoistCreateTask({ content, description, due_string, priority, project_id, labels } = {}) {
if (!content || !content.trim()) throw new Error('content is required');
const body = { content: content.trim() };
if (description) body.description = description;
if (due_string) body.due_string = due_string;
if (priority) body.priority = priority;
if (project_id) body.project_id = project_id;
if (Array.isArray(labels) && labels.length) body.labels = labels;
return slimTask(await api('POST', '/tasks', body));
}
export async function todoistUpdateTask({ id, content, description, due_string, priority, labels } = {}) {
if (!id) throw new Error('id is required');
const body = {};
if (content !== undefined) body.content = content;
if (description !== undefined) body.description = description;
if (due_string !== undefined) body.due_string = due_string;
if (priority !== undefined) body.priority = priority;
if (labels !== undefined) body.labels = labels;
return slimTask(await api('POST', `/tasks/${encodeURIComponent(id)}`, body));
}
export async function todoistCompleteTask({ id } = {}) {
if (!id) throw new Error('id is required');
await api('POST', `/tasks/${encodeURIComponent(id)}/close`);
return { id, completed: true };
}

View File

@@ -0,0 +1,103 @@
// kb#147 HTTP-layer proof, 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.
import assert from 'node:assert/strict';
import express from 'express';
import {
loadTokenMap,
resolveCallerAgent,
vaultAllowed,
authHeaderToken,
isVaultTool,
_resetRegistryCacheForTests,
} from './trust-gate.js';
const registry = {
trust_classes: {
trusted: { rank: 2 },
sandboxed: { rank: 1 },
untrusted: { rank: 0 },
},
agents: [
{ id: 'adolf', trust_class: 'trusted' },
{ id: 'torgash', trust_class: 'sandboxed' },
],
};
_resetRegistryCacheForTests(registry);
const tokenMap = loadTokenMap(JSON.stringify({
'tok-adolf-e2e-test': 'adolf',
'tok-torgash-e2e-test': 'torgash',
}));
// 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)'}` });
}
return res.status(200).json({ isError: false, caller: callerAgentId });
});
const server = app.listen(0);
const port = server.address().port;
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();
}
let passed = 0;
async function check(label, fn) {
await fn();
passed++;
console.log(`ok - ${label}`);
}
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');
});
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('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('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);
});
console.log(`\n${passed} passed`);
} finally {
server.close();
_resetRegistryCacheForTests(null);
}

View File

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

View File

@@ -0,0 +1,101 @@
// Proof-of-enforcement for kb#147, run with: node src/trust-gate.test.mjs
//
// Deliberately does NOT touch the live agap-mcp container, LiteLLM, or
// Vaultwarden — it exercises the exact exported functions server.js calls
// (trustRankOf/vaultAllowed/resolveCallerAgent/isVaultTool) against a
// synthetic registry + token map, so this is a real test of the enforcement
// logic itself, not a mock of it.
import assert from 'node:assert/strict';
import {
trustRankOf,
vaultAllowed,
resolveCallerAgent,
isVaultTool,
trustedRankThreshold,
_resetRegistryCacheForTests,
} from './trust-gate.js';
const registry = {
trust_classes: {
human: { rank: 3 },
trusted: { rank: 2 },
sandboxed: { rank: 1 },
untrusted: { rank: 0 },
},
agents: [
{ id: 'adolf', trust_class: 'trusted' },
{ id: 'claude-coder', trust_class: 'trusted' },
{ id: 'torgash', trust_class: 'sandboxed' },
{ id: 'researcher', trust_class: 'sandboxed' },
{ id: 'kimi-endpoint', trust_class: 'untrusted' },
],
};
const tokenMap = {
'tok-adolf': 'adolf',
'tok-claude-coder': 'claude-coder',
'tok-torgash': 'torgash',
'tok-researcher': 'researcher',
};
let passed = 0;
function check(label, fn) {
fn();
passed++;
console.log(`ok - ${label}`);
}
check('trusted rank threshold resolves from registry', () => {
assert.equal(trustedRankThreshold(registry), 2);
});
check('trusted agents (adolf, claude-coder) can reach vault', () => {
assert.equal(vaultAllowed('adolf', registry), true);
assert.equal(vaultAllowed('claude-coder', registry), true);
});
check('sandboxed agents (torgash, researcher) CANNOT reach vault', () => {
assert.equal(vaultAllowed('torgash', registry), false);
assert.equal(vaultAllowed('researcher', registry), false);
});
check('untrusted agent cannot reach vault', () => {
assert.equal(vaultAllowed('kimi-endpoint', registry), false);
});
check('unauthenticated caller (no token resolved) cannot reach vault', () => {
assert.equal(vaultAllowed(null, registry), false);
assert.equal(trustRankOf(null, registry), -1);
});
check('unknown/unregistered agent id fails closed, not open', () => {
assert.equal(vaultAllowed('some-new-agent-nobody-declared', registry), false);
});
check('resolveCallerAgent maps bearer token -> agent id, else null', () => {
assert.equal(resolveCallerAgent('tok-torgash', tokenMap), 'torgash');
assert.equal(resolveCallerAgent('tok-adolf', tokenMap), 'adolf');
assert.equal(resolveCallerAgent('not-a-real-token', tokenMap), null);
assert.equal(resolveCallerAgent(null, tokenMap), null);
});
check('end-to-end: a sandboxed agent\'s token provably cannot reach vw_* tools', () => {
const callerAgentId = resolveCallerAgent('tok-torgash', tokenMap);
assert.equal(callerAgentId, 'torgash');
assert.equal(isVaultTool('vw_get_password'), true);
assert.equal(vaultAllowed(callerAgentId, registry), false); // <- the acceptance bar
});
check('end-to-end: a trusted agent\'s token can reach vw_* tools', () => {
const callerAgentId = resolveCallerAgent('tok-adolf', tokenMap);
assert.equal(vaultAllowed(callerAgentId, registry), true);
});
check('non-vault tool name is unaffected by the gate', () => {
assert.equal(isVaultTool('gitea_read_file'), false);
assert.equal(isVaultTool('zabbix_get_problems'), false);
});
_resetRegistryCacheForTests(null);
console.log(`\n${passed} passed`);

View File

@@ -15,12 +15,29 @@ function bwEnv() {
}
function run(args, input) {
try {
return execFileSync(BW, args, {
env: bwEnv(),
encoding: 'utf8',
input,
stdio: input ? ['pipe','pipe','pipe'] : ['ignore','pipe','pipe'],
}).trim();
} catch (e) {
// SECURITY: execFileSync puts the FULL argv in e.message
// ("Command failed: bw unlock <BW_PASSWORD> --raw"), and login/unlock pass
// the master password as argv. That message propagates to console.error /
// tool errors -> docker logs. Re-throw with the argv stripped: keep only the
// subcommand + exit code + stderr, and scrub the password out of stderr too
// (defensive; bw doesn't normally echo it). Never let argv reach a log.
const sub = Array.isArray(args) && args.length ? args[0] : '?';
let stderr = (e && e.stderr ? e.stderr.toString() : '').trim();
const secret = process.env.BW_PASSWORD;
if (secret && stderr.includes(secret)) stderr = stderr.split(secret).join('<redacted>');
const err = new Error(`bw ${sub} failed (exit ${e && e.status != null ? e.status : '?'})` +
(stderr ? `: ${stderr}` : ''));
err.status = e && e.status;
throw err;
}
}
export async function initVaultwarden() {

View File

@@ -44,11 +44,18 @@ schema_version: 1
# ── trust classes (§5) ──────────────────────────────────────────────────
# "human > trusted > sandboxed > untrusted". Numeric rank lets routing/grant
# code do `>=` comparisons instead of string-matching an ordered list.
#
# default_budget_usd/budget_duration (kb#147): defense-in-depth defaults fed
# into each agent's LiteLLM virtual-key spec (agent_registry.py:
# litellm_key_spec()) when the agent doesn't override them. Moot for money
# TODAY (§3a: no metered API by default, only free local-small is reachable
# without opt-in) but real once anything metered is opted into a tier pool —
# a budget cap should already exist rather than being bolted on later.
trust_classes:
human: { rank: 3, note: "vault access yes; not MCP-tool-scoped, IS the reasoning" }
trusted: { rank: 2, note: "vault access yes (DECIDED, kb#147); outward actions per ask-first rules" }
sandboxed: { rank: 1, note: "no vault, no outward sends; scoped MCP allowlist; KB access project-scoped" }
untrusted: { rank: 0, note: "anything ingesting the open web; its OUTPUTS are tainted, not just its access restricted" }
trusted: { rank: 2, note: "vault access yes (DECIDED, kb#147); outward actions per ask-first rules", default_budget_usd: 50.0, budget_duration: "30d" }
sandboxed: { rank: 1, note: "no vault, no outward sends; scoped MCP allowlist; KB access project-scoped", default_budget_usd: 5.0, budget_duration: "30d" }
untrusted: { rank: 0, note: "anything ingesting the open web; its OUTPUTS are tainted, not just its access restricted", default_budget_usd: 0.0, budget_duration: "30d" }
# ── runtimes ─────────────────────────────────────────────────────────────
# Backbones deliberately OUTSIDE model-registry.yaml's `models:` list.
@@ -88,11 +95,83 @@ agents:
mcp_servers: [hindsight, openclaw-tools, kanboard, marketplace, agap]
gateway_tools: [cron, nodes, browser] # openclaw.json gateway.tools.allow, live 2026-07-21
vault_access: true # trusted-only per §5 DECIDED; reaches vw_* via the `agap` MCP server
# kb#144 (A2A-12): PER-TOOL scoping, TWO layers — 2026-07-22, second
# pass after a live-verified miss on the first. Each server below
# keeps its own justification comment in its config; this is the
# registry's copy of the same lists (source of truth this side —
# validate_capability_grants.py cross-checks against BOTH).
#
# Layer 1 — OpenClaw's own mcp.servers.*.toolFilter.include in
# adolf/openclaw.json, applied when OpenClaw (the `adolf` container)
# builds ITS OWN tool bundle. Landed first pass, verified schema-valid
# via `openclaw config validate` / `openclaw mcp probe`. Real, correct
# for OpenClaw's own client — but NOT what determines the model's
# actual per-turn context on Adolf's kimi backbone.
#
# Layer 2 — shared-mcp.json's per-server `enabledTools`, which
# adolf-llm/server.js's writeMcpConfig() seeds into each Kimi CLI
# session's project-root .mcp.json (Gate 1). THIS is the layer that
# actually reaches the model: Kimi CLI auto-discovers that file, not
# OpenClaw's config, and applies its own McpServerCommonFields.
# enabledTools/disabledTools via computeEnabledNames (an allowlist
# when only enabledTools is set — confirmed by decompiling the
# installed @moonshot-ai/kimi-code package's dist/main.mjs, both
# copies of the function, packages/agent-core{,-v2}/src/agent/mcp/
# connection-manager.ts). The first kb#144 pass got this backwards —
# see the release comment on kb#144 for the exact wire.jsonl proof
# (tool counts unchanged post-restart) that caught it: Layer 1 alone
# is invisible to Kimi.
#
# Counts (same tool lists both layers, confirmed identical by
# validate_capability_grants.py, exit 0):
# agap 32->24, hindsight 29->9, kanboard 23->14,
# openclaw-tools 5->5 (already minimal, no filter needed).
# marketplace stays UNFILTERED at layer 1 (7/13 kept) but is not in
# shared-mcp.json AT ALL — Kimi's session never had it in the first
# place (pre-existing gap between what OpenClaw offers Adolf and
# what reaches Kimi, out of kb#144's scope to close).
# Reachable-by-Kimi total: hindsight+kanboard+openclaw-tools+agap
# 102-13(marketplace, never reached Kimi)=89 -> 9+14+5+24=52 tools
# (-42%). Byte-measured (chars/4) against each server's real
# tools/list JSON schemas: est. ~7K tokens saved/turn — estimate
# pending the real wire.jsonl number, which needs the adolf-llm
# container restart alvis owns (shared-mcp.json is bind-mounted
# read-only but adolf-llm's server.js caches its content at process
# start, so editing the file alone does not take effect — see
# capability_grant_status below for the confirm-post-restart command).
#
# kb#95 (2026-07-23): added wiki_search/wiki_read/wiki_edit (family
# MediaWiki / РодоВики, family.alogins.net) to agap-mcp and to both
# layers' agap allowlist below — Adolf's persona domain (relatives,
# dates, events), same reasoning as HA/Zabbix/Todoist above. This ages
# the counts comment above (agap 32->24, total 52) by +3/+3; not
# recomputed here since it needs the same live wire.jsonl proof kb#144
# used and this task does not touch the running containers (see
# shared_mcp_kimi_allowlist below for the exact confirm command).
mcp_tool_filter:
hindsight: [recall, retain, reflect, list_memories, get_memory, update_memory, list_directives, create_directive, delete_directive]
kanboard: [kanboard_list_projects, kanboard_get_project, kanboard_list_tasks, kanboard_my_tasks, kanboard_get_task, kanboard_search_tasks, kanboard_list_users, kanboard_project_activity, kanboard_create_task, kanboard_update_task, kanboard_move_task, kanboard_change_task_status, kanboard_assign_task, kanboard_add_comment]
marketplace: [marketplace_find_best, marketplace_search, marketplace_get_product, marketplace_get_recommendations, marketplace_get_reviews, marketplace_compare_prices, marketplace_status]
agap: [vw_get_password, vw_get_item, vw_list_items, vw_create_login, vw_update_password, ha_get_state, ha_list_entities, ha_call_service, ha_get_history, zabbix_get_problems, zabbix_get_hosts, zabbix_get_items, zabbix_get_triggers, radicale_list_calendars, radicale_list_events, radicale_get_event, radicale_put_event, radicale_delete_event, radicale_move_event, todoist_list_tasks, todoist_list_projects, todoist_create_task, todoist_update_task, todoist_complete_task, wiki_search, wiki_read, wiki_edit]
openclaw-tools: null # no filter in openclaw.json — already minimal (5/5 kept)
note: >
"scoped core tools" per kb#134's brief — this IS openai/openclaw.json's
live mcp.servers block, not a narrower aspirational allowlist. Per-tool
(not per-server) scoping + LiteLLM virtual-key budgets are kb#147's job;
this field is the input #147 consumes.
"scoped core tools" per kb#134's brief, now REAL at both levels: this
IS openai/openclaw.json's live mcp.servers block (server selection)
plus its per-server toolFilter.include (tool selection, kb#144). Not
a narrower aspirational allowlist — validate_capability_grants.py
cross-checks both against the live config. LiteLLM virtual-key
budgets remain kb#147's separate job; this field is the input #147
consumes for MCP scope (litellm_key_spec() handles the model side).
capability_grant: # kb#147 — the enforcement input for LiteLLM + agap-mcp
litellm_key_alias: adolf
mcp_auth_token_env: AGAP_MCP_TOKEN_ADOLF # secret lives in Vaultwarden + this container's env, never in git; see agap-mcp/docker-compose.yml AGAP_MCP_AGENT_TOKENS
note: >
Reachable model tiers derived at read time from preferred_tier via
agent_registry.py:litellm_key_spec() — not duplicated here. Actual
virtual-key provisioning happens via openai/provision_litellm_keys.py
against the live LiteLLM proxy; NOT run automatically by this
registry (privileged action, requires the LiteLLM master key —
kb#147 handover step, see task comment).
memory:
banks:
- { id: adolf-alvis, role: private, interlocutor: alvis }
@@ -127,6 +206,10 @@ agents:
Widest-scoped agent by design (this session's own tool surface) —
gated by ask-first rules on outward/destructive actions rather than
MCP allowlisting. Per kb#147, a real virtual-key budget still applies.
capability_grant:
litellm_key_alias: claude-coder
mcp_auth_token_env: AGAP_MCP_TOKEN_CLAUDE_CODER
note: "same mechanism as adolf's capability_grant above; see that note."
memory:
banks: []
model: "session (ephemeral, per invocation) + repo state (git history, CLAUDE.md files, kanboard task/comment history) — no persistent Hindsight bank"
@@ -150,6 +233,15 @@ agents:
vault_access: false # sandboxed — hard rule §5, no exceptions
outward_sends: false
note: "scoped to mcp__marketplace__* tools only; no gitea/ha/zabbix/radicale, no kanboard project outside its own."
capability_grant:
litellm_key_alias: torgash
mcp_auth_token_env: AGAP_MCP_TOKEN_TORGASH
note: >
Not provisioned yet (agent not built — see persona.prompt_source
above). litellm_key_spec('torgash') already resolves correctly
against the registry today (preferred_tier: small -> models=[local-
small's litellm_model_name] only, no large/paid-fallback) — verified
by kb#147's provision_litellm_keys.py --dry-run.
memory:
banks: [{ id: torgash, role: private }]
current_state: "bank not yet created — target state, same as the persona itself"
@@ -178,6 +270,10 @@ agents:
native_tools: [WebSearch, WebFetch]
vault_access: false
outward_sends: false
capability_grant:
litellm_key_alias: researcher
mcp_auth_token_env: AGAP_MCP_TOKEN_RESEARCHER
note: "not provisioned yet (agent not built) — same verification status as torgash's capability_grant above."
memory:
banks: [{ id: researcher, role: private }]
current_state: "bank not yet created — target state"
@@ -293,3 +389,84 @@ routing_consumption:
>= required, and current a(t) == 1 (resolved via agent_registry.py's
effective_card(), which dereferences `backbone` into model-registry.yaml
or `runtimes:` above) — no submitter-side hardcoded agent id needed.
# ── kb#147 (A2A-15) implementation status ────────────────────────────────
# What "enforced" means as of this task, per enforcement point (§5 lists
# three: agap-mcp vault tools, OpenClaw per-agent MCP scoping, LiteLLM
# virtual keys). Recorded here — not just in the KB task comment — because
# this file is the grants source of truth the design demands.
capability_grant_status:
agap_mcp_vault_gate: >
IMPLEMENTED, tested, NOT ACTIVATED. agap-mcp/src/trust-gate.js resolves
a bearer token -> agent id -> trust rank (reading THIS file, mounted
read-only) and agap-mcp/src/server.js gates every vw_* tool behind it.
Proven with two real, run-now test suites (no live container touched):
src/trust-gate.test.mjs (pure logic, 10/10) and
src/trust-gate-http.test.mjs (real HTTP request/response, 4/4) — the
latter shows a sandboxed-agent token, no token, and an unknown token all
get "vault access denied" while a trusted-agent token passes. OFF by
default (AGAP_MCP_ENFORCE_VAULT_TRUST=0 in docker-compose.yml) so this
change is zero-impact until an operator: (1) generates real per-agent
bearer tokens, stores them in Vaultwarden, wires them into
AGAP_MCP_AGENT_TOKENS, (2) sets AGAP_MCP_ENFORCE_VAULT_TRUST=1, (3)
restarts the agap-mcp container — deliberately not done by this task
(never restart the live agap-mcp service unattended).
litellm_virtual_keys: >
SPEC'D, NOT PROVISIONED. agent_registry.py:litellm_key_spec() computes
each agent's model allow-list (derived from preferred_tier x
model-registry.yaml routing.tiers, non-metered only unless opted in)
and a default budget from trust_classes[...].default_budget_usd.
openai/provision_litellm_keys.py turns that spec into LiteLLM
/key/generate calls. Verified with --dry-run (prints the exact payload
per agent, no network call) — actually creating keys needs
LITELLM_MASTER_KEY against the live proxy, a privileged write this task
does not perform unattended; see the kb#147 task comment for the exact
command to run once approved.
openclaw_mcp_allowlist: >
STRUCTURAL at both server AND tool level, cross-checked (kb#144 extended
this from server-only). adolf/openclaw.json's mcp.servers block is
adolf's real, live MCP surface (git-controlled): its server set matches
tool_allowlist.mcp_servers, and each server's toolFilter.include (added
kb#144 first pass) matches tool_allowlist.mcp_tool_filter — exactly.
openai/validate_capability_grants.py checks this automatically, read-only,
no live changes. Real and correct for OpenClaw's OWN MCP client surface —
but per the kb#144 first-pass release comment's wire.jsonl proof, this
layer alone does NOT reach the model on Adolf's kimi backbone (see
shared_mcp_kimi_allowlist below, the layer that does). NOT restarted by
this task for this file's change either way (openclaw.json is
bind-mounted read-only as the live config; `docker compose restart adolf`
is the activation step, alvis's call).
shared_mcp_kimi_allowlist: >
STRUCTURAL, cross-checked, NOT YET ACTIVATED — kb#144 SECOND pass
(2026-07-22), added after live verification (restart + one real turn,
wire.jsonl inspection) proved the first pass's openclaw.json-only fix
left Kimi's actual tool bundle unchanged (23/32/29/5, not 14/24/9/5).
Root cause: Kimi CLI auto-discovers a project-root `.mcp.json` that
adolf-llm/server.js's writeMcpConfig() seeds from openai/shared-mcp.json
— a completely separate config from openclaw.json, read by a separate
MCP client (Kimi CLI inside the adolf-llm container, not OpenClaw inside
the adolf container). shared-mcp.json now carries the same per-server
tool lists as `enabledTools` (Kimi's own allowlist field —
McpServerCommonFields.enabledTools, applied via computeEnabledNames;
confirmed by decompiling the installed @moonshot-ai/kimi-code package's
dist/main.mjs, both copies of the function/schema, no live container
touched). openai/validate_capability_grants.py now cross-checks THIS
file too (load_shared_mcp_enabled_tools), same exit-0-or-fail contract
as the openclaw.json check. marketplace is absent from shared-mcp.json
entirely (pre-existing: Kimi's session never had it) — not asserted by
the validator for that server, by design, not a gap this task opened.
NOT YET ACTIVATED: shared-mcp.json IS bind-mounted read-only into
adolf-llm (`./shared-mcp.json:/shared-mcp.json:ro` in
openai/docker-compose.yml) so the file on disk is already what the
container would read — but adolf-llm/server.js loads it ONCE into a
module-level variable at process start (not per-request), so editing the
file alone does not take effect; `docker compose restart adolf-llm` is
the only remaining step, deliberately not run by this task (never
restart a live service unattended). Confirm the real post-restart
per-turn token delta via the adolf-llm container's Kimi session wire
log: `docker exec adolf-llm sh -c "tail -1
/root/.kimi-code/sessions/*/agents/main/wire.jsonl"` (after one real
turn against a NEW session, since existing sessions' .mcp.json is
rewritten on their next turn too) and compare per-server tool counts
against 9/14/5/24 (hindsight/kanboard/openclaw-tools/agap) — the exact
same command the first-pass verification used to catch the miss.

View File

@@ -2,7 +2,8 @@
"mcpServers": {
"hindsight": {
"type": "http",
"url": "http://hindsight:8888/mcp/adolf/"
"url": "http://hindsight:8888/mcp/adolf/",
"enabledTools": ["recall", "retain", "reflect", "list_memories", "get_memory", "update_memory", "list_directives", "create_directive", "delete_directive"]
},
"openclaw-tools": {
"type": "http",
@@ -10,11 +11,19 @@
},
"kanboard": {
"type": "http",
"url": "http://host.docker.internal:3104/mcp"
"url": "http://host.docker.internal:3104/mcp",
"enabledTools": ["kanboard_list_projects", "kanboard_get_project", "kanboard_list_tasks", "kanboard_my_tasks", "kanboard_get_task", "kanboard_search_tasks", "kanboard_list_users", "kanboard_project_activity", "kanboard_create_task", "kanboard_update_task", "kanboard_move_task", "kanboard_change_task_status", "kanboard_assign_task", "kanboard_add_comment"]
},
"agap": {
"type": "http",
"url": "http://host.docker.internal:3100/mcp"
"url": "http://host.docker.internal:3100/mcp",
"enabledTools": ["vw_get_password", "vw_get_item", "vw_list_items", "vw_create_login", "vw_update_password", "ha_get_state", "ha_list_entities", "ha_call_service", "ha_get_history", "zabbix_get_problems", "zabbix_get_hosts", "zabbix_get_items", "zabbix_get_triggers", "radicale_list_calendars", "radicale_list_events", "radicale_get_event", "radicale_put_event", "radicale_delete_event", "radicale_move_event", "todoist_list_tasks", "todoist_list_projects", "todoist_create_task", "todoist_update_task", "todoist_complete_task", "wiki_search", "wiki_read", "wiki_edit"]
},
"marketplace": {
"type": "http",
"url": "http://host.docker.internal:3101/mcp",
"bearerTokenEnvVar": "MARKETPLACE_MCP_TOKEN",
"enabledTools": ["marketplace_find_best", "marketplace_search", "marketplace_get_product", "marketplace_get_recommendations", "marketplace_get_reviews", "marketplace_compare_prices", "marketplace_status"]
}
}
}

View File

@@ -0,0 +1,313 @@
#!/usr/bin/env python3
"""validate_capability_grants — kb#147 (A2A-15), extended by kb#144 (A2A-12):
cross-check that agent-registry.yaml's declared tool_allowlist.mcp_servers
(server-level) AND tool_allowlist.mcp_tool_filter (per-tool level, kb#144)
for each agent match what that agent's real, git-controlled config actually
grants it -- at BOTH layers that materialize a tool bundle for Adolf:
1. OpenClaw's own mcp.servers.*.toolFilter.include in adolf/openclaw.json
(adolf's own MCP client surface).
2. shared-mcp.json's per-server `enabledTools` (kb#144 verification pass,
2026-07-22): what adolf-llm/server.js's writeMcpConfig() seeds into
each Kimi CLI session's project-root .mcp.json -- the layer that
ACTUALLY determines the MODEL's tool bundle for Adolf's kimi backbone.
Layer 1 alone shipped a false "Done" once already (kb#144 first pass):
wire.jsonl proved Kimi's tool counts were unchanged because OpenClaw's
toolFilter never reaches the Kimi CLI, which reads its own
enabledTools/disabledTools (McpServerCommonFields, computeEnabledNames
-- confirmed by decompiling the installed @moonshot-ai/kimi-code
package's dist/main.mjs). Checking only layer 1 would pass this
validator while leaving the real per-turn token bloat unfixed again.
Read-only. Makes no live changes and touches no running service — it just
diffs already-committed files so a registry edit that silently drifts from
an agent's real config fails loudly (exit 1) instead of rotting quietly,
which is exactly the "scattered configs" failure mode kb#147's acceptance
bar ("grants live in the agent registry, not scattered configs") exists to
prevent. kb#144's acceptance bar ("Adolf's tools are sourced from the
registry") means both layers, not just the one OpenClaw itself reads.
Only agents with a `prompt_source` pointing at a real openclaw.json-shaped
config are checked; agents that are registry-only target state (torgash,
researcher — no config file exists yet) are reported as skipped, not failed.
Usage:
./validate_capability_grants.py
./validate_capability_grants.py --openclaw-json ../adolf/openclaw.json --id adolf
"""
import argparse
import json
import re
import sys
import agent_registry as ar
HERE_ADOLF_OPENCLAW_JSON = "../adolf/openclaw.json"
SHARED_MCP_JSON = "shared-mcp.json"
# id -> path to the git-controlled OpenClaw config that IS this agent's live
# MCP surface. Only adolf has one today (claude-coder has no openclaw.json --
# it's a CLAUDE.md-driven persona, not an OpenClaw runtime; see its
# capability_grant note in agent-registry.yaml).
KNOWN_CONFIGS = {
"adolf": HERE_ADOLF_OPENCLAW_JSON,
}
# id -> path to the shared-mcp.json this agent's backbone runtime seeds its
# session .mcp.json from (kb#144 layer-2 check, see module docstring). Only
# agents on a Kimi-CLI-shaped backbone go through this file at all.
KNOWN_SHARED_MCP = {
"adolf": SHARED_MCP_JSON,
}
def _strip_jsonc_comments(text):
"""Drop // line comments. Good enough for this read-only check: this
file's comments are all on their own line or trail real content with no
'//' inside a string value today -- verified by hand."""
out = []
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("//"):
continue
m = re.search(r'(?<!:)//', line)
if m and line[: m.start()].count('"') % 2 == 0:
line = line[: m.start()]
out.append(line)
return "\n".join(out)
def _find_key_brace(text, key, start=0):
"""Find `key: {` (bare or quoted key) at or after `start`; return the
index of the matching '{'."""
m = re.search(rf'["\']?{re.escape(key)}["\']?\s*:\s*\{{', text[start:])
if not m:
raise ValueError(f"key {key!r} not found from offset {start}")
return start + m.end() - 1 # index of the '{' itself
def _matching_close_brace(text, open_idx):
depth = 0
for i in range(open_idx, len(text)):
if text[i] == '{':
depth += 1
elif text[i] == '}':
depth -= 1
if depth == 0:
return i
raise ValueError("unbalanced braces")
def _extract_object_top_level_keys(text, key_path):
"""openclaw.json is JS-object-literal JSON5 (bare identifier keys,
trailing commas) -- `json.loads` can't touch it and pulling in a JSON5
parser is overkill for one narrow read. Instead: locate `key_path`
(e.g. ["mcp", "servers"]) by finding each key's opening '{' in turn, then
brace-depth-scan that object collecting only its DIRECT child keys
(`name: {` at depth 1). Sufficient and honest for this validation
script's one job; not a general JSON5 reader."""
pos = 0
open_idx = 0
for key in key_path:
open_idx = _find_key_brace(text, key, pos)
pos = open_idx + 1
close_idx = _matching_close_brace(text, open_idx)
body = text[open_idx + 1 : close_idx]
# Depth-0 identifiers immediately followed by ": {" are this object's
# direct child keys (mcp.servers' entries are always objects). Only try
# a key match right after a boundary ('{', ',', or start-of-body) so an
# identifier can't be "matched" starting mid-token from inside a nested
# value (the earlier, buggy version of this scanner did exactly that).
keys = []
depth = 0
i, n = 0, len(body)
prev_boundary = True
key_re = re.compile(r'["\']?([A-Za-z0-9_-]+)["\']?\s*:\s*\{')
while i < n:
ch = body[i]
if ch in ' \t\r\n':
i += 1
continue
if depth == 0 and prev_boundary:
m = key_re.match(body, i)
if m:
keys.append(m.group(1))
i = m.end() - 1 # land on the key's '{' so the normal handling below opens depth 1
prev_boundary = False
continue
if ch == '{':
depth += 1
prev_boundary = True
elif ch == '}':
depth -= 1
prev_boundary = True
elif ch == ',':
prev_boundary = True
else:
prev_boundary = False
i += 1
return sorted(keys)
def load_mcp_servers(path):
with open(path) as f:
raw = f.read()
text = _strip_jsonc_comments(raw)
return _extract_object_top_level_keys(text, ["mcp", "servers"])
def _locate_object(text, key_path, start=0):
"""Chase `key_path` (e.g. ["mcp", "servers", "hindsight"]) through nested
`key: {` objects, same navigation _extract_object_top_level_keys does
internally, exposed standalone so other extractors (toolFilter below)
can reuse it instead of re-deriving brace offsets."""
pos = start
open_idx = start
for key in key_path:
open_idx = _find_key_brace(text, key, pos)
pos = open_idx + 1
close_idx = _matching_close_brace(text, open_idx)
return open_idx, close_idx
def _matching_close_bracket(text, open_idx):
"""Same brace-depth-scan as _matching_close_brace, for '[' / ']' — needed
to bound a toolFilter.include array (a list, not an object)."""
depth = 0
for i in range(open_idx, len(text)):
if text[i] == '[':
depth += 1
elif text[i] == ']':
depth -= 1
if depth == 0:
return i
raise ValueError("unbalanced brackets")
def load_tool_filter(path, server_name):
"""kb#144: extract mcp.servers.<server_name>.toolFilter.include as a
sorted list of tool names, or None if that server has no toolFilter (or
no include list) at all — OpenClaw's own semantics for "no toolFilter":
every tool the server offers stays eligible (see schema-BqdpWz19.js:
"When omitted, all server tools remain eligible unless excluded.").
exclude-only filters are not modeled here (none of Adolf's servers use
exclude today) and are reported as None (unrestricted) rather than
silently mis-parsed.
"""
with open(path) as f:
raw = f.read()
text = _strip_jsonc_comments(raw)
try:
server_open, server_close = _locate_object(text, ["mcp", "servers", server_name])
except ValueError:
return None # server not present in this config at all
body = text[server_open : server_close + 1]
try:
tf_open, tf_close = _locate_object(body, ["toolFilter"])
except ValueError:
return None # no toolFilter -> unrestricted, by OpenClaw's own semantics
tf_body = body[tf_open : tf_close + 1]
m = re.search(r'["\']?include["\']?\s*:\s*\[', tf_body)
if not m:
return None # exclude-only or empty toolFilter -- not modeled, treat as unrestricted
bracket_open = tf_body.index('[', m.start())
bracket_close = _matching_close_bracket(tf_body, bracket_open)
arr_body = tf_body[bracket_open + 1 : bracket_close]
return sorted(re.findall(r'["\']([A-Za-z0-9_.\-\*]+)["\']', arr_body))
def load_shared_mcp_enabled_tools(path):
"""kb#144 layer-2 check (see module docstring): shared-mcp.json is
strict JSON (no JSON5 quirks, unlike openclaw.json), so a plain
`json.load` is enough -- no brace-scanner needed here. Returns
{server_name: sorted-tool-list-or-None}, None meaning no `enabledTools`
key on that server (unfiltered -- every tool it offers stays eligible,
same "omitted = unrestricted" semantics as OpenClaw's own toolFilter).
"""
with open(path) as f:
data = json.load(f)
out = {}
for name, cfg in (data.get("mcpServers") or {}).items():
tools = cfg.get("enabledTools")
out[name] = sorted(tools) if tools else None
return out
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--registry", default=None)
ap.add_argument("--openclaw-json", default=None, help="override path for --id's config")
ap.add_argument("--id", default=None, help="only this agent id (default: every id in KNOWN_CONFIGS)")
args = ap.parse_args()
reg = ar.load_registry(args.registry)
ids = [args.id] if args.id else list(KNOWN_CONFIGS)
failures = 0
for agent_id in ids:
agent = ar.get_agent(reg, agent_id)
declared = sorted((agent.get("tool_allowlist") or {}).get("mcp_servers") or [])
config_path = args.openclaw_json or KNOWN_CONFIGS.get(agent_id)
if not config_path:
print(f"SKIP {agent_id}: no known live config to cross-check (target-state agent)")
continue
try:
live = load_mcp_servers(config_path)
except FileNotFoundError:
print(f"SKIP {agent_id}: config not found at {config_path}")
continue
if declared == live:
print(f"OK {agent_id}: registry tool_allowlist.mcp_servers == live {config_path} mcp.servers -> {live}")
else:
failures += 1
print(f"FAIL {agent_id}: registry says {declared} but {config_path} actually grants {live}")
# kb#144: per-tool cross-check, same idea one level down. Only
# meaningful for servers the registry actually declares a filter
# for (mcp_tool_filter); a server absent from that map is not
# asserted either way here (it may be intentionally unfiltered).
declared_filters = (agent.get("tool_allowlist") or {}).get("mcp_tool_filter") or {}
for server_name, declared_tools in declared_filters.items():
live_tools = load_tool_filter(config_path, server_name)
declared_sorted = sorted(declared_tools) if declared_tools else None
if declared_sorted == live_tools:
shown = live_tools if live_tools is not None else "(unfiltered)"
print(f"OK {agent_id}/{server_name}: registry mcp_tool_filter == live toolFilter.include -> {shown}")
else:
failures += 1
print(f"FAIL {agent_id}/{server_name}: registry mcp_tool_filter says {declared_sorted} but live toolFilter.include is {live_tools}")
# kb#144 layer-2: the file that actually reaches the MODEL for a
# Kimi-backed agent (see module docstring for why layer 1 alone
# missed the real bug once already). Servers the registry declares a
# filter for but that don't appear in shared-mcp.json at all (e.g.
# marketplace, which OpenClaw carries but Kimi's session never sees)
# are not asserted here -- that's a separate, pre-existing gap
# between what OpenClaw offers Adolf and what reaches Kimi, not a
# drift this validator's job to catch.
shared_mcp_path = KNOWN_SHARED_MCP.get(agent_id)
if shared_mcp_path:
try:
live_shared = load_shared_mcp_enabled_tools(shared_mcp_path)
except FileNotFoundError:
print(f"SKIP {agent_id}: shared-mcp.json not found at {shared_mcp_path}")
else:
for server_name, declared_tools in declared_filters.items():
if server_name not in live_shared:
continue
declared_sorted = sorted(declared_tools) if declared_tools else None
live_tools = live_shared[server_name]
if declared_sorted == live_tools:
shown = live_tools if live_tools is not None else "(unfiltered)"
print(f"OK {agent_id}/{server_name}: registry mcp_tool_filter == live shared-mcp.json enabledTools -> {shown}")
else:
failures += 1
print(f"FAIL {agent_id}/{server_name}: registry mcp_tool_filter says {declared_sorted} but shared-mcp.json enabledTools is {live_tools}")
sys.exit(1 if failures else 0)
if __name__ == "__main__":
main()