Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 17 additions & 14 deletions plugins/procedures/agents/procedure-scout.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,22 +91,25 @@ confident voice and carries a `proc.` id.
truncation notice, and dumping before selecting wastes context on records a
gloss would have excluded.

4. **Pull the traps.** For the same terms, sweep `references/failure-modes/`,
`references/solutions/`, and recall — **count first, then read**:
4. **Pull the traps.** For the same terms, sweep the failure-kind stores from
step 2's `--list-stores` boundary, then recall:

```bash
M="${CODEX_ROOT:-$HOME/.claude}/mistakes.jsonl"
grep -icE '<term set>' "$M" # how many matched
grep -iE '<term set>' "$M" | tail -20 # the 20 most recent
bash "${CLAUDE_PLUGIN_ROOT}/scripts/query-records.sh" --recall '<term set>'
```
A procedure tells the caller what to do; these tell them what has already
gone wrong doing it. The second is usually the more valuable half of your
answer. No new terms and no new stores after this step — reading the records
this sweep named is part of it, via step 3's `awk` batch-read.
⚠ if the count exceeds the 20 you read, say so in STANDING NOTES — a cap is
allowed, a SILENT one is not; the caller cannot weigh what you did not show
them. ⚠ never drop the count: `grep -i` is unanchored and matches inside
paths and URLs, so a broad term set can match every line of a 400KB+ file —
reading it whole is the latency this agent exists to avoid

Output is a `recall: N matched` count line, then the 20 most recent hits
(raise `--limit` only when the count says more exist and the overflow is
plausibly on-goal). Whitespace separates terms; keep phrases hyphenated
(`pickup-loop`), never split them. No new terms and no new stores after
this step — reading the records this sweep named is part of it, via step
3's `awk` batch-read.
⚠ never fall back to a raw `grep` over `mistakes.jsonl` — unanchored
`grep -i` matches inside paths and URLs; `--recall` matches only semantic
field values, whole-word.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
⚠ if the count exceeds the hits you read, say so in STANDING NOTES — a cap
is allowed, a SILENT one is not; the caller cannot weigh what you did not
show them

5. **Return the proposal.** Nothing else — no preamble, no narration of your
search. If steps 2-4 found nothing, emit only the `NOT FOUND` section of the
Expand Down
2 changes: 1 addition & 1 deletion plugins/procedures/hooks/how-do-i-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ SID="$(ts_session_id "$INPUT")"
ts_turn_started "$SID" || gate_failopen "how-do-i" "reset-hook-never-ran" "$SID"
ts_is_marked "$SID" how_do_i && exit 0

jq -nc --arg r "HOW-DO-I-GATE: this turn has not run Skill(how-do-i). Run it before acting, then retry. Reads under references/procedures/ and read-only discovery commands stay available — look first, then ask, then act (CLAUDE.md invariant; enforced by hooks/how-do-i-gate.sh)." '{
jq -nc --arg r "HOW-DO-I-GATE: this turn has not run Skill(how-do-i). Run it before acting, then retry. File reads and read-only shell inspection stay available — look first, then ask, then act (CLAUDE.md invariant; enforced by hooks/how-do-i-gate.sh)." '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
Expand Down
72 changes: 45 additions & 27 deletions plugins/procedures/hooks/lib/gate-allowlist.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,36 @@
# itself is the compliance path. Without this the gate denies the
# Agent call that would satisfy it. Added 2026-08-02 with the
# delegating skills; the pre-delegation gate did not need it.
# Read — of a file under references/procedures/ (the corpus read).
# Bash — read-only shapes on the discovery surface only.
# Read — any file read (Read / Grep / Glob / NotebookRead).
# Bash — any command line that only reads (see lib/readonly-shape.sh).
#
# GATE ON ACT, NOT ON LOOK: read-only inspection is allowlisted because a good
# /how-do-i query cannot be formed before you know what the turn is about. On a
# diagnostic turn ("the box is down") the useful query depends on what you find.
# Forcing the skill before any look yields a blind query and a wrong digest.
#
# WHY THE DISCOVERY-PATH CO-REQUIREMENT IS GONE: until 2026-08-09 a look also
# had to NAME `references/procedures/` — a Read anywhere else, or a
# `tmux capture-pane | grep | tail`, was denied even though the deny message
# promises read-only commands stay available. That co-requirement was never
# the safety property it looked like: `rm -rf references/procedures/` names the
# surface too. The real property is MUTATION, and it is now decided by a
# command classifier that judges the whole pipeline and fails closed.

