From b83c5b1f8db5c83525ee3be1d2030f8bc1e40ba1 Mon Sep 17 00:00:00 2001 From: The Technician Date: Sun, 9 Aug 2026 16:28:27 +0000 Subject: [PATCH 1/4] fix(procedures): gate how-do-i on mutation, not on the discovery path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deny message promises read-only inspection stays available; the allowlist did not deliver it. A Read was allowed only under references/procedures/, and a Bash command had to BOTH match a small shape set AND name the discovery surface — so pure reads like `tmux capture-pane | grep | tail`, `wc -l < f` and a Read of a task's own output file were denied. Replace the path co-requirement with a command classifier (hooks/lib/readonly-shape.sh) that judges the whole line: separators are split and every segment must be a known read-only invocation, while command substitution, process substitution and output redirection to a file are refused outright. Unrecognised shapes fail closed. Co-Authored-By: Claude Opus 5 --- plugins/procedures/hooks/how-do-i-gate.sh | 2 +- .../procedures/hooks/lib/gate-allowlist.sh | 61 ++--- .../procedures/hooks/lib/readonly-shape.sh | 215 ++++++++++++++++++ .../procedures/hooks/tests/gate-failopen.bats | 11 +- plugins/procedures/hooks/tests/gate-libs.bats | 167 ++++++++++++++ 5 files changed, 426 insertions(+), 30 deletions(-) create mode 100644 plugins/procedures/hooks/lib/readonly-shape.sh diff --git a/plugins/procedures/hooks/how-do-i-gate.sh b/plugins/procedures/hooks/how-do-i-gate.sh index d9dada7..d5a3bf7 100755 --- a/plugins/procedures/hooks/how-do-i-gate.sh +++ b/plugins/procedures/hooks/how-do-i-gate.sh @@ -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", diff --git a/plugins/procedures/hooks/lib/gate-allowlist.sh b/plugins/procedures/hooks/lib/gate-allowlist.sh index 99c1f8c..d0ea971 100644 --- a/plugins/procedures/hooks/lib/gate-allowlist.sh +++ b/plugins/procedures/hooks/lib/gate-allowlist.sh @@ -11,16 +11,31 @@ # 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. +# shellcheck source=readonly-shape.sh +. "${BASH_SOURCE[0]%/*}/readonly-shape.sh" 2>/dev/null || true + # gal_is_compliance_path — 0 when the call must be allowed # regardless of outstanding invariants. gal_is_compliance_path() { @@ -30,35 +45,27 @@ 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) + local fp + fp="$(printf '%s' "$payload" | jq -r ' + (.tool_input // .input // {}) + | (.file_path // .notebook_path // .path // "")' 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 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 diff --git a/plugins/procedures/hooks/lib/readonly-shape.sh b/plugins/procedures/hooks/lib/readonly-shape.sh new file mode 100644 index 0000000..b4094ad --- /dev/null +++ b/plugins/procedures/hooks/lib/readonly-shape.sh @@ -0,0 +1,215 @@ +#!/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 — 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 +ros__git_is_read_only() { + local sub a + 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 +} + +# ros__stage_is_read_only — 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// — 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 +} diff --git a/plugins/procedures/hooks/tests/gate-failopen.bats b/plugins/procedures/hooks/tests/gate-failopen.bats index 10aefbf..7372cd5 100644 --- a/plugins/procedures/hooks/tests/gate-failopen.bats +++ b/plugins/procedures/hooks/tests/gate-failopen.bats @@ -163,9 +163,16 @@ unreadable_lib() { # reader looking for the thing that is already there. See #233. What we # CAN and do pin: the multiplication is real, so a future rate calculation # is not surprised by it. - local P1="{\"session_id\":\"$SID\",\"tool_name\":\"Read\",\"tool_input\":{\"file_path\":\"/tmp/a\"}}" + # + # All three payloads must be ACTS. The allowlist short-circuits ahead of the + # turn-state lookup — deliberately, so a compliance call never depends on + # state — so an allowlisted LOOK exits before this recorder is reached and + # contributes no row. Until 2026-08-09 `Read /tmp/a` and `pwd` were denied + # and did contribute; now they are looks, and using them here would measure + # the allowlist rather than the per-call multiplication this pins. + local P1="{\"session_id\":\"$SID\",\"tool_name\":\"Write\",\"tool_input\":{\"file_path\":\"/tmp/a\"}}" local P2="{\"session_id\":\"$SID\",\"tool_name\":\"Edit\",\"tool_input\":{\"file_path\":\"/tmp/b\"}}" - local P3="{\"session_id\":\"$SID\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"pwd\"}}" + local P3="{\"session_id\":\"$SID\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"rm -rf /tmp/c\"}}" run drive "how-do-i-gate.sh" technician "$P1" run drive "how-do-i-gate.sh" technician "$P2" run drive "how-do-i-gate.sh" technician "$P3" diff --git a/plugins/procedures/hooks/tests/gate-libs.bats b/plugins/procedures/hooks/tests/gate-libs.bats index af1ec7d..4982dbc 100644 --- a/plugins/procedures/hooks/tests/gate-libs.bats +++ b/plugins/procedures/hooks/tests/gate-libs.bats @@ -149,3 +149,170 @@ teardown() { run gal_is_compliance_path Edit '{"tool_input":{"file_path":"/tmp/x"}}' [ "$status" -ne 0 ] } + +# ---------- gate-allowlist: gate on ACT, not on LOOK (2026-08-09) ---------- +# +# Six confirmed misfires on 2026-08-09: pure reads denied because the old +# allowlist demanded the command also NAME `references/procedures/`. Each +# shape below is one of them. The negatives right after prove the widening +# did not open a write path. + +@test "allowlist: a read of ANY file is allowed, not just the procedures tree" { + source "$LIB/gate-allowlist.sh" + run gal_is_compliance_path Read '{"tool_input":{"file_path":"/tmp/claude/tasks/btwvalrxw.output"}}' + [ "$status" -eq 0 ] +} + +@test "allowlist: Grep and Glob are looks" { + source "$LIB/gate-allowlist.sh" + run gal_is_compliance_path Grep '{"tool_input":{"pattern":"foo","path":"/var/log"}}' + [ "$status" -eq 0 ] + run gal_is_compliance_path Glob '{"tool_input":{"pattern":"**/*.sh"}}' + [ "$status" -eq 0 ] +} + +@test "allowlist: tmux capture-pane piped through grep and tail is a look" { + source "$LIB/gate-allowlist.sh" + run gal_is_compliance_path Bash '{"tool_input":{"command":"tmux capture-pane -t amzcart2 -p -S -60 | grep -v boring | tail -40"}}' + [ "$status" -eq 0 ] +} + +@test "allowlist: a plain file read through Bash is a look" { + source "$LIB/gate-allowlist.sh" + run gal_is_compliance_path Bash '{"tool_input":{"command":"cat /home/u/notes/2026-08-09.md"}}' + [ "$status" -eq 0 ] +} + +@test "allowlist: input redirection reads, so wc -l < file is a look" { + source "$LIB/gate-allowlist.sh" + run gal_is_compliance_path Bash '{"tool_input":{"command":"wc -l < decisions.jsonl"}}' + [ "$status" -eq 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"tail -4 decisions.jsonl | jq -r .id"}}' + [ "$status" -eq 0 ] +} + +@test "allowlist: read-only subcommands of multiplexers are looks" { + source "$LIB/gate-allowlist.sh" + run gal_is_compliance_path Bash '{"tool_input":{"command":"git status --short"}}' + [ "$status" -eq 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"systemctl --user status claude-agents"}}' + [ "$status" -eq 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"docker ps -a"}}' + [ "$status" -eq 0 ] +} + +@test "allowlist: a writing subcommand of an allowed multiplexer is refused" { + source "$LIB/gate-allowlist.sh" + run gal_is_compliance_path Bash '{"tool_input":{"command":"git commit -m x"}}' + [ "$status" -ne 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"tmux send-keys -t agent hi Enter"}}' + [ "$status" -ne 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"docker exec c rm -rf /"}}' + [ "$status" -ne 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"systemctl --user restart claude-agents"}}' + [ "$status" -ne 0 ] +} + +@test "allowlist: redirection to a file is a write, however read-only the stages" { + source "$LIB/gate-allowlist.sh" + run gal_is_compliance_path Bash '{"tool_input":{"command":"grep foo bar > out"}}' + [ "$status" -ne 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"cat x >> y"}}' + [ "$status" -ne 0 ] +} + +@test "allowlist: a pipeline is judged whole — tee at the end is still a write" { + source "$LIB/gate-allowlist.sh" + run gal_is_compliance_path Bash '{"tool_input":{"command":"cat x | tee y"}}' + [ "$status" -ne 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"cat x | xargs rm"}}' + [ "$status" -ne 0 ] +} + +@test "allowlist: command substitution reaching a mutator is refused" { + source "$LIB/gate-allowlist.sh" + run gal_is_compliance_path Bash '{"tool_input":{"command":"echo $(rm -rf /tmp/x)"}}' + [ "$status" -ne 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"cat `rm -rf /tmp/x`"}}' + [ "$status" -ne 0 ] +} + +@test "allowlist: a chained verification sweep is a look, separators and all" { + source "$LIB/gate-allowlist.sh" + # Verbatim from the 2026-08-09 misfire set. Every segment reads; the command + # was the disconfirming check on a correction just issued, so denying it + # taxed the verification discipline the codex mandates. + cmd='echo $CLAUDE_CODE_SESSION_ID; cd /home/admin/.claude && git rev-parse --short HEAD && git branch --show-current && git status --porcelain | wc -l; git worktree list; git log --oneline -1 main; git show --stat main' + run gal_is_compliance_path Bash "$(jq -nc --arg c "$cmd" '{tool_input:{command:$c}}')" + [ "$status" -eq 0 ] +} + +@test "allowlist: a listing git subcommand reads, an operand makes it write" { + source "$LIB/gate-allowlist.sh" + run gal_is_compliance_path Bash '{"tool_input":{"command":"git worktree list"}}' + [ "$status" -eq 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"git worktree add ../x main"}}' + [ "$status" -ne 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"git branch -D main"}}' + [ "$status" -ne 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"git tag v1"}}' + [ "$status" -ne 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"git stash"}}' + [ "$status" -ne 0 ] +} + +@test "allowlist: discarding output is not writing" { + source "$LIB/gate-allowlist.sh" + run gal_is_compliance_path Bash '{"tool_input":{"command":"ls /nope 2>/dev/null | wc -l"}}' + [ "$status" -eq 0 ] +} + +@test "allowlist: sequencing and backgrounding cannot smuggle a second command" { + source "$LIB/gate-allowlist.sh" + run gal_is_compliance_path Bash '{"tool_input":{"command":"ls; rm -rf /tmp/x"}}' + [ "$status" -ne 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"ls && rm -rf /tmp/x"}}' + [ "$status" -ne 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"ls || rm -rf /tmp/x"}}' + [ "$status" -ne 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"rm -rf /tmp/x &"}}' + [ "$status" -ne 0 ] +} + +@test "allowlist: sed edits in place but reads with -n" { + source "$LIB/gate-allowlist.sh" + run gal_is_compliance_path Bash '{"tool_input":{"command":"sed -n 1,20p /etc/hosts"}}' + [ "$status" -eq 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"sed -i s/a/b/ /etc/hosts"}}' + [ "$status" -ne 0 ] +} + +@test "allowlist: find is a reader until an action primary turns it into a writer" { + source "$LIB/gate-allowlist.sh" + run gal_is_compliance_path Bash '{"tool_input":{"command":"find /var/log -name *.log"}}' + [ "$status" -eq 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"find /var/log -name *.log -delete"}}' + [ "$status" -ne 0 ] +} + +@test "allowlist: an interpreter is unjudgeable, so it fails closed" { + source "$LIB/gate-allowlist.sh" + run gal_is_compliance_path Bash '{"tool_input":{"command":"python3 -c import json"}}' + [ "$status" -ne 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"bash script.sh"}}' + [ "$status" -ne 0 ] +} + +@test "allowlist: an env-assignment prefix hides the real command, so it is refused" { + source "$LIB/gate-allowlist.sh" + run gal_is_compliance_path Bash '{"tool_input":{"command":"FOO=1 cat /etc/hosts"}}' + [ "$status" -ne 0 ] +} + +@test "allowlist: an unknown command is not assumed harmless" { + source "$LIB/gate-allowlist.sh" + run gal_is_compliance_path Bash '{"tool_input":{"command":"frobnicate --all"}}' + [ "$status" -ne 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":""}}' + [ "$status" -ne 0 ] +} From 12fb5f3394365d090e295c2605020f58e9727e81 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 11:41:54 +0000 Subject: [PATCH 2/4] feat(procedures): field-anchored --recall over mistakes.jsonl in query-records.sh Closes #41. scripts/lib/recall-match.awk: whole-word, phrase-aware matching over semantic field values only (never paths/URLs/session ids), tolerant of both JSONL spacing styles, field-boundary-safe. --recall mode: loud count line, 20-most-recent default cap, --limit override, exit 2/3 fail-loud contract. procedure-scout step 4 swapped off raw grep. CodeRabbit: store boundary via --list-stores, recap prose dropped, gate-allowlist BASH_SOURCE bare-name guard. hooks/tests/recall.bats: 16 tests. Co-Authored-By: Claude Fable 5 --- plugins/procedures/agents/procedure-scout.md | 31 ++-- .../procedures/hooks/lib/gate-allowlist.sh | 7 +- plugins/procedures/hooks/tests/recall.bats | 135 ++++++++++++++++++ .../procedures/scripts/lib/recall-match.awk | 63 ++++++++ plugins/procedures/scripts/query-records.sh | 69 ++++++++- 5 files changed, 288 insertions(+), 17 deletions(-) create mode 100644 plugins/procedures/hooks/tests/recall.bats create mode 100644 plugins/procedures/scripts/lib/recall-match.awk diff --git a/plugins/procedures/agents/procedure-scout.md b/plugins/procedures/agents/procedure-scout.md index 0b668fe..e6b69d7 100644 --- a/plugins/procedures/agents/procedure-scout.md +++ b/plugins/procedures/agents/procedure-scout.md @@ -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 '' "$M" # how many matched - grep -iE '' "$M" | tail -20 # the 20 most recent + bash "${CLAUDE_PLUGIN_ROOT}/scripts/query-records.sh" --recall '' ``` - 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. + ⚠ 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 diff --git a/plugins/procedures/hooks/lib/gate-allowlist.sh b/plugins/procedures/hooks/lib/gate-allowlist.sh index d0ea971..84b31be 100644 --- a/plugins/procedures/hooks/lib/gate-allowlist.sh +++ b/plugins/procedures/hooks/lib/gate-allowlist.sh @@ -33,8 +33,13 @@ set -uo pipefail # 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 -. "${BASH_SOURCE[0]%/*}/readonly-shape.sh" 2>/dev/null || true +. "$GAL_LIB_DIR/readonly-shape.sh" 2>/dev/null || true # gal_is_compliance_path — 0 when the call must be allowed # regardless of outstanding invariants. diff --git a/plugins/procedures/hooks/tests/recall.bats b/plugins/procedures/hooks/tests/recall.bats new file mode 100644 index 0000000..4280ea2 --- /dev/null +++ b/plugins/procedures/hooks/tests/recall.bats @@ -0,0 +1,135 @@ +#!/usr/bin/env bats +# Tests for query-records.sh --recall — field-anchored recall over +# mistakes.jsonl (scripts/lib/recall-match.awk). +# +# Run: bats hooks/tests/recall.bats + +setup() { + SCRIPT="$BATS_TEST_DIRNAME/../../scripts/query-records.sh" + FIX="$(mktemp -d)" + export QUERY_RECORDS_ROOT="$FIX" + + # Fixture recall store. Line 2 plants "quokkanoise" ONLY inside non-semantic + # fields (session id + a URL in refs) — field-anchoring must not match it. + # Line 4 uses `"key": "value"` spacing (the file mixes both styles). + cat > "$FIX/mistakes.jsonl" <<'EOF' +{"ts":"2026-01-01T00:00:00Z","session":"aaa","category":"wrong-action","description":"forgot the quokkafact check before acting","correction":"run the check first","pattern":"skipped-check","severity":"low"} +{"ts":"2026-01-02T00:00:00Z","session":"quokkanoise-123","category":"wrong-action","description":"unrelated record","correction":"do the other thing","refs":"https://example.com/quokkanoise","pattern":"other-thing","severity":"low"} +{"ts":"2026-01-03T00:00:00Z","session":"bbb","category":"wrong-assumption","description":"read a stale pane during the pickup loop and acted on it","correction":"re-read live state","pattern":"stale-pane-read","severity":"medium"} +{"ts": "2026-01-04T00:00:00Z", "session": "ccc", "category": "wrong-action", "description": "another quokkafact miss, spaced-json record", "correction": "same fix", "pattern": "skipped-check", "severity": "low"} +{"ts":"2026-01-05T00:00:00Z","session":"ddd","category":"style-violation","description":"used loopback address in docs","correction":"use the hostname","pattern":"docs-address","severity":"low"} +EOF +} + +teardown() { rm -rf "$FIX"; } + +@test "recall: matches semantic fields, count line first" { + run bash "$SCRIPT" --recall quokkafact + [ "$status" -eq 0 ] + [ "${lines[0]}" = "recall: 2 matched" ] + [[ "$output" == *"skipped-check"* ]] +} + +@test "recall: parses spaced-json records (\"key\": \"value\")" { + run bash "$SCRIPT" --recall "spaced-json" + [ "$status" -eq 0 ] + [ "${lines[0]}" = "recall: 1 matched" ] +} + +@test "recall: term only in session id / URL does not match" { + run bash "$SCRIPT" --recall quokkanoise + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "recall: whole-word — 'loop' does not match 'loopback'" { + run bash "$SCRIPT" --recall loop + [ "$status" -eq 0 ] + [ "${lines[0]}" = "recall: 1 matched" ] + [[ "$output" == *"stale-pane-read"* ]] + [[ "$output" != *"docs-address"* ]] +} + +@test "recall: hyphenated term matches as phrase across separators" { + run bash "$SCRIPT" --recall pickup-loop + [ "$status" -eq 0 ] + [ "${lines[0]}" = "recall: 1 matched" ] + [[ "$output" == *"stale-pane-read"* ]] +} + +@test "recall: phrase does not match its last word alone" { + # "docs-loop" must not hit the "pickup loop" record via bare "loop". + run bash "$SCRIPT" --recall docs-loop + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "recall: --limit caps to most recent with loud count line" { + run bash "$SCRIPT" --recall "quokkafact pickup-loop" --limit 1 + [ "$status" -eq 0 ] + [ "${lines[0]}" = "recall: 3 matched — showing the 1 most recent (raise --limit for more)" ] + # most recent = last in file order among matches + [[ "${lines[1]}" == *"2026-01-04"* ]] +} + +@test "recall: phrase cannot match across a field boundary" { + # pattern ends "check", correction begins "run" — "check-run" must miss. + run bash "$SCRIPT" --recall check-run + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "recall: default cap is 20 most recent with loud count line" { + for i in $(seq 10 34); do + printf '{"ts":"2026-02-%sT00:00:00Z","session":"x","category":"wrong-action","description":"flooded quokkaflood record %s","correction":"n/a","pattern":"flood","severity":"low"}\n' "$i" "$i" >> "$FIX/mistakes.jsonl" + done + run bash "$SCRIPT" --recall quokkaflood + [ "$status" -eq 0 ] + [ "${lines[0]}" = "recall: 25 matched — showing the 20 most recent (raise --limit for more)" ] + [ "${#lines[@]}" -eq 21 ] + [[ "${lines[1]}" == *"record 15"* ]] +} + +@test "recall: --limit 0 is uncapped" { + for i in $(seq 10 34); do + printf '{"ts":"2026-02-%sT00:00:00Z","session":"x","category":"wrong-action","description":"flooded quokkaflood record %s","correction":"n/a","pattern":"flood","severity":"low"}\n' "$i" "$i" >> "$FIX/mistakes.jsonl" + done + run bash "$SCRIPT" --recall quokkaflood --limit 0 + [ "$status" -eq 0 ] + [ "${lines[0]}" = "recall: 25 matched" ] + [ "${#lines[@]}" -eq 26 ] +} + +@test "recall: QUERY_RECORDS_RECALL_FILE overrides the store path" { + mv "$FIX/mistakes.jsonl" "$FIX/elsewhere.jsonl" + QUERY_RECORDS_RECALL_FILE="$FIX/elsewhere.jsonl" run bash "$SCRIPT" --recall quokkafact + [ "$status" -eq 0 ] + [ "${lines[0]}" = "recall: 2 matched" ] +} + +@test "recall: cannot combine with --full" { + run bash "$SCRIPT" --recall x --full + [ "$status" -eq 2 ] +} + +@test "recall: genuine miss is empty stdout exit 0" { + run bash "$SCRIPT" --recall zzznope + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "recall: all-short tokens exit 2, not a silent miss" { + run bash "$SCRIPT" --recall "a b" + [ "$status" -eq 2 ] +} + +@test "recall: cannot combine with other modes" { + run bash "$SCRIPT" --recall x --keyword y + [ "$status" -eq 2 ] +} + +@test "recall: missing store is exit 3, not 'no matches'" { + rm "$FIX/mistakes.jsonl" + run bash "$SCRIPT" --recall quokkafact + [ "$status" -eq 3 ] +} diff --git a/plugins/procedures/scripts/lib/recall-match.awk b/plugins/procedures/scripts/lib/recall-match.awk new file mode 100644 index 0000000..f7a2803 --- /dev/null +++ b/plugins/procedures/scripts/lib/recall-match.awk @@ -0,0 +1,63 @@ +# recall-match.awk — field-anchored substring match over mistakes.jsonl. +# +# Input: JSON-lines records on stdin/files. Var: tokfile (one lowercase token +# per line, pre-tokenized by the caller the same way --keyword tokenizes). +# +# A line matches when ANY token appears as a substring inside the VALUE of one +# of the semantic fields below. Matching never sees keys, paths, URLs, session +# ids, or refs — the whole point over a raw `grep -i` of the file, whose +# unanchored matches inside paths/URLs made broad recall sweeps return noise. +# +# Emits matching lines unchanged, in file order (mistakes.jsonl is +# append-only, so file order IS chronological order). + +BEGIN { + nfields = split("pattern description correction face category skill summary what fix", FIELDS, " ") + # Each tokfile line is a lowercase TERM: a whole word, or a phrase whose + # words are joined by punctuation ("pickup-loop"). Build one regex per + # term: word-boundary anchored, with any punctuation run inside the term + # matching any separator run in the text — so "pickup-loop" matches + # "pickup loop" and "pickup-loop" as a phrase, never bare "loop", and + # "type.slot" never matches a lone "type". Whole-word + phrase matching is + # the precision fix over substring OR-union, which matched most of the + # file on common words. + while ((getline t < tokfile) > 0) { + if (t == "") continue + nparts = split(t, PARTS, /[^a-z0-9]+/) + re = "" + for (p = 1; p <= nparts; p++) { + if (PARTS[p] == "") continue + re = (re == "") ? PARTS[p] : re "[^a-z0-9\n]+" PARTS[p] + } + if (re != "") RE[++ntok] = "(^|[^a-z0-9])" re "($|[^a-z0-9])" + } + close(tokfile) +} + +# Value of "key":"..." in line, JSON-escape-aware (stops at the first +# unescaped quote). Tolerates whitespace around the colon — the file mixes +# `"k":"v"` and `"k": "v"` records. Returns "" when the key is absent or +# non-string. +function fieldval(line, key, rest, len, i, c, out, esc) { + if (!match(line, "\"" key "\"[ \t]*:[ \t]*\"")) return "" + rest = substr(line, RSTART + RLENGTH) + len = length(rest); out = ""; esc = 0 + for (i = 1; i <= len; i++) { + c = substr(rest, i, 1) + if (esc) { out = out c; esc = 0; continue } + if (c == "\\") { esc = 1; continue } + if (c == "\"") return out + out = out c + } + return out +} + +{ + # Fields are joined with \n and phrase joints match [^a-z0-9\n]+, so a + # phrase cannot match across a field boundary (pattern ending "check" + + # description starting "run" must not satisfy "check-run"). + hay = "" + for (f = 1; f <= nfields; f++) hay = hay "\n" fieldval($0, FIELDS[f]) + hay = tolower(hay) + for (t = 1; t <= ntok; t++) if (match(hay, RE[t])) { print; next } +} diff --git a/plugins/procedures/scripts/query-records.sh b/plugins/procedures/scripts/query-records.sh index 2660b3c..b52199b 100755 --- a/plugins/procedures/scripts/query-records.sh +++ b/plugins/procedures/scripts/query-records.sh @@ -39,6 +39,18 @@ # Piping --full to a truncating consumer (head/less-q) may # print a benign `xargs: awk: terminated by signal 13` on # stderr (SIGPIPE); stdout is unaffected. +# --recall field-anchored recall over mistakes.jsonl (its own mode; +# combine only with --limit). Tokens are matched against +# the VALUES of the semantic fields (pattern, description, +# correction, face, category, skill, summary, what, fix) — +# never keys, paths, URLs, or session ids, so a broad term +# set cannot return path-noise the way a raw `grep -i` of +# the file does. Output on a hit: a `recall: N matched` +# count line, then the most recent 20 matches (override +# with --limit; 0 = all). No match: empty stdout, exit 0, +# same as every other mode. +# Matcher: scripts/lib/recall-match.awk. File: +# $ROOT/mistakes.jsonl (env QUERY_RECORDS_RECALL_FILE). # --rel-ratio matcher's relative floor, within a kind bucket a # candidate scoring < X * (bucket top score) is dropped # (env QUERY_RECORDS_REL_RATIO). 0 disables this @@ -128,6 +140,7 @@ Q_KIND="" Q_ID="" Q_LINKS_TO="" Q_FULL=0 +Q_RECALL="" # PLUGIN ADAPTATION: upstream silently keeps the last occurrence of a repeated # flag. For a discovery tool a confident wrong answer is worse than an error, so @@ -157,6 +170,7 @@ while [ "$#" -gt 0 ]; do --kind) seen_once "$1"; Q_KIND="${2:-}"; shift 2 ;; --id) seen_once "$1"; Q_ID="${2:-}"; shift 2 ;; --links-to) seen_once "$1"; Q_LINKS_TO="${2:-}"; shift 2 ;; + --recall) seen_once "$1"; Q_RECALL="${2:-}"; shift 2 ;; --limit) seen_once "$1"; LIMIT="${2:-0}"; shift 2 ;; --full) Q_FULL=1; shift ;; --rel-ratio) seen_once "$1"; REL_RATIO="${2:-}"; shift 2 ;; @@ -166,11 +180,62 @@ while [ "$#" -gt 0 ]; do done case "$LIMIT" in ''|*[!0-9]*) echo "query-records: --limit needs a non-negative integer (0 = uncapped)" >&2; exit 2 ;; esac -if [ -z "$Q_KEYWORD" ] && [ -z "$Q_KIND" ] && [ -z "$Q_ID" ] && [ -z "$Q_LINKS_TO" ]; then - echo "query-records: need at least one of --keyword/--kind/--id/--links-to" >&2 +if [ -z "$Q_KEYWORD" ] && [ -z "$Q_KIND" ] && [ -z "$Q_ID" ] && [ -z "$Q_LINKS_TO" ] && [ -z "$Q_RECALL" ]; then + echo "query-records: need at least one of --keyword/--kind/--id/--links-to/--recall" >&2 exit 2 fi +# ---- recall mode: field-anchored sweep over mistakes.jsonl ---- +if [ -n "$Q_RECALL" ]; then + if [ -n "$Q_KEYWORD" ] || [ -n "$Q_KIND" ] || [ -n "$Q_ID" ] || [ -n "$Q_LINKS_TO" ] || [ "$Q_FULL" -eq 1 ]; then + echo "query-records: --recall is its own mode — combine only with --limit" >&2 + exit 2 + fi + RECALL_FILE="${QUERY_RECORDS_RECALL_FILE:-$ROOT/mistakes.jsonl}" + if [ ! -f "$RECALL_FILE" ]; then + # Same contract as exit 3 elsewhere: "no store" must not read as "no + # matches". + echo "query-records: recall store not found: $RECALL_FILE — NOT 'no matches'" >&2 + exit 3 + fi + # Recall tokenization differs from --keyword: whitespace separates TERMS, + # and punctuation inside a term is kept as a phrase joint ("pickup-loop" + # matches the phrase "pickup loop"/"pickup-loop", never bare "loop"). + # Splitting phrases into independent tokens made a broad term set match + # most of the file — common words like "type" hit everywhere. + TOKEN_FILE="$(mktemp)" + trap 'rm -f "$TOKEN_FILE"' EXIT + printf '%s' "$Q_RECALL" \ + | tr '[:upper:]' '[:lower:]' \ + | tr -s '[:space:]' '\n' \ + | awk -v min="$MIN_TOKEN_LEN" '{ s = $0; gsub(/[^a-z0-9]/, "", s); if (length(s) >= min) print }' \ + | sort -u > "$TOKEN_FILE" + if [ ! -s "$TOKEN_FILE" ]; then + echo "query-records: --recall \"$Q_RECALL\" has no token of $MIN_TOKEN_LEN+ characters — nothing to search." >&2 + echo "query-records: this is NOT 'no matches'. Tokens shorter than $MIN_TOKEN_LEN characters are dropped; use a longer term." >&2 + exit 2 + fi + if ! HITS="$(awk -v tokfile="$TOKEN_FILE" -f "$LIB_DIR/recall-match.awk" "$RECALL_FILE")"; then + echo "query-records: recall scan failed (awk unusable or lib missing) — NOT 'no matches'" >&2 + exit 3 + fi + [ -z "$HITS" ] && exit 0 + TOTAL="$(printf '%s\n' "$HITS" | grep -c .)" + # Default cap 20 (most recent — file order is chronological); an explicit + # --limit overrides, 0 = uncapped. The count line always prints, so a cap + # can never be silent. + RECALL_CAP=20 + case "$SEEN_FLAGS" in *" --limit "*) RECALL_CAP="$LIMIT" ;; esac + if [ "$RECALL_CAP" -gt 0 ] && [ "$TOTAL" -gt "$RECALL_CAP" ]; then + printf 'recall: %d matched — showing the %d most recent (raise --limit for more)\n' "$TOTAL" "$RECALL_CAP" + printf '%s\n' "$HITS" | tail -n "$RECALL_CAP" + else + printf 'recall: %d matched\n' "$TOTAL" + printf '%s\n' "$HITS" + fi + exit 0 +fi + # ---- candidate corpus (all stores, excluding INDEX.md and archived) ---- ALL_FILES="$(for d in "${ALL_STORES[@]}"; do [ -d "$d" ] || continue From e8b839865d9d199bb8400dc18a89771898f23023 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 13:39:42 +0000 Subject: [PATCH 3/4] fix(procedures): address CodeRabbit round-2 findings - gate-allowlist: per-tool traversal fields (Grep path+glob, Glob path+pattern); Grep regex pattern exempt (.. is any-two-chars) - readonly-shape: reject git options that hand execution to an external program (-O/--open-files-in-pager, --ext-diff, --config-env) - readonly-shape: drop ip from the unconditional allowlist; gate the write modes of sort/-o, yq/-i, date/-s, uniq/xxd second operand - PLUGIN ADAPTATION markers on query-records.sh recall + recall-match.awk - bats coverage for every newly gated shape (203 green) --- .../procedures/hooks/lib/gate-allowlist.sh | 10 +++- .../procedures/hooks/lib/readonly-shape.sh | 45 ++++++++++++-- plugins/procedures/hooks/tests/gate-libs.bats | 60 +++++++++++++++++++ .../procedures/scripts/lib/recall-match.awk | 3 + plugins/procedures/scripts/query-records.sh | 4 ++ 5 files changed, 115 insertions(+), 7 deletions(-) diff --git a/plugins/procedures/hooks/lib/gate-allowlist.sh b/plugins/procedures/hooks/lib/gate-allowlist.sh index 84b31be..dff5fd4 100644 --- a/plugins/procedures/hooks/lib/gate-allowlist.sh +++ b/plugins/procedures/hooks/lib/gate-allowlist.sh @@ -52,10 +52,16 @@ gal_is_compliance_path() { 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 ' + fp="$(printf '%s' "$payload" | jq -r --arg tool "$tool" ' (.tool_input // .input // {}) - | (.file_path // .notebook_path // .path // "")' 2>/dev/null || true)" + | 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 diff --git a/plugins/procedures/hooks/lib/readonly-shape.sh b/plugins/procedures/hooks/lib/readonly-shape.sh index b4094ad..c7182d7 100644 --- a/plugins/procedures/hooks/lib/readonly-shape.sh +++ b/plugins/procedures/hooks/lib/readonly-shape.sh @@ -50,6 +50,17 @@ ros__nth_nonflag() { # ros__git_is_read_only ros__git_is_read_only() { local sub a + # Options that make an otherwise-read-only subcommand execute an external + # program: `grep -O`/`--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=` is invalid git + # syntax, and separate `-c =` 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|\ @@ -107,12 +118,13 @@ ros__stage_is_read_only() { local a case "$bin" in # No write mode, whatever the arguments. `cd` changes only this shell's - # own working directory, which no file outlives. - cat|head|tail|nl|wc|cut|sort|uniq|tr|rev|column|comm|diff|cmp|\ - basename|dirname|realpath|readlink|echo|printf|date|pwd|cd|whoami|\ + # 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|yq|grep|egrep|fgrep|rg|ls|tree|seq|md5sum|sha256sum|which|\ - hexdump|xxd|strings|lsof|ss|ip|getent|type|true|test) + jq|grep|egrep|fgrep|rg|ls|tree|seq|md5sum|sha256sum|which|\ + hexdump|strings|lsof|ss|getent|type|true|test) return 0 ;; # Read-only unless asked to edit in place. @@ -122,6 +134,29 @@ ros__stage_is_read_only() { 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 diff --git a/plugins/procedures/hooks/tests/gate-libs.bats b/plugins/procedures/hooks/tests/gate-libs.bats index 4982dbc..eec9705 100644 --- a/plugins/procedures/hooks/tests/gate-libs.bats +++ b/plugins/procedures/hooks/tests/gate-libs.bats @@ -171,6 +171,22 @@ teardown() { [ "$status" -eq 0 ] } +@test "allowlist: traversal in Glob pattern / Grep glob-path is refused" { + source "$LIB/gate-allowlist.sh" + run gal_is_compliance_path Glob '{"tool_input":{"pattern":"../../**/*.env"}}' + [ "$status" -eq 1 ] + run gal_is_compliance_path Grep '{"tool_input":{"pattern":"foo","glob":"../*.env"}}' + [ "$status" -eq 1 ] + run gal_is_compliance_path Grep '{"tool_input":{"pattern":"foo","path":"../secrets"}}' + [ "$status" -eq 1 ] +} + +@test "allowlist: '..' inside a Grep REGEX is not traversal" { + source "$LIB/gate-allowlist.sh" + run gal_is_compliance_path Grep '{"tool_input":{"pattern":"a..b.*end","path":"/var/log"}}' + [ "$status" -eq 0 ] +} + @test "allowlist: tmux capture-pane piped through grep and tail is a look" { source "$LIB/gate-allowlist.sh" run gal_is_compliance_path Bash '{"tool_input":{"command":"tmux capture-pane -t amzcart2 -p -S -60 | grep -v boring | tail -40"}}' @@ -316,3 +332,47 @@ teardown() { run gal_is_compliance_path Bash '{"tool_input":{"command":""}}' [ "$status" -ne 0 ] } + +@test "allowlist: git options that hand execution to an external program are refused" { + source "$LIB/gate-allowlist.sh" + run gal_is_compliance_path Bash '{"tool_input":{"command":"git grep -Otouch foo"}}' + [ "$status" -ne 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"git grep --open-files-in-pager=touch foo"}}' + [ "$status" -ne 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"git diff --ext-diff"}}' + [ "$status" -ne 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"git --config-env=core.pager=EVIL log"}}' + [ "$status" -ne 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"git grep foo"}}' + [ "$status" -eq 0 ] +} + +@test "allowlist: write-capable modes of listed readers are refused" { + source "$LIB/gate-allowlist.sh" + run gal_is_compliance_path Bash '{"tool_input":{"command":"sort -o out in"}}' + [ "$status" -ne 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"uniq in out"}}' + [ "$status" -ne 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"xxd -r in out"}}' + [ "$status" -ne 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"yq -i .a=1 f.yml"}}' + [ "$status" -ne 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"date -s 20260101"}}' + [ "$status" -ne 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"ip link set eth0 down"}}' + [ "$status" -ne 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"ip addr"}}' + [ "$status" -ne 0 ] +} + +@test "allowlist: the read-only modes of those readers stay allowed" { + source "$LIB/gate-allowlist.sh" + run gal_is_compliance_path Bash '{"tool_input":{"command":"sort in"}}' + [ "$status" -eq 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"uniq -c in"}}' + [ "$status" -eq 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"cat f.yml | yq .a"}}' + [ "$status" -eq 0 ] + run gal_is_compliance_path Bash '{"tool_input":{"command":"date -u"}}' + [ "$status" -eq 0 ] +} diff --git a/plugins/procedures/scripts/lib/recall-match.awk b/plugins/procedures/scripts/lib/recall-match.awk index f7a2803..3129379 100644 --- a/plugins/procedures/scripts/lib/recall-match.awk +++ b/plugins/procedures/scripts/lib/recall-match.awk @@ -1,5 +1,8 @@ # recall-match.awk — field-anchored substring match over mistakes.jsonl. # +# PLUGIN ADAPTATION: no upstream source — the plugin is the source of truth +# for query-records machinery post orchard-codex#268 phase 1. +# # Input: JSON-lines records on stdin/files. Var: tokfile (one lowercase token # per line, pre-tokenized by the caller the same way --keyword tokenizes). # diff --git a/plugins/procedures/scripts/query-records.sh b/plugins/procedures/scripts/query-records.sh index b52199b..f0f55b5 100755 --- a/plugins/procedures/scripts/query-records.sh +++ b/plugins/procedures/scripts/query-records.sh @@ -186,6 +186,10 @@ if [ -z "$Q_KEYWORD" ] && [ -z "$Q_KIND" ] && [ -z "$Q_ID" ] && [ -z "$Q_LINKS_T fi # ---- recall mode: field-anchored sweep over mistakes.jsonl ---- +# PLUGIN ADAPTATION: recall has no upstream counterpart to vendor from — +# orchard-codex#268 phase 1 removed these scripts from the codex, making this +# plugin the source of truth for query-records machinery. The codex's own +# copy is a frozen older version that never covered mistakes.jsonl. if [ -n "$Q_RECALL" ]; then if [ -n "$Q_KEYWORD" ] || [ -n "$Q_KIND" ] || [ -n "$Q_ID" ] || [ -n "$Q_LINKS_TO" ] || [ "$Q_FULL" -eq 1 ]; then echo "query-records: --recall is its own mode — combine only with --limit" >&2 From 0a1977c7b37114f7909ea40f5343bf071d9a0423 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 13:46:51 +0000 Subject: [PATCH 4/4] test(procedures): PLUGIN ADAPTATION marker + all-semantic-fields probe in recall.bats --- plugins/procedures/hooks/tests/recall.bats | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/plugins/procedures/hooks/tests/recall.bats b/plugins/procedures/hooks/tests/recall.bats index 4280ea2..82f7cb3 100644 --- a/plugins/procedures/hooks/tests/recall.bats +++ b/plugins/procedures/hooks/tests/recall.bats @@ -2,6 +2,9 @@ # Tests for query-records.sh --recall — field-anchored recall over # mistakes.jsonl (scripts/lib/recall-match.awk). # +# PLUGIN ADAPTATION: no upstream source — the plugin is the source of truth +# for query-records machinery post orchard-codex#268 phase 1. +# # Run: bats hooks/tests/recall.bats setup() { @@ -30,6 +33,19 @@ teardown() { rm -rf "$FIX"; } [[ "$output" == *"skipped-check"* ]] } +@test "recall: every semantic field is matchable" { + # One record carrying a unique term in EACH field recall-match.awk anchors + # to — a field silently dropped from FIELDS would fail its probe here. + cat > "$FIX/mistakes.jsonl" <<'EOF' +{"ts":"2026-03-01T00:00:00Z","session":"f1","pattern":"zubpattern probe","description":"zubdescription probe","correction":"zubcorrection probe","face":"zubface probe","category":"zubcategory","skill":"zubskill probe","summary":"zubsummary probe","what":"zubwhat probe","fix":"zubfix probe"} +EOF + for t in zubpattern zubdescription zubcorrection zubface zubcategory zubskill zubsummary zubwhat zubfix; do + run bash "$SCRIPT" --recall "$t" + [ "$status" -eq 0 ] + [ "${lines[0]}" = "recall: 1 matched" ] + done +} + @test "recall: parses spaced-json records (\"key\": \"value\")" { run bash "$SCRIPT" --recall "spaced-json" [ "$status" -eq 0 ]