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
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>
339 lines
15 KiB
Python
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}
|