feat(6854): the server says which project a pending act would change, and whether this session may do it
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Failing after 11s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Failing after 1m23s
CI & Build / integration (push) Successful in 1m53s
CI & Build / Build & push image (push) Skipped

GET /api/plugin/write-scope answers the plugin's fence. The session's project
comes from repo / project_id, as every plugin endpoint reads it. The act is
either:
- a directory (target_repo / target_project_id), for a file edit or a git
  command; or
- an MCP call (tool, plus input: the call's short arguments).

It returns allowed, the other projects the act would change, and the reason
the operator is asked. It is a GET so a read-scoped key still fences file
edits and git.

services/write_scope.WRITE_TARGETS lists every write tool with the arguments
that locate its write: a project id, or a task, note, milestone, System, rule,
relation, trash batch, repo or family decision, each traced to its project. A
write that lands in no project (install settings, user-level records, the
judgments a session is asked for anywhere) has an empty entry. The one
cross-project write allowed is create_task(kind="issue") as a new, unplanned,
open issue. A project the caller cannot read is never named.

tests/test_write_scope.py:
- requires an entry for every write tool;
- requires every declared argument to exist on the tool;
- refuses a locating argument (project_id, task_id, milestone_id and the
  rest) that a write tool takes without declaring it.

The integration test traces each locator from real rows.

Milestone 583, step 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 21:39:03 -05:00
co-authored by Claude Opus 5.5
parent 373f5bd454
commit 55f0f96016
6 changed files with 655 additions and 1 deletions
+53
View File
@@ -8,6 +8,8 @@ fmcp_<key>` API key (a read-scoped key suffices for this GET).
"""
from __future__ import annotations
import json
from quart import Blueprint, g, jsonify, request
from grimoire.auth import admin_required, get_current_user_id, login_required
@@ -21,6 +23,8 @@ from grimoire.services import report_check as report_check_svc
from grimoire.services import rule_moment_judgments as judgments_svc
from grimoire.services import shape_check as shape_check_svc
from grimoire.services import task_claims as task_claims_svc
from grimoire.services import write_scope as write_scope_svc
from grimoire.services.access import can_read_project
from grimoire.services.embeddings import memoized_query_embeddings
from grimoire.services.settings import get_admin_setting, set_setting
@@ -626,6 +630,55 @@ async def claim_session():
return jsonify({"claim": claim})
@plugin_bp.get("/write-scope")
@login_required
async def write_scope():
"""May this session do this act? Asked by `grimoire_project_fence.sh`
before a file edit, a git command or a Grimoire write.
A session belongs to the project its starting directory resolves to
(`repo` / `project_id`, as every plugin endpoint reads them). The act is
either a directory (`target_repo` / `target_project_id`) or an MCP call
(`tool`, with `input` as a JSON object of the call's short arguments: the
ids, kind and status that say where it writes). `act` words the act for the
reason, e.g. "git push in".
Returns `{allowed, session_project, targets, issue, reason}` from
services/write_scope.check_act. A GET, so a read-scoped key still fences
file edits and git commands.
"""
session_project, _repo, _unbound = await _project_scope()
if session_project and not await can_read_project(g.user.id, session_project):
session_project = 0
tool = (request.args.get("tool") or "").strip()
if tool.startswith("mcp__"):
tool = tool.rsplit("__", 1)[-1]
args: dict = {}
if tool:
try:
parsed = json.loads(request.args.get("input") or "{}")
except ValueError:
return jsonify({"error": "input must be a JSON object"}), 400
if not isinstance(parsed, dict):
return jsonify({"error": "input must be a JSON object"}), 400
args = parsed
target_project = 0
if not tool:
try:
target_project = int(request.args.get("target_project_id") or 0)
except ValueError:
target_project = 0
target_repo = (request.args.get("target_repo") or "").strip()
if not target_project and target_repo:
target_project = await repo_bindings_svc.resolve_project(g.user.id, target_repo) or 0
result = await write_scope_svc.check_act(
g.user.id, session_project, tool=tool, args=args,
target_project=target_project, act=(request.args.get("act") or "")[:80],
)
result["session_project"] = session_project or None
return jsonify(result)
@plugin_bp.get("/release-session")
@login_required
async def release_session():
+307
View File
@@ -0,0 +1,307 @@
"""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, 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",
})
# 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}