set -uo pipefail

# readonly-shape.sh answers "does this Bash line mutate?". Sourced defensively:
# if it is unreadable, `ros_is_read_only` is undefined and every Bash call is
# denied — the pre-fix behaviour for all but a handful of shapes, and a deny
# costs one Skill(how-do-i), so degradation is safe.
# ${BASH_SOURCE[0]%/*} is the file's own name when sourced by a bare name
# (no slash) — guard with the same fallback how-do-i-gate.sh uses, so a
# future caller doesn't silently lose Bash inspection.
GAL_LIB_DIR="${BASH_SOURCE[0]%/*}"
[ "$GAL_LIB_DIR" = "${BASH_SOURCE[0]}" ] && GAL_LIB_DIR="."
# shellcheck source=readonly-shape.sh
. "$GAL_LIB_DIR/readonly-shape.sh" 2>/dev/null || true

# gal_is_compliance_path <tool_name> <payload> — 0 when the call must be allowed
# regardless of outstanding invariants.
gal_is_compliance_path() {
Expand All @@ -30,35 +50,33 @@ gal_is_compliance_path() {
Skill|Agent) return 0 ;;
esac

if [ "$tool" = "Read" ]; then
local fp
fp="$(printf '%s' "$payload" | jq -r '(.tool_input.file_path // .input.file_path) // empty' 2>/dev/null || true)"
case "$fp" in
# Traversal is rejected BEFORE the glob: a path like
# .../references/procedures/../../CLAUDE.md matches the glob yet
# escapes the tree, which would leak arbitrary reads.
*..*) return 1 ;;
*/references/procedures/*|references/procedures/*) return 0 ;;
esac
return 1
fi
case "$tool" in
Read|NotebookRead|Grep|Glob)
# Traversal fields differ per tool: Glob targets via `pattern` and
# Grep can scope via `glob`. Grep's `pattern` is a REGEX where `..`
# is legitimate (any-two-chars), so it is deliberately not checked.
local fp
fp="$(printf '%s' "$payload" | jq -r --arg tool "$tool" '
(.tool_input // .input // {})
| if $tool == "Grep" then [(.path // ""), (.glob // "")]
elif $tool == "Glob" then [(.path // ""), (.pattern // "")]
else [(.file_path // .notebook_path // .path // "")] end
| join(" ")' 2>/dev/null || true)"
# Traversal stays refused. A read is no longer scoped to a tree, so
# this no longer guards an escape — it is kept because a `..` path
# is nearly always an unintended read of somewhere else, and the
# absolute form of the same read is always available.
case "$fp" in
*..*) return 1 ;;
esac
return 0 ;;
esac
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if [ "$tool" = "Bash" ]; then
command -v ros_is_read_only >/dev/null 2>&1 || return 1
local cmd
cmd="$(printf '%s' "$payload" | jq -r '(.tool_input.command // .input.command) // empty' 2>/dev/null || true)"
case "$cmd" in
*..*) return 1 ;;
esac
# SUBSTRING MATCHING ALONE IS UNSAFE: a destructive command that merely
# CONTAINS `references/procedures/` (e.g. as an rm argument) would pass.
# Anchor on a known read-only invocation shape AND the discovery surface.
case "$cmd" in
grep\ *|ls\ *|cat\ *|rg\ *|sed\ -n\ *|bash\ scripts/query-records.sh*|scripts/query-records.sh*)
case "$cmd" in
*references/procedures/*|*query-records.sh*) return 0 ;;
esac
;;
esac
ros_is_read_only "$cmd" && return 0
return 1
fi

Expand Down
250 changes: 250 additions & 0 deletions plugins/procedures/hooks/lib/readonly-shape.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
#!/usr/bin/env bash
# readonly-shape.sh — is a shell command line a pure LOOK, or does it ACT?
#
# SINGLE RESPONSIBILITY: classify one Bash command line as read-only or not.
# Policy about which gate honours that answer lives in gate-allowlist.sh.
#
# WHY A CLASSIFIER AND NOT A PATH TEST: the gates promise that read-only
# inspection stays available, so the question a gate needs answered is "does
# this MUTATE?" — not "which directory does it name?". Keying on a discovery
# path denied `tmux capture-pane | grep | tail` and `wc -l < f`, which are
# looks, while a path substring is no evidence of read-onlyness at all
# (`rm -rf references/procedures/` contains one).
#
# THE SHAPE OF THE ANSWER: separators (`;` `&&` `||` `|` `&`) are not the
# danger — what they RUN is. So every separator is split on and EVERY resulting
# segment must be a known-read-only invocation. A separator that is really
# inside quotes splits a segment badly, which yields an unrecognised command
# and a deny: quoting can only ever cost an allow, never buy one.
#
# FAILS CLOSED. A shape this cannot judge confidently is NOT read-only. The
# cost of a false deny is one `Skill(how-do-i)` call; the cost of a false allow
# is an unenforced invariant. Hence an ALLOWLIST of commands with no write
# mode, guarded by an up-front rejection of the constructs that hide a command
# from the segment scan entirely: command substitution (`$(…)`, backticks),
# process substitution (`<(…)`), and output redirection to a file. Interpreters
# — python, perl, node, awk, sh — are deliberately absent: an arbitrary program
# is precisely the thing that cannot be judged without running it.

set -uo pipefail

# ros__nth_nonflag <n> <words…> — prints the Nth argument not starting with `-`.
# Used to reach a subcommand past global flags (`git -C x status`, `systemctl
# --user status`). A flag taking a SEPARATE value (`tmux -f FILE cmd`) yields
# the value instead of the subcommand and therefore fails closed.
ros__nth_nonflag() {
local want="${1:-1}" seen=0 w
shift
for w in "$@"; do
case "$w" in
-*) ;;
*)
seen=$((seen + 1))
if [ "$seen" -eq "$want" ]; then printf '%s' "$w"; return 0; fi
;;
esac
done
return 1
}

# ros__git_is_read_only <args-after-git…>
ros__git_is_read_only() {
local sub a
# Options that make an otherwise-read-only subcommand execute an external
# program: `grep -O<pager>`/`--open-files-in-pager`, `diff --ext-diff`,
# and `--config-env` (injects config such as core.pager from env). Rejected
# before the subcommand allowlist. Attached `-c<k>=<v>` is invalid git
# syntax, and separate `-c <k>=<v>` already fails closed: its value word
# becomes the "subcommand" and misses the allowlist.
for a in "$@"; do
case "$a" in
-O*|--open-files-in-pager*|--ext-diff|--config-env*) return 1 ;;
esac
done
sub="$(ros__nth_nonflag 1 "$@")"
case "$sub" in
status|log|show|diff|blame|describe|shortlog|rev-parse|rev-list|\
ls-files|ls-tree|cat-file|show-ref|whatchanged|grep)
return 0 ;;
worktree)
# `worktree add/remove/prune` all mutate; only `list` reports.
[ "$(ros__nth_nonflag 2 "$@")" = "list" ] && return 0
return 1 ;;
branch|remote|tag|config|stash)
# Bare, these list. Given an operand they create, delete, rename or
# set — so any second non-flag word, or any write flag, is an act.
# (`git stash` with no operand PUSHES a stash, so it is excluded
# from the bare-listing allowance below by the flag scan alone —
# it is listed here only to reach that scan, and `stash list` is
# caught by the second-operand rule. Both end in a deny.)
case "$sub" in stash) return 1 ;; esac
ros__nth_nonflag 2 "$@" >/dev/null 2>&1 && return 1
for a in "$@"; do
case "$a" in
-d|-D|-m|-M|-c|-C|-f|-u|--delete*|--move*|--copy*|--force|\
--set-upstream*|--unset*|--add|--prune|--edit-description|\
--replace-all|--rename-section|--remove-section)
return 1 ;;
esac
done
return 0 ;;
esac
return 1
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# ros__stage_is_read_only <segment> — 0 when this single invocation cannot write.
ros__stage_is_read_only() {
local stage="${1:-}"
# `<` only ever reads its operand, so drop it and judge the words. Output
# redirection never reaches here — ros_is_read_only rejects it up front.
stage="${stage//</ }"

local restore_glob=0
case $- in *f*) : ;; *) restore_glob=1 ;; esac
set -f
local IFS=$' \t\n'
# shellcheck disable=SC2086 # deliberate: split the segment into words.
set -- $stage
[ "$restore_glob" -eq 1 ] && set +f

# An empty segment is a separator artefact (`ls;`), not a command.
[ "$#" -gt 0 ] || return 0

local bin="${1##*/}"
shift
# `FOO=bar cmd` — the real command is a word we have not vetted.
case "$bin" in *=*) return 1 ;; esac

