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():