Files
FabledGrimoire/src/grimoire/services/write_scope.py
T
bvandeusenandClaude Opus 5.5 16b1d68ebd
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m43s
CI & Build / integration (push) Successful in 2m2s
CI & Build / Build & push image (push) Successful in 22s
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 <noreply@anthropic.com>
2026-11-04 21:42:14 -05:00

339 lines
15 KiB
Python

"""Which project a write lands in, so a session can be held to its own.
A Claude Code session belongs to one project: the one its starting directory
is bound to. Toward any other project it may read anything and file an issue,
and every other write waits for the operator. The plugin's PreToolUse hook
enforces that, because only the hook knows which session is acting: the MCP
endpoint is stateless and sees nothing but the caller's key. This module
answers the hook's one question: given the session's project and a pending
act, which projects would the act change, and may it go ahead?
An act is either a directory (a file edit or a git command; the hook resolves
it to a repo or a `.grimoire` marker) or an MCP tool call. For a tool call,
`WRITE_TARGETS` says, for every write tool, which of its arguments locate the
write. That means the record or project the write changes, never one it only
mentions: `arose_from_id` on a new task points at another task without
changing it. tests/test_write_scope.py holds the table to the registered tools,
so a new write tool can't be added without saying where it writes.
"""
from __future__ import annotations
from sqlalchemy import or_, select
# How an argument locates the project a write lands in.
PROJECT = "project" # a project id
NOTE = "note" # a task, note, lesson, snippet or process: its project
MILESTONE = "milestone" # a milestone: its project
SYSTEM = "system" # a System: its project
RULE = "rule" # a project rule: its project. A global rule: none
RELATION = "relation" # a rule relation: the projects of its two rules
TRASH = "trash" # a deleted batch: every project its rows belong to
REPO = "repo" # a repo remote: the project it is bound to
DECISION = "decision" # a family decision: its project, else its idea's home
LOCATORS = frozenset({PROJECT, NOTE, MILESTONE, SYSTEM, RULE, RELATION, TRASH, REPO, DECISION})
# Every write tool, and the arguments that locate its write. An empty entry is
# a write that lands in no project:
# - install-level settings (moment mappings, retrieval dials);
# - user-level records (processes, design systems, rulebooks, topics, global
# rules and preferences);
# - judgments the system asks a session for in any project (a rule's outcome,
# a lesson-to-rule link, moment proposals, a menu verdict). Those describe how
# the corpus is retrieved; they don't change another project's work.
WRITE_TARGETS: dict[str, dict[str, str]] = {
# notes, tasks, planning
"create_note": {"project_id": PROJECT},
"update_note": {"note_id": NOTE, "project_id": PROJECT},
"delete_note": {"note_id": NOTE},
"mark_note_verified": {"note_id": NOTE},
"create_lesson": {"project_id": PROJECT},
"update_lesson": {"lesson_id": NOTE},
"delete_lesson": {"lesson_id": NOTE},
"judge_lesson_link": {},
"create_task": {"project_id": PROJECT, "milestone_id": MILESTONE, "parent_id": NOTE},
"update_task": {"task_id": NOTE, "project_id": PROJECT, "milestone_id": MILESTONE},
"delete_task": {"task_id": NOTE},
"add_task_log": {"task_id": NOTE},
"create_records": {"project_id": PROJECT, "milestone_id": MILESTONE},
"start_planning": {"project_id": PROJECT},
"create_milestone": {"project_id": PROJECT},
"update_milestone": {"milestone_id": MILESTONE, "project_id": PROJECT},
"delete_milestone": {"milestone_id": MILESTONE},
# projects, Systems, repos
"create_project": {},
"update_project": {"project_id": PROJECT},
"delete_project": {"project_id": PROJECT},
"decide_project_inception": {"project_id": PROJECT},
"set_project_platforms": {"project_id": PROJECT},
"create_system": {"project_id": PROJECT},
"update_system": {"system_id": SYSTEM},
"delete_system": {"system_id": SYSTEM},
"map_system_to_canonical": {"system_id": SYSTEM},
# Binding a repo changes the project it's bound to and, when it was bound
# elsewhere, the project it leaves.
"bind_repo": {"project_id": PROJECT, "repo_url": REPO},
"unbind_repo": {"repo_url": REPO},
# family canon. An idea is a record in its home project; an adoption is a
# project's answer; a conflict is between the two projects it names.
"propose_family_idea": {"note_id": NOTE},
"promote_family_idea": {"note_id": NOTE},
"retire_family_idea": {"note_id": NOTE},
"revise_family_idea": {"note_id": NOTE},
"set_family_references": {"note_id": NOTE},
"set_family_topic": {"note_id": NOTE},
"undo_family_decision": {"decision_id": DECISION},
"assess_family_adoption": {"project_id": PROJECT},
"resolve_family_conflict": {"canon_project_id": PROJECT, "other_project_id": PROJECT},
# snippets, processes, the shape ledger
"create_snippet": {"project_id": PROJECT},
"update_snippet": {"snippet_id": NOTE, "project_id": PROJECT},
"delete_snippet": {"snippet_id": NOTE},
"verify_snippet": {"snippet_id": NOTE},
"merge_snippets": {"target_id": NOTE, "source_ids": NOTE},
"unmerge_snippet": {"survivor_id": NOTE, "source_id": NOTE},
"create_process": {},
"update_process": {"process_id": NOTE},
"delete_process": {"process_id": NOTE},
"classify_shapes": {"project_id": PROJECT},
"classify_shapes_by_rule": {"project_id": PROJECT},
"confirm_shape_proposals": {"project_id": PROJECT},
"refresh_pattern_coverage": {"project_id": PROJECT},
"move_path": {"project_id": PROJECT},
# design systems belong to their owner, not to a project; choosing one for
# a project is that project's write.
"create_design_system": {},
"update_design_system": {},
"delete_design_system": {},
"create_design_token": {},
"update_design_token": {},
"delete_design_token": {},
"set_project_design_system": {"project_id": PROJECT},
# rules
"create_rulebook": {},
"update_rulebook": {},
"delete_rulebook": {},
"create_topic": {},
"update_topic": {},
"delete_topic": {},
"create_rule": {},
"create_preference": {},
"create_project_rule": {"project_id": PROJECT},
"update_rule": {"rule_id": RULE},
"update_preference": {"rule_id": RULE},
"move_rule": {"rule_id": RULE, "project_id": PROJECT},
"delete_rule": {"rule_id": RULE},
"mark_rule_verified": {"rule_id": RULE},
"relate_rules": {"from_rule_id": RULE, "to_rule_id": RULE},
"unrelate_rules": {"relation_id": RELATION},
"rule_outcome": {},
# moments and retrieval: install-level, and judgments asked for anywhere
"tune_retrieval": {},
"migrate_retrieval_floor": {},
"map_action": {},
"unmap_action": {},
"propose_rule_moments": {},
"judge_rule_moments": {},
"rule_misfired": {},
"judge_menu": {},
# trash: a batch can hold rows from several projects
"restore": {"deleted_batch_id": TRASH},
"purge_trash": {"deleted_batch_id": TRASH},
}
# 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, 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"})
def is_issue_filing(tool: str, args: dict) -> bool:
"""True for create_task(kind="issue") that opens a fresh, unplanned issue."""
if tool != "create_task":
return False
if str(args.get("kind") or "") != "issue":
return False
if str(args.get("status") or "") not in ISSUE_STATUSES:
return False
return not _positive_ints(args.get("milestone_id")) and not _positive_ints(args.get("parent_id"))
def _positive_ints(value) -> list[int]:
"""The ids an argument names. 0 and negative values mean "none" or "leave
unchanged" across the tools (update_task's project_id=-1 clears), so only
positive ids locate anything."""
values = value if isinstance(value, (list, tuple)) else [value]
out = []
for v in values:
try:
n = int(v)
except (TypeError, ValueError):
continue
if n > 0:
out.append(n)
return out
async def _projects_of(user_id: int, kind: str, value) -> set[int]:
"""The projects the argument `value` of locator `kind` lands in."""
from grimoire.models import async_session
from grimoire.models.family import FamilyDecision
from grimoire.models.milestone import Milestone
from grimoire.models.note import Note
from grimoire.models.rulebook import Rule, RuleRelation
from grimoire.models.system import System
from grimoire.services import repo_bindings
if kind == REPO:
found = await repo_bindings.resolve_project(user_id, str(value or ""))
return {found} if found else set()
if kind == TRASH:
batch = str(value or "").strip()
if not batch:
return set()
async with async_session() as session:
found: set[int] = set()
for model in (Note, Milestone, System, Rule):
rows = await session.execute(
select(model.project_id).where(model.deleted_batch_id == batch)
)
found.update(p for p in rows.scalars() if p)
return found
ids = _positive_ints(value)
if not ids:
return set()
if kind == PROJECT:
return set(ids)
async with async_session() as session:
if kind == RELATION:
rows = await session.execute(
select(Rule.project_id)
.join(RuleRelation, or_(RuleRelation.from_rule_id == Rule.id,
RuleRelation.to_rule_id == Rule.id))
.where(RuleRelation.id.in_(ids))
)
return {p for p in rows.scalars() if p}
if kind == DECISION:
rows = await session.execute(
select(FamilyDecision.project_id, Note.project_id)
.join(Note, Note.id == FamilyDecision.idea_id)
.where(FamilyDecision.id.in_(ids))
)
return {own or home for own, home in rows.all() if own or home}
model = {NOTE: Note, MILESTONE: Milestone, SYSTEM: System, RULE: Rule}[kind]
rows = await session.execute(select(model.project_id).where(model.id.in_(ids)))
return {p for p in rows.scalars() if p}
async def tool_targets(user_id: int, tool: str, args: dict) -> set[int]:
"""Every project the MCP call `tool(**args)` would change. Empty for a
read, a write that lands in no project, and a tool this server doesn't
have."""
found: set[int] = set()
for name, kind in WRITE_TARGETS.get(tool, {}).items():
if name in args:
found |= await _projects_of(user_id, kind, args[name])
return found
async def _titles(ids: list[int]) -> dict[int, str]:
from grimoire.models import async_session
from grimoire.models.project import Project
if not ids:
return {}
async with async_session() as session:
rows = await session.execute(select(Project.id, Project.title).where(Project.id.in_(ids)))
return {pid: title for pid, title in rows.all()}
def _named(project_id: int, titles: dict[int, str]) -> str:
title = titles.get(project_id)
return f"{title} (project {project_id})" if title else f"project {project_id}"
async def check_act(
user_id: int, session_project: int, *, tool: str = "", args: dict | None = None,
target_project: int = 0, act: str = "",
) -> dict:
"""May a session in `session_project` do this act?
The act is an MCP call (`tool` with `args`) or a directory already
resolved to `target_project`; `act` words it for the reason ("git push
in", "Editing a file in"). Returns:
- `allowed`: False when the act would change another project and isn't the
one cross-project write allowed;
- `targets`: the other projects, as {id, title};
- `issue`: the act is that allowed write, filing an issue;
- `reason`: what the operator reads in the prompt, and the session after.
A session with no project is held to nothing. A target the caller can't
read is left out: the write itself is refused there, so there is nothing
to ask about, and naming it would tell the caller where a record lives.
"""
from grimoire.services.access import can_read_project
args = args or {}
if tool:
landing = await tool_targets(user_id, tool, args)
else:
landing = {target_project} if target_project else set()
others = [
p for p in sorted(landing)
if p != session_project and await can_read_project(user_id, p)
]
if not session_project or not others:
return {"allowed": True, "targets": [], "issue": False, "reason": ""}
titles = await _titles([session_project, *others])
targets = [{"id": p, "title": titles.get(p, "")} for p in others]
if tool and is_issue_filing(tool, args):
return {"allowed": True, "targets": targets, "issue": True, "reason": ""}
there = ", ".join(_named(p, titles) for p in others)
what = act.strip() or (f"{tool} changes" if tool else "This changes")
first = others[0]
reason = (
f"This session belongs to {_named(session_project, titles)}. {what} {there}, "
f"a different project. From here, the one change to make there is filing an "
f"issue: create_task(project_id={first}, kind=\"issue\") with how it was found "
f"and the evidence. Approve only if this session should change {there} itself."
)
return {"allowed": False, "targets": targets, "issue": False, "reason": reason}