fix(mcp): two rule reads reach a read-only key, and every tool must now be classified (#3191)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 55s
CI & Build / Python tests (push) Successful in 1m40s
CI & Build / Build & push image (push) Successful in 25s

rules_due_for_verification (the rule staleness sweep) and rule_history (what a
rule used to say) are pure reads, and a read-scoped API key was refused both:
neither is in _READ_ONLY_TOOLS, and the completeness test that should have
caught it only looked at tools whose NAMES start like a read (get_, list_,
search…). Neither does.

- Both join _READ_ONLY_TOOLS; the comment that pointed at this issue now says
  why they sat unlisted.
- _WRITE_TOOLS declares every writing tool by name. Nothing reads it at
  runtime — default-deny already refuses an unlisted tool — it exists so the
  classification is total.
- test_every_registered_tool_is_classified_exactly_once takes its candidates
  from what build_mcp_server() actually mounts, requires each in exactly one
  of the three sets, and still flags a classified name that is no tool. The
  decision stays explicit; only the candidate set widened.
- test_the_completeness_check_can_fail drops a real tool from its set and
  asserts it is noticed (rule 167).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-15 12:31:04 -04:00
co-authored by Claude Opus 5
parent 4e4020c040
commit 0bf7406f42
2 changed files with 103 additions and 56 deletions
+48 -5
View File
@@ -72,9 +72,10 @@ shared:true records are another user's suggestion, not settled practice.
# what you hand to something you don't fully trust — a dashboard, a CI job, a
# shared integration — and a boundary inferred from a naming convention grants
# access to whatever a future author happens to call `get_*`. Enumerating it is
# the point; staleness is the cost, and test_mcp_auth covers that (a read-shaped
# tool must appear here or in _DELIBERATELY_WRITE_SCOPED below, so adding one
# forces a decision instead of silently denying it).
# the point; staleness is the cost, and test_mcp_auth covers that: EVERY
# registered tool must appear in exactly one of _READ_ONLY_TOOLS, _WRITE_TOOLS or
# _DELIBERATELY_WRITE_SCOPED below, so adding one forces a decision instead of
# silently denying it — whatever the tool is called (#3191).
#
# Membership means "reads the operator's data and mutates none of it". Several
# getters record a retrieval event via record_pulled; that is telemetry about
@@ -121,9 +122,51 @@ _READ_ONLY_TOOLS = frozenset({
# is the write, and it is deliberately NOT here. Spelled out for
# retrieval_telemetry's reason: `notes_due_for_verification` matches none of
# the prefixes the completeness test derives from, so nothing would have
# prompted this decision. `rules_due_for_verification` is in the same
# position and is NOT listed — see #3191.
# prompted this decision.
"notes_due_for_verification",
# Its rule twin and a rule's edit history (milestones 312 and 323). Both
# pure reads, and both sat unlisted — so a read key was refused them — for
# the same reason: no read prefix, back when the completeness test only
# looked at names that had one (#3191). rule_history records a pull the way
# the getters above do.
"rules_due_for_verification", "rule_history",
})
# Every tool that WRITES, by name. Nothing reads this set at runtime — a tool
# absent from _READ_ONLY_TOOLS is already denied to a read key. It exists so the
# classification is total: test_mcp_auth requires every registered tool to sit
# in exactly one of the three sets, which is what makes forgetting impossible
# rather than merely unlikely. Before #3191 the test only asked about tools whose
# names looked like reads, and two reads with other names were denied for weeks.
_WRITE_TOOLS = frozenset({
# notes, tasks, planning
"create_note", "update_note", "delete_note",
"create_task", "update_task", "delete_task", "add_task_log",
"create_records", "start_planning",
"create_milestone", "update_milestone", "delete_milestone",
"mark_note_verified",
# projects, Systems, repos
"create_project", "update_project", "delete_project", "decide_project_inception",
"create_system", "update_system", "delete_system", "map_system_to_canonical",
"bind_repo", "unbind_repo",
# snippets, processes, the shape ledger
"create_snippet", "update_snippet", "delete_snippet", "verify_snippet",
"merge_snippets", "unmerge_snippet",
"create_process", "update_process", "delete_process",
"classify_shapes", "classify_shapes_by_rule", "confirm_shape_proposals",
"refresh_pattern_coverage",
# design systems
"create_design_system", "update_design_system", "delete_design_system",
"create_design_token", "update_design_token", "delete_design_token",
"set_project_design_system",
# rules
"create_rulebook", "update_rulebook", "delete_rulebook",
"create_topic", "update_topic", "delete_topic",
"create_rule", "create_project_rule", "update_rule", "move_rule", "delete_rule",
"create_preference", "update_preference",
"relate_rules", "unrelate_rules", "mark_rule_verified",
# trash
"restore", "purge_trash",
})
# Read-SHAPED tools that must NOT be reachable with a read key — a getter that