From 16b1d68ebd54753654a70f10d3af529fe7697fc7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 4 Nov 2026 21:42:14 -0500 Subject: [PATCH] fix(6854): the locating arguments that only point are declared as references, and the plugin version is minted The guard caught seven write-tool arguments with a locating name that are not where the write lands: - snippet_id on classify_shapes_by_rule and confirm_shape_proposals names the canon, not the rows written; - rule_outcome, rule_misfired and judge_lesson_link record judgments. REFERENCES lists each one with its reason, and the guard reads it. READ_PREFIXES names the tool prefixes the fence answers without the server, and a test holds every write tool to a name outside them. plugin.json is minted to 2026.11.05.0242 for the PACKAGING.md row. Co-Authored-By: Claude Opus 5.5 --- plugin/.claude-plugin/plugin.json | 2 +- src/grimoire/services/write_scope.py | 33 +++++++++++++++++++++++++++- tests/test_write_scope.py | 23 ++++++++++++++++--- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 905a0391..ac32ad78 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "grimoire", "description": "Grimoire for Claude Code: connects the grimoire MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Grimoire skills (using-grimoire, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting, family-canon), and syncs your saved Grimoire Processes as skills (/grimoire:sync).", - "version": "2026.10.26.2029", + "version": "2026.11.05.0242", "author": { "name": "Bryan Van Deusen" }, diff --git a/src/grimoire/services/write_scope.py b/src/grimoire/services/write_scope.py index 55dddb17..7fe7149c 100644 --- a/src/grimoire/services/write_scope.py +++ b/src/grimoire/services/write_scope.py @@ -143,12 +143,43 @@ WRITE_TARGETS: dict[str, dict[str, str]] = { # Argument names that would locate a write wherever they appear. The guard test # requires every write tool parameter with one of these names to be declared -# above, so a tool that gains one can't write past the fence unnoticed. +# above, or listed in REFERENCES below, so a tool that gains one can't write +# past the fence unnoticed. LOCATOR_NAMES = frozenset({ "project_id", "task_id", "note_id", "lesson_id", "snippet_id", "process_id", "milestone_id", "system_id", "rule_id", "deleted_batch_id", }) +# Arguments with a locating name that only point at a record, with why each one +# doesn't say where the write lands. +REFERENCES: dict[str, dict[str, str]] = { + "classify_shapes_by_rule": { + "snippet_id": "the canon the shapes are judged against; the rows written are project_id's", + }, + "confirm_shape_proposals": { + "snippet_id": "narrows which proposals to confirm; the rows written are project_id's", + }, + "judge_lesson_link": { + "lesson_id": "a judgment on a lesson-to-rule pairing the system asked for", + "rule_id": "a judgment on a lesson-to-rule pairing the system asked for", + }, + "rule_outcome": { + "rule_id": "telemetry on what a rule changed in this session's own work", + }, + "rule_misfired": { + "rule_id": "telemetry on a moment that fired a rule in this session", + "project_id": "the project the misfire happened in, recorded with the report", + }, +} + +# Tool-name prefixes the plugin's fence answers without asking the server: every +# tool named this way reads. tests/test_write_scope.py holds the hook's copy of +# this list equal to it, and holds every write tool to a name outside it. +READ_PREFIXES = ( + "get_", "list_", "search", "find_", "enter_project", "what_might_apply", + "resolve_design_system", +) + # The one write a session may make in another project: filing an issue there, # as a new open task outside any plan of theirs. ISSUE_STATUSES = frozenset({"", "todo"}) diff --git a/tests/test_write_scope.py b/tests/test_write_scope.py index 49d3b1b0..f6b5888f 100644 --- a/tests/test_write_scope.py +++ b/tests/test_write_scope.py @@ -13,7 +13,7 @@ from quart import g from grimoire.services import write_scope from grimoire.services.write_scope import ( - LOCATOR_NAMES, LOCATORS, WRITE_TARGETS, check_act, is_issue_filing, + LOCATOR_NAMES, LOCATORS, REFERENCES, WRITE_TARGETS, check_act, is_issue_filing, ) @@ -53,14 +53,31 @@ def test_an_argument_that_would_locate_a_write_cannot_be_left_out(): f"{tool}.{arg}" for tool, names in params.items() for arg in names & LOCATOR_NAMES - if arg not in WRITE_TARGETS[tool] + if arg not in WRITE_TARGETS[tool] and arg not in REFERENCES.get(tool, {}) ) assert not undeclared, ( f"write tools take these locating arguments without declaring them in " - f"WRITE_TARGETS: {undeclared}" + f"WRITE_TARGETS, or saying in REFERENCES why they only point: {undeclared}" ) +def test_a_reference_is_an_argument_the_tool_takes_and_not_also_a_locator(): + params = _write_tool_params() + for tool, refs in REFERENCES.items(): + for arg in refs: + assert arg in params[tool], f"REFERENCES names {tool}.{arg}, which it doesn't take" + assert arg not in WRITE_TARGETS[tool], f"{tool}.{arg} is both a locator and a reference" + + +def test_no_write_tool_carries_a_read_prefix(): + # The fence hook skips the server for these prefixes, so a write named + # like a read would pass unchecked. + from grimoire.mcp.server import _WRITE_TOOLS + + read_named = sorted(t for t in _WRITE_TOOLS if t.startswith(write_scope.READ_PREFIXES)) + assert not read_named, f"write tools named like reads: {read_named}" + + def test_the_locator_scan_sees_the_arguments_it_guards(): # The guard above passes vacuously if the scan finds no parameters at all. params = _write_tool_params()