local a
case "$bin" in
# No write mode, whatever the arguments. `cd` changes only this shell's
# own working directory, which no file outlives. (`ip` is deliberately
# absent: `ip link set`/`addr add`/`route del` mutate network state.)
cat|head|tail|nl|wc|cut|tr|rev|column|comm|diff|cmp|\
basename|dirname|realpath|readlink|echo|printf|pwd|cd|whoami|\
hostname|uname|id|stat|file|du|df|free|uptime|ps|pgrep|pstree|\
jq|grep|egrep|fgrep|rg|ls|tree|seq|md5sum|sha256sum|which|\
hexdump|strings|lsof|ss|getent|type|true|test)
return 0 ;;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Read-only unless asked to edit in place.
sed)
for a in "$@"; do
case "$a" in -i*|--in-place*) return 1 ;; esac
done
return 0 ;;

# Read-only unless an option or operand names an OUTPUT file / sets
# system state.
sort)
for a in "$@"; do
case "$a" in -o*|--output*) return 1 ;; esac
done
return 0 ;;
yq)
for a in "$@"; do
case "$a" in -i|--in-place*) return 1 ;; esac
done
return 0 ;;
date)
for a in "$@"; do
case "$a" in -s*|--set*) return 1 ;; esac
done
return 0 ;;
uniq|xxd)
# A second file operand is an output file (`uniq in out`,
# `xxd -r in out`).
ros__nth_nonflag 2 "$@" >/dev/null 2>&1 && return 1
return 0 ;;

