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>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
313
openai/validate_capability_grants.py
Executable file
313
openai/validate_capability_grants.py
Executable 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()
|
||||
Reference in New Issue
Block a user