From 805fda5cb194fe728f5f891eda35c0d589077673 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 4 Nov 2026 21:45:37 -0500 Subject: [PATCH] feat(6855): the plugin asks before a session writes to a project other than its own 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 --- plugin/.claude-plugin/plugin.json | 2 +- plugin/PACKAGING.md | 1 + plugin/README.md | 14 ++ plugin/hooks/grimoire_project_fence.sh | 240 +++++++++++++++++++++++++ plugin/hooks/hooks.json | 18 ++ scripts/check_plugin.py | 22 ++- src/grimoire/routes/plugin.py | 11 +- tests/test_project_fence.py | 192 ++++++++++++++++++++ tests/test_write_scope.py | 26 +++ 9 files changed, 519 insertions(+), 7 deletions(-) create mode 100644 plugin/hooks/grimoire_project_fence.sh create mode 100644 tests/test_project_fence.py diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index ac32ad78..c7b0ac7d 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "grimoire", "description": "Grimoire for Claude Code: connects the grimoire MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Grimoire skills (using-grimoire, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting, family-canon), and syncs your saved Grimoire Processes as skills (/grimoire:sync).", - "version": "2026.11.05.0242", + "version": "2026.11.05.0245", "author": { "name": "Bryan Van Deusen" }, diff --git a/plugin/PACKAGING.md b/plugin/PACKAGING.md index 9ee37e9a..f32929d6 100644 --- a/plugin/PACKAGING.md +++ b/plugin/PACKAGING.md @@ -39,6 +39,7 @@ another one means adding files, not moving or rewriting any. | `hooks/grimoire_prior_art.sh` | PreToolUse on editor writes: `GET /api/plugin/prior-art`. | | `hooks/grimoire_after_write.sh` | PostToolUse on shell commands: the same check for code written through the shell. | | `hooks/grimoire_tool_rules.sh` | PreToolUse on shell commands: `GET /api/plugin/tool-rules`. | +| `hooks/grimoire_project_fence.sh` | PreToolUse on editor writes, shell commands and Grimoire's tools: `GET /api/plugin/write-scope` for an act aimed at another project, answered `ask` with the reason; PostToolUse on `enter_project` remembers the project a session without one entered. | | `hooks/grimoire_moment.sh` | PreToolUse on every tool: the rules mounted on the moments the call reaches, `POST /api/plugin/moment`; skips tools `GET /api/plugin/moment-tools` says reach nothing mounted, and Grimoire's own (their responses carry `moment_rules`). | | `hooks/grimoire_reply_check.sh` | Stop: sends the finished reply to `POST /api/plugin/reply-rules` — the rules mounted on the reply moments, and the reply against every rule's trigger, and — when the turn closed a task — the completion-section check; holds once per rule, with the reason the server returns, and never holds the rewrite. | | `hooks/grimoire_shape_check.sh` | Stop: sends the definitions the turn wrote (the write hooks' `.written.ids` ledger) to `GET /api/plugin/shape-check`; blocks once, with the reason the server returns, so the agent judges what it built. | diff --git a/plugin/README.md b/plugin/README.md index 54afcd27..da002892 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -79,6 +79,20 @@ On install you'll be asked for: message, as a short menu of titles and matched passages. - `hooks/hooks.json` → PreToolUse hook on `Bash` (`hooks/grimoire_tool_rules.sh`) → `GET /api/plugin/tool-rules`: the rules that match the command about to run. +- `hooks/hooks.json` → PreToolUse hook on editor writes, `Bash` and Grimoire's + tools (`hooks/grimoire_project_fence.sh`) → `GET /api/plugin/write-scope`: + holds a session to its own project. A session belongs to the project its + starting directory resolves to (a `.grimoire` marker, else the git remote's + binding), or, started outside any, to the first project it enters. Editing + another project's files, a git commit, push, merge, rebase, pull, tag or + reset in its repo (from `git -C`, after `cd`, or in a worktree of it), and a + Grimoire write that lands in another project each stop on a permission + prompt naming that project, in every permission mode. Filing an issue there + (`create_task(kind="issue")`) and every read go ahead. An edit in the + session's own repo never calls the server. It can't see a non-git shell + write into another repo (`sed -i`, a redirect), but it fences that repo's + commit and push. When the instance doesn't answer, the act goes ahead and + the hook says so once per outage. - `hooks/hooks.json` → PreToolUse hook on every tool (`hooks/grimoire_moment.sh`) → `POST /api/plugin/moment`: the rules mounted on the moments the call reaches. It skips tools that `GET /api/plugin/moment-tools` says reach diff --git a/plugin/hooks/grimoire_project_fence.sh b/plugin/hooks/grimoire_project_fence.sh new file mode 100644 index 00000000..3b266bba --- /dev/null +++ b/plugin/hooks/grimoire_project_fence.sh @@ -0,0 +1,240 @@ +#!/usr/bin/env bash +# Grimoire — hold a session to its own project. +# +# 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 project it +# enters. Toward any other project a session may read anything and file an +# issue. Every other write stops on a permission prompt naming the other +# project, so the operator decides: +# - editing a file in another project's repo (Write, Edit, MultiEdit, +# NotebookEdit); +# - a git command that changes history or a remote there (commit, push, +# merge, rebase, pull, cherry-pick, revert, tag, am, reset, worktree add), +# wherever it runs from: `git -C DIR`, `cd DIR && git …`, or the shell's +# current directory; +# - a Grimoire write that lands in another project. +# +# `ask`, not `deny`. Which projects a session may change is the operator's +# call, and `ask` puts it to them each time, in every permission mode, +# bypassPermissions included. +# +# The server says where an act lands (GET /api/plugin/write-scope). Two cases +# never reach it: +# - a directory that resolves the same way as the session's own, which is +# nearly every edit; +# - a tool named like a read (READ_PREFIXES in services/write_scope.py, held +# equal to FENCE_READ_PREFIXES below by tests/test_write_scope.py). +# +# What it can't see: a non-git write from the shell (`sed -i`, a redirect) +# into another repo. That repo's commit and push are still fenced. +# +# EXIT 0, ALWAYS. When the server doesn't answer, the act goes ahead and the +# hook says once per outage that it couldn't check. +# +# Run as PostToolUse on enter_project, it records the project a session +# without one has entered, and says nothing. +set -uo pipefail + +# shellcheck source=plugin/hooks/grimoire_defs.sh +. "$(dirname "${BASH_SOURCE[0]}")/grimoire_defs.sh" + +FENCE_READ_PREFIXES="get_ list_ search find_ enter_project what_might_apply resolve_design_system" +FENCE_GIT_WRITES="commit push merge rebase pull cherry-pick revert tag am reset" + +event=$(cat 2>/dev/null || true) +[ -n "$event" ] || exit 0 +event_flat=$(printf '%s' "$event" | grimoire_json_flat) +[ -n "$event_flat" ] || exit 0 + +session_id=$(grimoire_json_pick "$event_flat" '.session_id') +tool_name=$(grimoire_json_pick "$event_flat" '.tool_name') +hook_event=$(grimoire_json_pick "$event_flat" '.hook_event_name') +cwd=$(grimoire_json_pick "$event_flat" '.cwd') +state_dir="${TMPDIR:-/tmp}/grimoire-priorart" +safe_sid=$(printf '%s' "${session_id:-none}" | tr -c 'A-Za-z0-9._-' '_') +entered_file="$state_dir/${safe_sid}.entered" + +# ── PostToolUse on enter_project: remember the first project entered ───── +if [ "$hook_event" = "PostToolUse" ]; then + case "$tool_name" in + *__enter_project) + pid=$(grimoire_json_pick "$event_flat" '.tool_input.project_id' | tr -cd '0-9') + if [ -n "$pid" ] && [ -n "$session_id" ] && [ ! -s "$entered_file" ]; then + mkdir -p "$state_dir" 2>/dev/null || true + printf '%s' "$pid" > "$entered_file" 2>/dev/null || true + fi + ;; + esac + exit 0 +fi + +# The nearest directory at or above $1 that exists. A Write may create the +# directories its file goes in. +fence_existing_dir() { + local d="$1" depth=0 + while [ -n "$d" ] && [ ! -d "$d" ] && [ "$depth" -lt 40 ]; do + d=$(dirname -- "$d" 2>/dev/null) || return 0 + depth=$((depth + 1)) + done + [ -d "$d" ] && printf '%s' "$d" +} + +# "DIRSUBCOMMAND" for each git command in the shell command $1 that +# changes history or a remote, with DIR resolved from $2: `cd` moves it for +# the segments after, `git -C` for that one command. Segments split on ; && || +# | and newlines, and a quoted separator inside a message splits too; the +# words after it are read as a command only if they start with cd or git. +fence_git_targets() { + printf '%s\n' "$1" | awk -v start="$2" -v home="${HOME:-}" -v writes="$FENCE_GIT_WRITES" ' + function unq(w) { gsub(/^["\047(]+|["\047)]+$/, "", w); return w } + function at(base, p) { + if (p ~ /^\//) return p + if (p == "~") return home + if (p ~ /^~\//) return home substr(p, 2) + return base "/" p + } + BEGIN { dir = start; n = split(writes, w, " "); for (i = 1; i <= n; i++) iswrite[w[i]] = 1 } + { + line = $0 + gsub(/&&|\|\||;|\|/, "\n", line) + m = split(line, segs, "\n") + for (s = 1; s <= m; s++) { + k = split(segs[s], t, /[ \t]+/) + i = 1 + while (i <= k && (t[i] == "" || t[i] ~ /^[A-Za-z_][A-Za-z0-9_]*=/)) i++ + if (i > k) continue + cmd = unq(t[i]) + if (cmd == "cd") { + if (i + 1 <= k && t[i + 1] != "") dir = at(dir, unq(t[i + 1])) + continue + } + if (cmd != "git" && cmd !~ /\/git$/) continue + gdir = dir; i++ + while (i <= k && t[i] ~ /^-/) { + if (t[i] == "-C" && i + 1 <= k) { gdir = at(gdir, unq(t[i + 1])); i += 2; continue } + if (t[i] == "-c" || t[i] == "--git-dir" || t[i] == "--work-tree") { i += 2; continue } + i++ + } + if (i > k) continue + sub_ = unq(t[i]) + if (sub_ == "worktree") { if (i + 1 <= k && unq(t[i + 1]) == "add") print gdir "\tworktree add"; continue } + if (sub_ in iswrite) print gdir "\t" sub_ + } + }' 2>/dev/null || true +} + +# The short scalar arguments of the MCP call, as a JSON object of strings: +# ids, kind, status, a repo url, and arrays of ids. Long text (bodies, code) is +# left out; nothing that says where a write lands is long. +fence_tool_input() { + printf '%s\n' "$event_flat" | awk -F'\t' ' + function add(k, v) { if (!(k in seen)) { seen[k] = 1; order[++n] = k } } + $2 ~ /^\.tool_input\.[A-Za-z_]+$/ && $3 != "null" && length($3) <= 200 { + k = substr($2, 13); add(k); val[k] = "\"" $3 "\"" + } + $2 ~ /^\.tool_input\.[A-Za-z_]+\[[0-9]+\]$/ && $3 != "null" && length($3) <= 200 { + k = substr($2, 13); sub(/\[.*$/, "", k); add(k) + arr[k] = (k in arr) ? arr[k] ",\"" $3 "\"" : "\"" $3 "\"" + } + END { + printf "{" + for (i = 1; i <= n; i++) { + k = order[i] + printf "%s\"%s\":%s", (i > 1 ? "," : ""), k, ((k in arr) ? "[" arr[k] "]" : val[k]) + } + printf "}" + }' 2>/dev/null || printf '{}' +} + +grimoire_config || exit 0 +command -v curl >/dev/null 2>&1 || exit 0 + +project_dir=${CLAUDE_PROJECT_DIR:-$PWD} +session_scope=$(grimoire_scope_query "$project_dir") +entered=$(cat "$entered_file" 2>/dev/null | tr -cd '0-9') +# No project to hold the session to: started outside any, and entered none. +[ -n "$session_scope" ] || [ -n "$entered" ] || exit 0 + +# Each check this act needs: "SCOPEACT" for a directory, or a single +# "tool" line for a Grimoire call. +checks="" +case "$tool_name" in + Write|Edit|MultiEdit|NotebookEdit) + path=$(grimoire_json_pick "$event_flat" '.tool_input.file_path') + [ -n "$path" ] || path=$(grimoire_json_pick "$event_flat" '.tool_input.notebook_path') + [ -n "$path" ] || exit 0 + case "$path" in /*) ;; *) path="${cwd:-$PWD}/$path" ;; esac + dir=$(fence_existing_dir "$(dirname -- "$path")") + [ -n "$dir" ] || exit 0 + scope=$(grimoire_scope_query "$dir") + [ -n "$scope" ] && [ "$scope" != "$session_scope" ] || exit 0 + checks=$(printf '%s\t%s' "$scope" "Editing a file in") + ;; + Bash) + command=$(grimoire_json_pick "$event_flat" '.tool_input.command') + case "$command" in *git*) ;; *) exit 0 ;; esac + while IFS=$'\t' read -r gdir gsub; do + [ -n "$gdir" ] || continue + [ -d "$gdir" ] || continue + scope=$(grimoire_scope_query "$gdir") + [ -n "$scope" ] && [ "$scope" != "$session_scope" ] || continue + checks="${checks}${checks:+$'\n'}${scope}"$'\t'"git ${gsub} in" + done </dev/null || true + now=$(date +%s 2>/dev/null) || now=0 + if [ -f "$marker" ]; then + last=$(cat "$marker" 2>/dev/null) || last=0 + case "$last" in ''|*[!0-9]*) last=0 ;; esac + [ $((now - last)) -lt 600 ] && return 0 + fi + printf '%s' "$now" > "$marker" 2>/dev/null || true + grimoire_json_out "PreToolUse" "> Grimoire did not answer the project fence check, so this act went ahead without being checked against the session's project. If it changes another project's files or records, stop and file an issue there instead. Said once per outage." +} + +while IFS= read -r check; do + [ -n "$check" ] || continue + if [ "$check" = "tool" ]; then + input=$(fence_tool_input | grimoire_urlenc) + q="tool=$(printf '%s' "$short" | grimoire_urlenc)&input=${input}" + else + scope=${check%%$'\t'*} + act=${check#*$'\t'} + q="target_${scope}&act=$(printf '%s' "$act" | grimoire_urlenc)" + fi + resp=$(curl -fsS --max-time 4 -H "Authorization: Bearer ${token}" "${base}${q}" 2>/dev/null) || { + fence_unreached + exit 0 + } + rm -f "$state_dir/${safe_sid}.fence.unreached" 2>/dev/null || true + flat=$(printf '%s' "$resp" | grimoire_json_flat) + if [ "$(grimoire_json_pick "$flat" '.allowed')" = "false" ]; then + reason=$(grimoire_json_pick "$flat" '.reason') + esc=$(printf '%s' "$reason" | grimoire_json_escape) || exit 0 + printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"%s"}}\n' "$esc" + exit 0 + fi +done < Grimoire did not answer the project fence check", +} def _run_hook(script: Path, event: str, env_extra: dict[str, str]) -> subprocess.CompletedProcess: @@ -464,13 +476,13 @@ def check_fail_open() -> None: f"output must survive having no credentials ({what})") else: ok(f"{rel} [{label}]: exit 0, {what}") - elif out and label == "unreachable" and script.name in OUTAGE_SPEAKERS: + elif out and label == "unreachable" and script.name in OUTAGE_LINES: # The only thing allowed here is the outage line itself. try: ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"] except (ValueError, KeyError, TypeError): ctx = "" - if ctx.startswith(OUTAGE_LINE): + if ctx.startswith(OUTAGE_LINES[script.name]): ok(f"{rel} [{label}]: exit 0, says the instance did not answer") else: fail(f"{rel} [{label}]: emitted output with no working instance " diff --git a/src/grimoire/routes/plugin.py b/src/grimoire/routes/plugin.py index 27a6fb47..553b931e 100644 --- a/src/grimoire/routes/plugin.py +++ b/src/grimoire/routes/plugin.py @@ -637,7 +637,9 @@ async def write_scope(): 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 + (`repo` / `project_id`, as every plugin endpoint reads them), or when + that resolves to none, to `fallback_project_id`: the first project the + session entered. 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 @@ -648,6 +650,13 @@ async def write_scope(): file edits and git commands. """ session_project, _repo, _unbound = await _project_scope() + if not session_project: + # A session that started outside any project belongs to the first one + # it entered; the hook remembers it. + try: + session_project = int(request.args.get("fallback_project_id") or 0) + except ValueError: + session_project = 0 if session_project and not await can_read_project(g.user.id, session_project): session_project = 0 tool = (request.args.get("tool") or "").strip() diff --git a/tests/test_project_fence.py b/tests/test_project_fence.py new file mode 100644 index 00000000..120a7e78 --- /dev/null +++ b/tests/test_project_fence.py @@ -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) == "" diff --git a/tests/test_write_scope.py b/tests/test_write_scope.py index f6b5888f..714bfc74 100644 --- a/tests/test_write_scope.py +++ b/tests/test_write_scope.py @@ -69,6 +69,16 @@ def test_a_reference_is_an_argument_the_tool_takes_and_not_also_a_locator(): assert arg not in WRITE_TARGETS[tool], f"{tool}.{arg} is both a locator and a reference" +def test_the_fence_hook_skips_exactly_the_read_prefixes_the_server_names(): + import re + from pathlib import Path + + hook = Path(__file__).resolve().parents[1] / "plugin" / "hooks" / "grimoire_project_fence.sh" + found = re.search(r'^FENCE_READ_PREFIXES="([^"]*)"$', hook.read_text(), re.M) + assert found, "grimoire_project_fence.sh no longer declares FENCE_READ_PREFIXES" + assert tuple(found.group(1).split()) == write_scope.READ_PREFIXES + + def test_no_write_tool_carries_a_read_prefix(): # The fence hook skips the server for these prefixes, so a write named # like a read would pass unchecked. @@ -212,6 +222,22 @@ async def test_the_route_resolves_a_target_repo_to_its_project(): assert check.call_args.kwargs["act"] == "git push in" +async def test_a_session_whose_directory_resolves_to_nothing_belongs_to_the_project_it_entered(): + from grimoire.app import create_app + from grimoire.routes import plugin + + app = create_app() + check = AsyncMock(return_value={"allowed": True, "targets": [], "issue": False, "reason": ""}) + url = ("/api/plugin/write-scope?fallback_project_id=7" + "&tool=add_task_log&input=%7B%22task_id%22%3A5%7D") + async with app.test_request_context(url): + g.user = SimpleNamespace(id=1) + with patch.object(plugin.write_scope_svc, "check_act", check), \ + patch.object(plugin, "can_read_project", AsyncMock(return_value=True)): + await plugin.write_scope.__wrapped__() + assert check.call_args.args == (1, 7) + + async def test_the_route_refuses_input_that_is_not_an_object(): from grimoire.app import create_app from grimoire.routes import plugin