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>
241 lines
9.6 KiB
Bash
241 lines
9.6 KiB
Bash
#!/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"
|
|
}
|
|
|
|
# "DIR<TAB>SUBCOMMAND" 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: "SCOPE<TAB>ACT" 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 <<EOF
|
|
$(fence_git_targets "$command" "${cwd:-$PWD}")
|
|
EOF
|
|
[ -n "$checks" ] || exit 0
|
|
;;
|
|
mcp__*)
|
|
short=${tool_name##*__}
|
|
for prefix in $FENCE_READ_PREFIXES; do
|
|
case "$short" in "$prefix"*) exit 0 ;; esac
|
|
done
|
|
checks="tool"
|
|
;;
|
|
*) exit 0 ;;
|
|
esac
|
|
|
|
base="${url%/}/api/plugin/write-scope?"
|
|
[ -n "$session_scope" ] && base="${base}${session_scope}&"
|
|
[ -n "$entered" ] && base="${base}fallback_project_id=${entered}&"
|
|
|
|
fence_unreached() {
|
|
local marker="$state_dir/${safe_sid}.fence.unreached" now last
|
|
mkdir -p "$state_dir" 2>/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 <<EOF
|
|
$checks
|
|
EOF
|
|
exit 0
|