# A reader until an action primary turns it into a writer.
find)
for a in "$@"; do
case "$a" in
-delete|-exec|-execdir|-ok|-okdir|-fprintf|-fprint|-fprint0|-fls)
return 1 ;;
esac
done
return 0 ;;

git)
# Explicit returns: a bare call would only set the case's status,
# and execution would fall through to the deny below.
ros__git_is_read_only "$@" && return 0
return 1 ;;

# Multiplexers: only their reporting subcommands.
tmux)
case "$(ros__nth_nonflag 1 "$@")" in
capture-pane|list-sessions|list-panes|list-windows|\
list-clients|list-buffers|ls|display-message|show-options|\
show-environment|has-session)
return 0 ;;
esac
return 1 ;;
docker)
case "$(ros__nth_nonflag 1 "$@")" in
ps|logs|inspect|images|stats|top|version|info|port|diff)
return 0 ;;
esac
return 1 ;;
systemctl)
case "$(ros__nth_nonflag 1 "$@")" in
status|show|cat|list-units|list-timers|list-unit-files|\
is-active|is-enabled|is-failed)
return 0 ;;
esac
return 1 ;;
journalctl)
for a in "$@"; do
case "$a" in --vacuum*|--rotate|--flush|--sync) return 1 ;; esac
done
return 0 ;;
esac

return 1
}

# ros_is_read_only <command-line> — 0 when the WHOLE line only reads. Judged as
# a whole because `cat x | tee y` writes even though its first stage does not.
ros_is_read_only() {
local cmd="${1:-}"
[ -n "$cmd" ] || return 1

# Discarding output is not writing. Stripped BEFORE the redirection check so
# the overwhelmingly common `2>/dev/null` / `2>&1` do not cost an allow.
local scan="$cmd"
scan="${scan//2>&1/ }"
scan="${scan//>&2/ }"
scan="${scan//&>\/dev\/null/ }"
scan="${scan//2>\/dev\/null/ }"
scan="${scan//>\/dev\/null/ }"

# Constructs that hide a command from the segment scan below.
case "$scan" in
*'$('*|*'`'*|*'<('*|*'>'*|*$'\n'*) return 1 ;;
esac

# Normalise every separator to a single `|`, then require each segment to
# be read-only. `&&` and `||` collapse first so the doubled form does not
# leave an empty field that IFS splitting would silently drop.
scan="${scan//&&/|}"
scan="${scan//||/|}"
scan="${scan//;/|}"
scan="${scan//&/|}"

local restore_glob=0
case $- in *f*) : ;; *) restore_glob=1 ;; esac
set -f
local IFS='|'
# shellcheck disable=SC2086 # deliberate: split the line into segments.
set -- $scan
[ "$#" -gt 0 ] || { [ "$restore_glob" -eq 1 ] && set +f; return 1; }
[ "$restore_glob" -eq 1 ] && set +f

local stage
for stage in "$@"; do
ros__stage_is_read_only "$stage" || return 1
done
return 0
}
Loading
Loading