"""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}