feat(6855): the plugin asks before a session writes to a project other than its own
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Failing after 1m27s
CI & Build / integration (push) Successful in 2m1s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Failing after 1m27s
CI & Build / integration (push) Successful in 2m1s
CI & Build / Build & push image (push) Skipped
plugin/hooks/grimoire_project_fence.sh runs as PreToolUse on editor writes, Bash and Grimoire's tools. A session belongs to the project its starting directory resolves to (CLAUDE_PROJECT_DIR: a .grimoire marker, else the git remote's binding). A session that starts outside any project belongs to the first one it enters, which PostToolUse on enter_project records. These acts stop on permissionDecision "ask", with the server's reason naming the other project: - an edit in another project's repo or a worktree of it; - a git commit, push, merge, rebase, pull, cherry-pick, revert, tag, am, reset or worktree add there, found through git -C, a cd before it, or the shell's current directory; - a Grimoire write that lands in another project. A live probe showed "ask" holds in bypassPermissions. Filing an issue and every read go ahead. An edit or git command in the session's own repo, and any tool named like a read, never calls the server. Grimoire writes send only their short arguments, so the URL stays small. When the instance doesn't answer, the act goes ahead and the hook says once per outage that it couldn't check. check_plugin.py now holds one outage line per hook and has a smoke event for the fence. GET /api/plugin/write-scope takes fallback_project_id for the entered project. tests/test_project_fence.py drives the hook against real git repos and a stand-in server. tests/test_write_scope.py holds the hook's FENCE_READ_PREFIXES equal to services/write_scope.READ_PREFIXES. The plugin README and PACKAGING describe the fence, and the plugin is minted to 2026.11.05.0245. Milestone 583, step 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
"""The project fence, end to end: plugin/hooks/grimoire_project_fence.sh run
|
||||
through a real shell against real git repos and a stand-in server.
|
||||
|
||||
A session started in one repo edits, commits and pushes in another, and makes
|
||||
Grimoire writes. Each act aimed elsewhere is asked about in the server's
|
||||
words; acts in the session's own repo never reach the server.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers import http_sink, need_tools
|
||||
|
||||
HOOK = Path(__file__).resolve().parents[1] / "plugin" / "hooks" / "grimoire_project_fence.sh"
|
||||
PATH = "/api/plugin/write-scope"
|
||||
HOME_URL = "https://forge.example/team/home.git"
|
||||
OTHER_URL = "https://forge.example/team/other.git"
|
||||
ASK = json.dumps({"allowed": False, "targets": [{"id": 9, "title": "Other"}], "issue": False,
|
||||
"reason": "This session belongs to Home. Editing a file in Other, a different project."}).encode()
|
||||
OK = b'{"allowed": true, "targets": [], "issue": false, "reason": ""}'
|
||||
|
||||
|
||||
def _git(*args, cwd):
|
||||
subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True,
|
||||
env={**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@example.com",
|
||||
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@example.com"})
|
||||
|
||||
|
||||
def _repo(tmp_path: Path, name: str, remote: str) -> Path:
|
||||
path = tmp_path / name
|
||||
path.mkdir()
|
||||
_git("init", "-q", cwd=path)
|
||||
_git("remote", "add", "origin", remote, cwd=path)
|
||||
return path
|
||||
|
||||
|
||||
def _run(tmp_path, port, event, project_dir):
|
||||
need_tools("bash", "curl", "awk", "git")
|
||||
env = {"PATH": os.environ["PATH"], "GRIMOIRE_URL": f"http://127.0.0.1:{port}",
|
||||
"GRIMOIRE_TOKEN": "t", "TMPDIR": str(tmp_path / "tmp"), "HOME": str(tmp_path),
|
||||
"CLAUDE_PROJECT_DIR": str(project_dir)}
|
||||
(tmp_path / "tmp").mkdir(exist_ok=True)
|
||||
event = {"session_id": "s-fence", "hook_event_name": "PreToolUse", **event}
|
||||
out = subprocess.run(["bash", str(HOOK)], input=json.dumps(event), capture_output=True,
|
||||
text=True, env=env, timeout=30)
|
||||
assert out.returncode == 0, out.stderr
|
||||
return out.stdout.strip()
|
||||
|
||||
|
||||
def _asked(out: str) -> str:
|
||||
decision = json.loads(out)["hookSpecificOutput"]
|
||||
assert decision["permissionDecision"] == "ask"
|
||||
return decision["permissionDecisionReason"]
|
||||
|
||||
|
||||
def _edit(path, cwd):
|
||||
return {"tool_name": "Edit", "cwd": str(cwd),
|
||||
"tool_input": {"file_path": str(path), "old_string": "a", "new_string": "b"}}
|
||||
|
||||
|
||||
def _bash(command, cwd):
|
||||
return {"tool_name": "Bash", "cwd": str(cwd), "tool_input": {"command": command}}
|
||||
|
||||
|
||||
def test_an_edit_in_the_sessions_own_repo_never_asks_the_server(tmp_path):
|
||||
home = _repo(tmp_path, "home", HOME_URL)
|
||||
with http_sink(by_path={PATH: ASK}) as (port, seen):
|
||||
out = _run(tmp_path, port, _edit(home / "src" / "a.py", home), home)
|
||||
assert out == "" and seen == []
|
||||
|
||||
|
||||
def test_an_edit_in_another_projects_repo_is_asked_about_in_the_servers_words(tmp_path):
|
||||
home = _repo(tmp_path, "home", HOME_URL)
|
||||
other = _repo(tmp_path, "other", OTHER_URL)
|
||||
with http_sink(by_path={PATH: ASK}) as (port, seen):
|
||||
out = _run(tmp_path, port, _edit(other / "new_dir" / "b.py", home), home)
|
||||
assert "a different project" in _asked(out)
|
||||
q = seen[0]
|
||||
assert q["_path"] == PATH
|
||||
assert q["repo"] == [HOME_URL] and q["target_repo"] == [OTHER_URL]
|
||||
assert q["act"] == ["Editing a file in"]
|
||||
|
||||
|
||||
def test_an_edit_the_server_allows_goes_ahead_silently(tmp_path):
|
||||
home = _repo(tmp_path, "home", HOME_URL)
|
||||
other = _repo(tmp_path, "other", OTHER_URL)
|
||||
with http_sink(by_path={PATH: OK}) as (port, seen):
|
||||
out = _run(tmp_path, port, _edit(other / "b.py", home), home)
|
||||
assert out == "" and len(seen) == 1
|
||||
|
||||
|
||||
def test_an_edit_outside_every_repo_is_no_projects_business(tmp_path):
|
||||
home = _repo(tmp_path, "home", HOME_URL)
|
||||
loose = tmp_path / "loose"
|
||||
loose.mkdir()
|
||||
with http_sink(by_path={PATH: ASK}) as (port, seen):
|
||||
out = _run(tmp_path, port, _edit(loose / "notes.txt", home), home)
|
||||
assert out == "" and seen == []
|
||||
|
||||
|
||||
def test_a_commit_through_git_dash_c_is_checked_against_that_repo(tmp_path):
|
||||
home = _repo(tmp_path, "home", HOME_URL)
|
||||
other = _repo(tmp_path, "other", OTHER_URL)
|
||||
command = f'git -C {other} commit -m "tidy && push later"'
|
||||
with http_sink(by_path={PATH: ASK}) as (port, seen):
|
||||
out = _run(tmp_path, port, _bash(command, home), home)
|
||||
_asked(out)
|
||||
assert seen[0]["target_repo"] == [OTHER_URL]
|
||||
assert seen[0]["act"] == ["git commit in"]
|
||||
|
||||
|
||||
def test_a_push_after_cd_is_checked_against_the_repo_it_moved_to(tmp_path):
|
||||
home = _repo(tmp_path, "home", HOME_URL)
|
||||
_repo(tmp_path, "other", OTHER_URL)
|
||||
with http_sink(by_path={PATH: ASK}) as (port, seen):
|
||||
out = _run(tmp_path, port, _bash("cd ../other && git push origin dev", home), home)
|
||||
_asked(out)
|
||||
assert seen[0]["target_repo"] == [OTHER_URL] and seen[0]["act"] == ["git push in"]
|
||||
|
||||
|
||||
def test_git_commands_that_change_nothing_and_writes_at_home_stay_local(tmp_path):
|
||||
home = _repo(tmp_path, "home", HOME_URL)
|
||||
other = _repo(tmp_path, "other", OTHER_URL)
|
||||
with http_sink(by_path={PATH: ASK}) as (port, seen):
|
||||
assert _run(tmp_path, port, _bash(f"git -C {other} status && git -C {other} log -1", home), home) == ""
|
||||
assert _run(tmp_path, port, _bash("git commit -m x && git push", home), home) == ""
|
||||
assert _run(tmp_path, port, _bash("echo hello", home), home) == ""
|
||||
assert seen == []
|
||||
|
||||
|
||||
def test_a_worktree_of_another_repo_belongs_to_that_repo(tmp_path):
|
||||
home = _repo(tmp_path, "home", HOME_URL)
|
||||
other = _repo(tmp_path, "other", OTHER_URL)
|
||||
_git("commit", "-q", "--allow-empty", "-m", "start", cwd=other)
|
||||
tree = tmp_path / "other-tree"
|
||||
_git("worktree", "add", "-q", str(tree), cwd=other)
|
||||
with http_sink(by_path={PATH: ASK}) as (port, seen):
|
||||
out = _run(tmp_path, port, _edit(tree / "c.py", home), home)
|
||||
_asked(out)
|
||||
assert seen[0]["target_repo"] == [OTHER_URL]
|
||||
|
||||
|
||||
def test_a_grimoire_write_sends_its_short_arguments_and_is_asked_about(tmp_path):
|
||||
home = _repo(tmp_path, "home", HOME_URL)
|
||||
event = {"tool_name": "mcp__plugin_grimoire_grimoire__update_task", "cwd": str(home),
|
||||
"tool_input": {"task_id": 5, "status": "done", "body": "x" * 500,
|
||||
"system_ids": [3, 4]}}
|
||||
with http_sink(by_path={PATH: ASK}) as (port, seen):
|
||||
out = _run(tmp_path, port, event, home)
|
||||
_asked(out)
|
||||
q = seen[0]
|
||||
assert q["tool"] == ["update_task"]
|
||||
assert json.loads(q["input"][0]) == {"task_id": "5", "status": "done", "system_ids": ["3", "4"]}
|
||||
|
||||
|
||||
def test_a_grimoire_read_never_asks_the_server(tmp_path):
|
||||
home = _repo(tmp_path, "home", HOME_URL)
|
||||
event = {"tool_name": "mcp__plugin_grimoire_grimoire__get_task", "cwd": str(home),
|
||||
"tool_input": {"task_id": 5}}
|
||||
with http_sink(by_path={PATH: ASK}) as (port, seen):
|
||||
assert _run(tmp_path, port, event, home) == ""
|
||||
assert seen == []
|
||||
|
||||
|
||||
def test_a_session_started_outside_any_project_takes_the_first_one_it_enters(tmp_path):
|
||||
loose = tmp_path / "loose"
|
||||
loose.mkdir()
|
||||
write = {"tool_name": "mcp__plugin_grimoire_grimoire__add_task_log", "cwd": str(loose),
|
||||
"tool_input": {"task_id": 5, "content": "c"}}
|
||||
with http_sink(by_path={PATH: ASK}) as (port, seen):
|
||||
# Nothing to hold the session to yet.
|
||||
assert _run(tmp_path, port, write, loose) == ""
|
||||
assert seen == []
|
||||
for pid in (7, 8):
|
||||
_run(tmp_path, port, {"hook_event_name": "PostToolUse", "cwd": str(loose),
|
||||
"tool_name": "mcp__plugin_grimoire_grimoire__enter_project",
|
||||
"tool_input": {"project_id": pid}}, loose)
|
||||
_asked(_run(tmp_path, port, write, loose))
|
||||
assert seen[0]["fallback_project_id"] == ["7"]
|
||||
assert "repo" not in seen[0]
|
||||
|
||||
|
||||
def test_an_unanswered_check_lets_the_act_through_and_says_so_once(tmp_path):
|
||||
home = _repo(tmp_path, "home", HOME_URL)
|
||||
other = _repo(tmp_path, "other", OTHER_URL)
|
||||
first = _run(tmp_path, 1, _edit(other / "b.py", home), home)
|
||||
ctx = json.loads(first)["hookSpecificOutput"]["additionalContext"]
|
||||
assert ctx.startswith("> Grimoire did not answer the project fence check")
|
||||
assert _run(tmp_path, 1, _edit(other / "b.py", home), home) == ""
|
||||
Reference in New Issue
Block a user