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:
+17
-5
@@ -395,6 +395,13 @@ SMOKE_EVENTS: dict[str, str] = {
|
||||
{"session_id": "smoke", "cwd": ".", "hook_event_name": "SessionEnd",
|
||||
"reason": "prompt_input_exit"}
|
||||
),
|
||||
# The project fence. A Grimoire write is the one act that always asks the
|
||||
# server, so it is what reaches the network in the unreachable case: there
|
||||
# the act goes ahead and the hook says once that it couldn't check.
|
||||
"grimoire_project_fence.sh": json.dumps(
|
||||
{"session_id": "smoke", "cwd": ".", "hook_event_name": "PreToolUse",
|
||||
"tool_name": "mcp__grimoire__update_task", "tool_input": {"task_id": 1}}
|
||||
),
|
||||
# The shared library is sourced, never run; executed bare it defines
|
||||
# functions and exits — silent by construction.
|
||||
"grimoire_defs.sh": "",
|
||||
@@ -415,9 +422,14 @@ STATIC_EMITTERS = {
|
||||
# would look like a leak; it is the opposite.
|
||||
"grimoire_precompact_preserve.sh": "preservation instructions present",
|
||||
}
|
||||
# The hooks that say so when a configured instance does not answer.
|
||||
OUTAGE_SPEAKERS = {"grimoire_prior_art.sh", "grimoire_after_write.sh"}
|
||||
OUTAGE_LINE = "> Grimoire did not answer the prior-art check"
|
||||
# The hooks that say so when a configured instance does not answer, each with
|
||||
# the line it says it in.
|
||||
_PRIOR_ART_OUTAGE = "> Grimoire did not answer the prior-art check"
|
||||
OUTAGE_LINES = {
|
||||
"grimoire_prior_art.sh": _PRIOR_ART_OUTAGE,
|
||||
"grimoire_after_write.sh": _PRIOR_ART_OUTAGE,
|
||||
"grimoire_project_fence.sh": "> 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 "
|
||||
|
||||
Reference in New Issue
Block a user