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
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:
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user