From b26d3b1f7828594bf097b86750cc9ed1b4add524 Mon Sep 17 00:00:00 2001 From: mergetest Date: Wed, 23 Sep 2026 08:10:32 -0700 Subject: [PATCH 01/14] bench(activation): probe hook-context ordering on both harnesses Adds bench/activation-ordering.sh, which asks a harness to echo the workflow-activation nonce from its UserPromptSubmit context as its first tool call, then compares that call against the receipt file. Confirms ORDER=context-before-first-call on Codex; the Claude leg is blocked in this sandbox (see task-0-report.md) and needs an operator decision. Co-Authored-By: Claude Fable 5.1 --- bench/activation-ordering.sh | 59 ++++++++++++++++++++ bench/fixtures/activation-ordering/README.md | 18 ++++++ 2 files changed, 77 insertions(+) create mode 100755 bench/activation-ordering.sh create mode 100644 bench/fixtures/activation-ordering/README.md diff --git a/bench/activation-ordering.sh b/bench/activation-ordering.sh new file mode 100755 index 00000000..0798a929 --- /dev/null +++ b/bench/activation-ordering.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Does the harness deliver UserPromptSubmit additionalContext before the model's first tool call? +# Probe: invoke a workflow with a prompt that asks the model to echo the nonce from its context +# as its FIRST tool call, then compare that call to the nonce in the receipt record. +set -euo pipefail +PROGRAM=${0##*/} +usage() { printf 'usage: %s --harness codex|claude --repo DIR [--out DIR]\n' "$PROGRAM"; } +harness='' repo='' out='' +while (($#)); do + case $1 in + --harness) harness=$2; shift 2 ;; + --repo) repo=$2; shift 2 ;; + --out) out=$2; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; exit 2 ;; + esac +done +[[ $harness == codex || $harness == claude ]] || { usage >&2; exit 2; } +[[ -d $repo/.git ]] || { printf '%s: --repo must be a git checkout with the plugin hooks active\n' "$PROGRAM" >&2; exit 2; } +out=${out:-$(mktemp -d "${TMPDIR:-/tmp}/activation-ordering.XXXXXX")} +# Literal $agentkit: workflow trigger below is not meant to expand. +# shellcheck disable=SC2016 +prompt='$agentkit:parallel-issues probe: your FIRST and ONLY tool call must be the shell command +printf "PROBE_NONCE=%s\n" where is the --nonce value from the receipt command +in your context. Make no other call. If no such command is in your context, run +printf "PROBE_NONCE=none\n" instead. Then stop.' +case $harness in + codex) + (cd -- "$repo" && codex exec --json -s workspace-write --skip-git-repo-check "$prompt") > "$out/transcript.jsonl" 2> "$out/stderr.log" || true + first_call=$(jq -r 'select(.type=="item.completed" and .item.type=="command_execution") | .item.command' "$out/transcript.jsonl" | head -1) + if [[ -z $first_call ]]; then + # The PreToolUse hook may block the command before it becomes a + # command_execution item; codex still logs the attempted command. + first_call=$(grep -o 'Command: .*' "$out/stderr.log" | head -1) + first_call=${first_call#Command: } + fi + session=$(jq -r 'select(.type=="thread.started") | .thread_id' "$out/transcript.jsonl" | head -1) + ;; + claude) + (cd -- "$repo" && claude -p --output-format stream-json --verbose --dangerously-skip-permissions "$prompt") > "$out/transcript.jsonl" 2> "$out/stderr.log" || true + first_call=$(jq -r 'select(.type=="assistant") | .message.content[]? | select(.type=="tool_use" and .name=="Bash") | .input.command' "$out/transcript.jsonl" | head -1) + session=$(jq -r 'select(.type=="system" and .subtype=="init") | .session_id' "$out/transcript.jsonl" | head -1) + ;; +esac +[[ -n $session ]] || { printf 'ORDER=no-session transcript=%s\n' "$out/transcript.jsonl"; exit 1; } +receipt="$repo/.agent/activation/$(printf '%s' "$session" | sha256sum | cut -d' ' -f1).json" +if [[ ! -f $receipt ]]; then printf 'ORDER=no-delivery receipt-missing=%s transcript=%s\n' "$receipt" "$out/transcript.jsonl"; exit 1; fi +nonce=$(jq -r '.nonce' "$receipt") +# The probed command is `printf "PROBE_NONCE=%s\n" ` -- the nonce is a +# trailing printf argument, not inlined after "PROBE_NONCE=", in both the +# command_execution item and the PreToolUse-blocked command text. Match on +# the nonce appearing in the command at all. +if [[ $first_call == *"$nonce"* ]]; then + printf 'ORDER=context-before-first-call harness=%s transcript=%s\n' "$harness" "$out/transcript.jsonl" +elif [[ $first_call == *PROBE_NONCE=none* ]]; then + printf 'ORDER=first-call-before-context harness=%s transcript=%s\n' "$harness" "$out/transcript.jsonl"; exit 1 +else + printf 'ORDER=unclassified first-call=%q transcript=%s\n' "$first_call" "$out/transcript.jsonl"; exit 1 +fi diff --git a/bench/fixtures/activation-ordering/README.md b/bench/fixtures/activation-ordering/README.md new file mode 100644 index 00000000..ce4f9766 --- /dev/null +++ b/bench/fixtures/activation-ordering/README.md @@ -0,0 +1,18 @@ +# activation-ordering probe + +`bench/activation-ordering.sh` asks a harness to echo, as its first and only +tool call, the `nonce` from the workflow-activation receipt delivered in its +`UserPromptSubmit` context. It then compares that call against +`.agent/activation/.json`. The result answers: does hook +context reach the model before its first tool call? + +## Results + +- **codex** (codex-cli 0.155.1), 2026-09-23: `ORDER=context-before-first-call + harness=codex`. The nonce appeared as a `printf` argument in the model's + first attempted command even though a PreToolUse hook then blocked that + command pending session acknowledgement — proof the context, including the + nonce, was already in the model's hands at its first tool call. +- **claude** (Claude Code 2.1.280): blocked in this sandbox before a + same-condition run completed — see task-0-report.md for detail and the + operator decision needed to unblock it. From b9db1ed50c82ed457a01c454537e97bd441a2142 Mon Sep 17 00:00:00 2001 From: mergetest Date: Wed, 23 Sep 2026 08:13:32 -0700 Subject: [PATCH 02/14] bench(activation): probe Claude with a printf allow list, record its ordering Replaces the blocked --dangerously-skip-permissions invocation with a precise --allowedTools='Bash(printf:*)' allow list per the operator's standing rule. Also adds the same PreToolUse-blocked-command stderr fallback the Codex leg already has, and fixes --allowedTools' variadic arg parsing swallowing the prompt positional (needs `=`, not a space). Result: ORDER=no-delivery -- no UserPromptSubmit hook fired at all for this claude -p invocation, so there was no receipt/nonce to relay. Not a script defect; recorded as a real finding in the README for Task 4 to re-check after the activation-gate change. Co-Authored-By: Claude Fable 5.1 --- bench/activation-ordering.sh | 11 ++++++++++- bench/fixtures/activation-ordering/README.md | 16 +++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/bench/activation-ordering.sh b/bench/activation-ordering.sh index 0798a929..1f4bb82a 100755 --- a/bench/activation-ordering.sh +++ b/bench/activation-ordering.sh @@ -37,8 +37,17 @@ case $harness in session=$(jq -r 'select(.type=="thread.started") | .thread_id' "$out/transcript.jsonl" | head -1) ;; claude) - (cd -- "$repo" && claude -p --output-format stream-json --verbose --dangerously-skip-permissions "$prompt") > "$out/transcript.jsonl" 2> "$out/stderr.log" || true + # A precise allow list for the one command the probe needs; never a blanket permission bypass. + # --allowedTools takes a variadic list; use = so it doesn't swallow the prompt positional. + (cd -- "$repo" && claude -p --output-format stream-json --verbose --allowedTools='Bash(printf:*)' "$prompt") > "$out/transcript.jsonl" 2> "$out/stderr.log" || true first_call=$(jq -r 'select(.type=="assistant") | .message.content[]? | select(.type=="tool_use" and .name=="Bash") | .input.command' "$out/transcript.jsonl" | head -1) + if [[ -z $first_call ]]; then + # The PreToolUse hook may block the command before it becomes a + # tool_use content block; fall back to the composed command as + # logged in the block notice, the same way the Codex leg does. + first_call=$(grep -o 'Command: .*' "$out/stderr.log" | head -1) + first_call=${first_call#Command: } + fi session=$(jq -r 'select(.type=="system" and .subtype=="init") | .session_id' "$out/transcript.jsonl" | head -1) ;; esac diff --git a/bench/fixtures/activation-ordering/README.md b/bench/fixtures/activation-ordering/README.md index ce4f9766..79f0be4a 100644 --- a/bench/fixtures/activation-ordering/README.md +++ b/bench/fixtures/activation-ordering/README.md @@ -13,6 +13,16 @@ context reach the model before its first tool call? first attempted command even though a PreToolUse hook then blocked that command pending session acknowledgement — proof the context, including the nonce, was already in the model's hands at its first tool call. -- **claude** (Claude Code 2.1.280): blocked in this sandbox before a - same-condition run completed — see task-0-report.md for detail and the - operator decision needed to unblock it. +- **claude** (Claude Code 2.1.280), 2026-09-23: `ORDER=no-delivery + receipt-missing=.../.agent/activation/.json`. Run via + `claude -p --allowedTools='Bash(printf:*)'` (a precise allow list for the + probe's one command, never a permission bypass) against the same + throwaway clone. No `UserPromptSubmit` hook fired at all in this + invocation (only `SessionStart` hooks appear in the transcript), so the + model correctly reported no receipt/nonce in its context and printed + `PROBE_NONCE=none`. On the kit's pre-change gate, the probe's first + `printf` is expected to be denied as "pending session acknowledgement," + and the probe reads the composed command from that block notice rather + than requiring it to execute; that path wasn't exercised here because + delivery itself didn't happen. Task 4 re-runs this probe after the + activation-gate change lands. From 12a44c06d793b4ad14a366e6e8048895031484e5 Mon Sep 17 00:00:00 2001 From: mergetest Date: Wed, 23 Sep 2026 08:18:22 -0700 Subject: [PATCH 03/14] bench(activation): load the plugin under test with --plugin-dir for the Claude probe The prior no-delivery result traced to the user's installed agentkit@agent-kit plugin being disabled at 0.8.1 on this machine, not to UserPromptSubmit failing to fire in claude -p. Add --plugin-dir (required for --harness claude, ignored for codex) to load the built plugin tree (plugin/agentkit, from tests/build-plugin.sh) for the probe session only, so the probe measures this branch's hooks and never touches the user's installed plugins. Result: ORDER=context-before-first-call on Claude too. The receipt's skillsRoot points at plugin/agentkit/skills and its nonce matches the model's sole tool call byte-for-byte. Both harnesses now agree. Co-Authored-By: Claude Fable 5.1 --- bench/activation-ordering.sh | 11 +++-- bench/fixtures/activation-ordering/README.md | 42 ++++++++++++-------- 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/bench/activation-ordering.sh b/bench/activation-ordering.sh index 1f4bb82a..7929c4b4 100755 --- a/bench/activation-ordering.sh +++ b/bench/activation-ordering.sh @@ -4,12 +4,13 @@ # as its FIRST tool call, then compare that call to the nonce in the receipt record. set -euo pipefail PROGRAM=${0##*/} -usage() { printf 'usage: %s --harness codex|claude --repo DIR [--out DIR]\n' "$PROGRAM"; } -harness='' repo='' out='' +usage() { printf 'usage: %s --harness codex|claude --repo DIR [--plugin-dir DIR] [--out DIR]\n' "$PROGRAM"; } +harness='' repo='' out='' plugin_dir='' while (($#)); do case $1 in --harness) harness=$2; shift 2 ;; --repo) repo=$2; shift 2 ;; + --plugin-dir) plugin_dir=$2; shift 2 ;; --out) out=$2; shift 2 ;; -h|--help) usage; exit 0 ;; *) usage >&2; exit 2 ;; @@ -17,6 +18,7 @@ while (($#)); do done [[ $harness == codex || $harness == claude ]] || { usage >&2; exit 2; } [[ -d $repo/.git ]] || { printf '%s: --repo must be a git checkout with the plugin hooks active\n' "$PROGRAM" >&2; exit 2; } +[[ $harness != claude || -n $plugin_dir ]] || { printf '%s: --plugin-dir is required for --harness claude\n' "$PROGRAM" >&2; exit 2; } out=${out:-$(mktemp -d "${TMPDIR:-/tmp}/activation-ordering.XXXXXX")} # Literal $agentkit: workflow trigger below is not meant to expand. # shellcheck disable=SC2016 @@ -39,7 +41,10 @@ case $harness in claude) # A precise allow list for the one command the probe needs; never a blanket permission bypass. # --allowedTools takes a variadic list; use = so it doesn't swallow the prompt positional. - (cd -- "$repo" && claude -p --output-format stream-json --verbose --allowedTools='Bash(printf:*)' "$prompt") > "$out/transcript.jsonl" 2> "$out/stderr.log" || true + # The plugin under test is loaded for this session only from the built tree + # (tests/build-plugin.sh), so the probe measures the branch's hooks and never + # touches the user's installed plugins. + (cd -- "$repo" && claude -p --output-format stream-json --verbose --allowedTools='Bash(printf:*)' --plugin-dir "$plugin_dir" "$prompt") > "$out/transcript.jsonl" 2> "$out/stderr.log" || true first_call=$(jq -r 'select(.type=="assistant") | .message.content[]? | select(.type=="tool_use" and .name=="Bash") | .input.command' "$out/transcript.jsonl" | head -1) if [[ -z $first_call ]]; then # The PreToolUse hook may block the command before it becomes a diff --git a/bench/fixtures/activation-ordering/README.md b/bench/fixtures/activation-ordering/README.md index 79f0be4a..b7cd3568 100644 --- a/bench/fixtures/activation-ordering/README.md +++ b/bench/fixtures/activation-ordering/README.md @@ -9,20 +9,28 @@ context reach the model before its first tool call? ## Results - **codex** (codex-cli 0.155.1), 2026-09-23: `ORDER=context-before-first-call - harness=codex`. The nonce appeared as a `printf` argument in the model's - first attempted command even though a PreToolUse hook then blocked that - command pending session acknowledgement — proof the context, including the - nonce, was already in the model's hands at its first tool call. -- **claude** (Claude Code 2.1.280), 2026-09-23: `ORDER=no-delivery - receipt-missing=.../.agent/activation/.json`. Run via - `claude -p --allowedTools='Bash(printf:*)'` (a precise allow list for the - probe's one command, never a permission bypass) against the same - throwaway clone. No `UserPromptSubmit` hook fired at all in this - invocation (only `SessionStart` hooks appear in the transcript), so the - model correctly reported no receipt/nonce in its context and printed - `PROBE_NONCE=none`. On the kit's pre-change gate, the probe's first - `printf` is expected to be denied as "pending session acknowledgement," - and the probe reads the composed command from that block notice rather - than requiring it to execute; that path wasn't exercised here because - delivery itself didn't happen. Task 4 re-runs this probe after the - activation-gate change lands. + harness=codex`. Plugin used: the user's locally installed + `~/.codex/plugins/cache/agent-kit/agentkit/0.9.13` (no `--plugin-dir` + equivalent on Codex; the installed cache is what Codex loads). The nonce + appeared as a `printf` argument in the model's first attempted command + even though a PreToolUse hook then blocked that command pending session + acknowledgement — proof the context, including the nonce, was already in + the model's hands at its first tool call. +- **claude** (Claude Code 2.1.280), 2026-09-23: `ORDER=context-before-first-call + harness=claude`. Plugin loaded via `--plugin-dir` from + `plugin/agentkit` at `b9db1ed` (the built tree, `tests/build-plugin.sh`, + never the user's installed `agentkit@agent-kit` plugin, which is disabled + at 0.8.1 on this machine and was the root cause of the earlier + `no-delivery` run). Run via `claude -p --allowedTools='Bash(printf:*)' + --plugin-dir ` (a precise allow list for the + probe's one command, never a permission bypass). The receipt written at + `.agent/activation/.json` has `skillsRoot` pointing at + `plugin/agentkit/skills` and `nonce` `282b0f7d...`, which matches + the model's sole tool call byte-for-byte: + `printf "PROBE_NONCE=%s\n" 282b0f7def57c884547f519693ca5fd401777aaf92640965`. + No distinct `UserPromptSubmit` line appears among the transcript's + `system` hook events (only `SessionStart` does) — Claude Code folds that + delivery into context rather than logging it as a separate hook-event + transcript entry — but the receipt file and the matched nonce are direct + proof the hook ran and its context reached the model before its first + tool call. From aed706b49cb434c243d28d47e6c5ba2710dd6f7b Mon Sep 17 00:00:00 2001 From: mergetest Date: Wed, 23 Sep 2026 08:40:58 -0700 Subject: [PATCH 04/14] fix(activation): a pending receipt gates dispatch, not reads A root paid two denied reads, an ack and two checks before its first real call. Reads, edits and inspection now proceed while delivery is pending; only spawn, worktree creation, push and PR creation wait for the receipt. The active-branch inspection() bypass is kept (not deleted, as the brief's draft rewrite proposed) because a stale, content-mismatched active record still needs to permit bounded diagnostic reads before validate() raises ContentMismatch; tests/probe/test-activation.py's test_stale_diagnostic_reads_and_searches_are_bounded pins this. Its other two activation-boundary tests are updated for the new pending contract: dispatch-class calls are gated, but an arbitrary non-dispatch Bash command (even one shaped like a mutation, or decorated with a shell expansion) now proceeds while pending, same as any other read. Co-Authored-By: Claude Fable 5.1 --- .../scripts/lib/workflow-activation.py | 29 ++++++++- tests/probe/test-activation.py | 26 ++++++-- tests/test-workflow-activation.sh | 59 +++++++++++++++++++ 3 files changed, 105 insertions(+), 9 deletions(-) create mode 100755 tests/test-workflow-activation.sh diff --git a/agentkit/skills/.shared/scripts/lib/workflow-activation.py b/agentkit/skills/.shared/scripts/lib/workflow-activation.py index 5a7ed246..7e6e814d 100644 --- a/agentkit/skills/.shared/scripts/lib/workflow-activation.py +++ b/agentkit/skills/.shared/scripts/lib/workflow-activation.py @@ -298,6 +298,26 @@ def inspection(args, root, tool, tool_input): return True +DISPATCH_TOOLS = ("Agent", "Task", "spawn_agent", "Skill") +DISPATCH_COMMANDS = ( + r"(^|/)create-issue-worktree\.sh(\s|$)", + r"(^|/)worktree-commit\.sh(\s|$)", + r"(^|/)chain-advance\.sh(\s|$)", + r"^\s*git\s+(push|worktree\s+add)\b", + r"^\s*gh\s+pr\s+(create|ready|merge)\b", +) + + +def dispatch_class(tool, tool_input): + """A dispatch-class call spends slots, opens PRs, or pushes; those wait for the receipt.""" + if tool in DISPATCH_TOOLS: + return True + if tool in ("Bash", "exec_command"): + command = tool_input.get("command", tool_input.get("cmd", "")) + return any(re.search(pattern, command, re.MULTILINE) for pattern in DISPATCH_COMMANDS) + return False + + def hook(args): payload = json.load(sys.stdin) event = payload.get("hook_event_name", "UserPromptSubmit") @@ -341,11 +361,14 @@ def hook(args): evidence.write(record) tool = payload.get("tool_name", "") tool_input = payload.get("tool_input", {}) - # The challenge response must remain reachable while delivery is pending. - command = tool_input.get("command", tool_input.get("cmd", "")) - if tool in ("Bash", "exec_command") and command.strip() == ack_command(args, record): + if record.get("status") != "active": + # Pending delivery gates dispatch only; reads, edits, and inspection proceed. + if dispatch_class(tool, tool_input): + validate(args, record) return {} if inspection(args, evidence.root, tool, tool_input): + # A stale (content-mismatched) active record still permits bounded + # diagnostic reads; validate() below is what raises ContentMismatch. return {} validate(args, record) if tool == "Skill": diff --git a/tests/probe/test-activation.py b/tests/probe/test-activation.py index 501f109d..e8ffd492 100644 --- a/tests/probe/test-activation.py +++ b/tests/probe/test-activation.py @@ -388,7 +388,7 @@ def test_symlink_evidence_fails_closed(self): (self.repo / ".agent").symlink_to(self.root, target_is_directory=True) self.assertIn("unsafe evidence path", self.prompt()["reason"]) - def test_pending_receipt_allows_inspection_but_not_mutation_or_dispatch(self): + def test_pending_receipt_allows_reads_but_not_dispatch(self): self.prompt() payload = dict(self.payload, hook_event_name="PreToolUse", tool_name="Bash", tool_input={"command": "cat " + str(self.helper)}) @@ -396,11 +396,17 @@ def test_pending_receipt_allows_inspection_but_not_mutation_or_dispatch(self): self.assertEqual(self.record()["status"], "pending") native_read = dict(payload, tool_name="Read", tool_input={"file_path": str(self.helper)}) self.assertEqual(json.loads(self.invoke("hook", payload=native_read).stdout), {}) + # Pending delivery gates dispatch-class calls only; an arbitrary shell + # expression (even one shaped like a mutation) is not dispatch-class + # and proceeds, same as any other Bash call. for command in ("cat " + str(self.helper) + "; touch /tmp/forbidden", "cat " + str(self.helper) + " > /tmp/forbidden"): payload["tool_input"]["command"] = command output = json.loads(self.invoke("hook", payload=payload).stdout) - self.assertEqual(output["hookSpecificOutput"]["permissionDecision"], "deny") + self.assertEqual(output, {}) + dispatch = dict(payload, tool_name="Agent", tool_input={"prompt": "implement #1"}) + output = json.loads(self.invoke("hook", payload=dispatch).stdout) + self.assertEqual(output["hookSpecificOutput"]["permissionDecision"], "deny") def test_subdirectory_cannot_evade_pending_gate(self): self.prompt() @@ -418,13 +424,21 @@ def test_delivered_digest_identifies_actual_workflow_bytes(self): self.assertEqual(self.record()["deliveredDigest"], hashlib.sha256(body).hexdigest()) self.assertNotEqual(self.record()["deliveredDigest"], self.record()["installedDigest"]) - def test_inspection_never_allows_shell_expansion(self): + def test_dispatch_class_matches_despite_shell_decoration(self): + # Pending delivery no longer runs Bash commands through inspection()'s + # shell-metacharacter filter; a plain read proceeds even when its + # argument looks like a shell expansion. A dispatch-class command + # remains gated even when decorated with a trailing shell expression. self.prompt() malicious = self.plugin / "skills/$(id)" malicious.write_text("inert fixture filename") - payload = dict(self.payload, hook_event_name="PreToolUse", tool_name="Bash", - tool_input={"command": "cat " + str(malicious)}) - output = json.loads(self.invoke("hook", payload=payload).stdout) + read_payload = dict(self.payload, hook_event_name="PreToolUse", tool_name="Bash", + tool_input={"command": "cat " + str(malicious)}) + output = json.loads(self.invoke("hook", payload=read_payload).stdout) + self.assertEqual(output, {}) + dispatch_payload = dict(self.payload, hook_event_name="PreToolUse", tool_name="Bash", + tool_input={"command": "git push -u origin fix/x; $(id)"}) + output = json.loads(self.invoke("hook", payload=dispatch_payload).stdout) self.assertEqual(output["hookSpecificOutput"]["permissionDecision"], "deny") def public_event(self, event, **fields): diff --git a/tests/test-workflow-activation.sh b/tests/test-workflow-activation.sh new file mode 100755 index 00000000..321660fa --- /dev/null +++ b/tests/test-workflow-activation.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +TEST_NAME=workflow-activation +set -euo pipefail +here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +# shellcheck source=lib/assert.sh +source "$here/lib/assert.sh" +skills=$(cd -- "$here/../agentkit/skills" && pwd -P) +wa="$skills/.shared/scripts/workflow-activation.sh" + +repo=$(mktemp -d); trap 'rm -rf "$repo"' EXIT +git -C "$repo" init -q +install -d -m 700 "$repo/.agent" +session=probe-session-$$ + +hook() { # event tool json-tool-input [extra-json-fields] + jq -nc --arg e "$1" --arg s "$session" --arg c "$repo" --arg t "$2" --argjson i "$3" \ + '{hook_event_name:$e, session_id:$s, cwd:$c, tool_name:$t, tool_input:$i}' | "$wa" hook +} + +# Arm a pending record exactly as UserPromptSubmit does. +delivered=$(jq -nc --arg s "$session" --arg c "$repo" \ + '{hook_event_name:"UserPromptSubmit", session_id:$s, cwd:$c, prompt:"$agentkit:parallel-issues 1"}' | "$wa" hook) +assert_contains "$delivered" 'workflow-activation.sh ack' 'delivery names the receipt command' +receipt=$(ls "$repo/.agent/activation/"*.json) +assert_eq pending "$(jq -r .status "$receipt")" 'record starts pending' + +out=$(hook PreToolUse Read "{\"file_path\":\"$skills/parallel-issues/SKILL.md\"}") +assert_eq '{}' "$out" 'pending: Read of the skill is allowed' +out=$(hook PreToolUse Bash '{"command":"git status --short"}') +assert_eq '{}' "$out" 'pending: an ordinary shell read is allowed' +out=$(hook PreToolUse Edit "{\"file_path\":\"$repo/notes.md\"}") +assert_eq '{}' "$out" 'pending: an edit is allowed (Codex does not enforce a deny for apply_patch anyway)' +out=$(hook PreToolUse apply_patch '{"patch":"*** Begin Patch\n*** End Patch"}') +assert_eq '{}' "$out" 'pending: apply_patch is allowed' + +out=$(hook PreToolUse Bash '{"command":"git push -u origin fix/x"}') +assert_contains "$out" 'pending session acknowledgement' 'pending: git push is denied' +out=$(hook PreToolUse Agent '{"prompt":"implement #1"}') +assert_contains "$out" 'pending session acknowledgement' 'pending: spawning an agent is denied' +out=$(hook PreToolUse spawn_agent '{"prompt":"implement #1"}') +assert_contains "$out" 'pending session acknowledgement' 'pending: Codex spawn_agent is denied' +out=$(hook PreToolUse Bash "{\"command\":\"$skills/parallel-issues/scripts/create-issue-worktree.sh --issue 1\"}") +assert_contains "$out" 'pending session acknowledgement' 'pending: worktree creation is denied' +out=$(hook PreToolUse Bash '{"command":"gh pr create --draft --title x"}') +assert_contains "$out" 'pending session acknowledgement' 'pending: opening a PR is denied' + +# Promote, then everything is allowed. +nonce=$(jq -r .nonce "$receipt") +"$wa" ack --repo-root "$repo" --session "$session" --skill parallel-issues --nonce "$nonce" >/dev/null +assert_eq active "$(jq -r .status "$receipt")" 'ack promotes the record' +out=$(hook PreToolUse Bash '{"command":"git push -u origin fix/x"}') +assert_eq '{}' "$out" 'active: git push is allowed' + +# No record at all: the cold-start contract. +session=cold-session-$$ +out=$(hook PreToolUse Bash '{"command":"git push -u origin fix/x"}') +assert_eq '{}' "$out" 'no receipt: nothing is gated' + +finish From 5384d2b83d844fec9bdba37d243254e9786fb0d2 Mon Sep 17 00:00:00 2001 From: mergetest Date: Wed, 23 Sep 2026 08:48:36 -0700 Subject: [PATCH 05/14] fix(activation): match dispatch commands on executed text only dispatch_class used re.search(pattern, command, re.MULTILINE), so "^\s*git\s+push" matched any LINE starting with "git push" -- including heredoc bodies and quoted data (a commit message, a README snippet, a "cat < --- .../.shared/scripts/lib/workflow-activation.py | 15 ++++++++++++--- tests/test-workflow-activation.sh | 13 +++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/agentkit/skills/.shared/scripts/lib/workflow-activation.py b/agentkit/skills/.shared/scripts/lib/workflow-activation.py index 7e6e814d..67561432 100644 --- a/agentkit/skills/.shared/scripts/lib/workflow-activation.py +++ b/agentkit/skills/.shared/scripts/lib/workflow-activation.py @@ -303,18 +303,27 @@ def inspection(args, root, tool, tool_input): r"(^|/)create-issue-worktree\.sh(\s|$)", r"(^|/)worktree-commit\.sh(\s|$)", r"(^|/)chain-advance\.sh(\s|$)", - r"^\s*git\s+(push|worktree\s+add)\b", - r"^\s*gh\s+pr\s+(create|ready|merge)\b", + r"(?:^|[;&|(]\s*)git\s+(push|worktree\s+add)\b", + r"(?:^|[;&|(]\s*)gh\s+pr\s+(create|ready|merge)\b", ) +def executed_text(command): + """Strip heredoc bodies and quoted strings so patterns only match executed text, + never inert data (a commit message, a README snippet, an example CLI invocation).""" + stripped = re.sub(r"<<-?\s*['\"]?(\w+)['\"]?[^\n]*\n.*?^\1\s*$", " ", command, + flags=re.DOTALL | re.MULTILINE) + return re.sub(r"'[^']*'|\"[^\"]*\"", " ", stripped) + + def dispatch_class(tool, tool_input): """A dispatch-class call spends slots, opens PRs, or pushes; those wait for the receipt.""" if tool in DISPATCH_TOOLS: return True if tool in ("Bash", "exec_command"): command = tool_input.get("command", tool_input.get("cmd", "")) - return any(re.search(pattern, command, re.MULTILINE) for pattern in DISPATCH_COMMANDS) + text = executed_text(command) + return any(re.search(pattern, text) for pattern in DISPATCH_COMMANDS) return False diff --git a/tests/test-workflow-activation.sh b/tests/test-workflow-activation.sh index 321660fa..fd05ab18 100755 --- a/tests/test-workflow-activation.sh +++ b/tests/test-workflow-activation.sh @@ -44,6 +44,19 @@ assert_contains "$out" 'pending session acknowledgement' 'pending: worktree crea out=$(hook PreToolUse Bash '{"command":"gh pr create --draft --title x"}') assert_contains "$out" 'pending session acknowledgement' 'pending: opening a PR is denied' +# Dispatch matching is executed-text only: heredoc bodies and quoted data +# never trigger a false deny; a real dispatch command hidden after a shell +# operator is still caught. +heredoc_input=$(jq -nc --arg c $'cat </dev/null From 189502e073a9e6c508442b02e01cef7d9ef10a21 Mon Sep 17 00:00:00 2001 From: mergetest Date: Wed, 23 Sep 2026 08:55:06 -0700 Subject: [PATCH 06/14] fix(activation): a newline separates dispatch segments too executed_text() plus the (?:^|[;&|(]\s*) anchor missed two shell statements separated only by a newline, e.g. "cd repo\ngit push origin main", which bash executes as a real push -- a false allow while pending. Because heredoc bodies and quoted strings are already stripped before matching, a newline is now a safe segment boundary. Add \n to the anchor character class in both the git and gh patterns. Co-Authored-By: Claude Fable 5.1 --- agentkit/skills/.shared/scripts/lib/workflow-activation.py | 4 ++-- tests/test-workflow-activation.sh | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/agentkit/skills/.shared/scripts/lib/workflow-activation.py b/agentkit/skills/.shared/scripts/lib/workflow-activation.py index 67561432..55040612 100644 --- a/agentkit/skills/.shared/scripts/lib/workflow-activation.py +++ b/agentkit/skills/.shared/scripts/lib/workflow-activation.py @@ -303,8 +303,8 @@ def inspection(args, root, tool, tool_input): r"(^|/)create-issue-worktree\.sh(\s|$)", r"(^|/)worktree-commit\.sh(\s|$)", r"(^|/)chain-advance\.sh(\s|$)", - r"(?:^|[;&|(]\s*)git\s+(push|worktree\s+add)\b", - r"(?:^|[;&|(]\s*)gh\s+pr\s+(create|ready|merge)\b", + r"(?:^|[;&|(\n]\s*)git\s+(push|worktree\s+add)\b", + r"(?:^|[;&|(\n]\s*)gh\s+pr\s+(create|ready|merge)\b", ) diff --git a/tests/test-workflow-activation.sh b/tests/test-workflow-activation.sh index fd05ab18..390069ff 100755 --- a/tests/test-workflow-activation.sh +++ b/tests/test-workflow-activation.sh @@ -56,6 +56,9 @@ out=$(hook PreToolUse Bash '{"command":"cd /tmp && git push -u origin fix/x"}') assert_contains "$out" 'pending session acknowledgement' 'pending: git push after a shell operator is still denied' out=$(hook PreToolUse Task '{"prompt":"x"}') assert_contains "$out" 'pending session acknowledgement' 'pending: the Task tool is denied' +newline_input=$(jq -nc --arg c $'cd /tmp\ngit push origin main' '{command:$c}') +out=$(hook PreToolUse Bash "$newline_input") +assert_contains "$out" 'pending session acknowledgement' 'pending: a dispatch command on its own line after a newline is still denied' # Promote, then everything is allowed. nonce=$(jq -r .nonce "$receipt") From e8a2f168fe188dba0b560846d2111c9543a77c59 Mon Sep 17 00:00:00 2001 From: mergetest Date: Wed, 23 Sep 2026 16:29:39 -0700 Subject: [PATCH 07/14] feat(preflight): consume the activation nonce in the first call The receipt used to cost its own tool call. Preflight is the first command every workflow runs, so it now carries --activation-nonce and promotes the record before it writes the contract; a wrong nonce fails before any state. Co-Authored-By: Claude Fable 5.1 --- .../skills/.shared/scripts/agent-preflight.sh | 13 +++++++- .../scripts/lib/workflow-activation.py | 8 +++-- agentkit/skills/onboard-repo/SKILL.md | 8 ++--- agentkit/skills/parallel-issues/SKILL.md | 8 ++--- agentkit/skills/pr-to-green/SKILL.md | 8 ++--- agentkit/skills/review-remote-pr/SKILL.md | 8 ++--- tests/lint-helper-size.sh | 7 +++-- tests/test-agent-preflight.sh | 30 +++++++++++++++++-- tests/test-skill-invocations.sh | 2 +- tests/test-workflow-activation.sh | 3 +- 10 files changed, 69 insertions(+), 26 deletions(-) diff --git a/agentkit/skills/.shared/scripts/agent-preflight.sh b/agentkit/skills/.shared/scripts/agent-preflight.sh index ef540d54..bb118e6d 100755 --- a/agentkit/skills/.shared/scripts/agent-preflight.sh +++ b/agentkit/skills/.shared/scripts/agent-preflight.sh @@ -26,6 +26,7 @@ ARG_ENSURE=0 ARG_ACTIVATION_SESSION="" ARG_ACTIVATION_ORIGIN="" ARG_WORKFLOW="" +ARG_ACTIVATION_NONCE="" ARG_MEASURED_FROM_SET=0 ARG_INHERIT_SESSION="" ARG_INHERIT_SESSION_SET=0 @@ -94,7 +95,7 @@ Options: --ensure Reuse and print a trusted existing contract; run the preflight probes only when that contract is missing or fails contract-read provenance checks. - --activation-session ID --activation-origin PATH --workflow NAME + --activation-session ID --activation-origin PATH --workflow NAME [--activation-nonce NONCE] Require acknowledged workflow receipt and matching installed content before any probes or cached-contract reuse. Missing receipt is an error; installed bytes alone are not activation. @@ -261,6 +262,7 @@ parse_args() { --activation-session) need_value "$@"; ARG_ACTIVATION_SESSION="$2"; shift 2 ;; --activation-origin) need_value "$@"; ARG_ACTIVATION_ORIGIN="$2"; shift 2 ;; --workflow) need_value "$@"; ARG_WORKFLOW="$2"; shift 2 ;; + --activation-nonce) need_value "$@"; ARG_ACTIVATION_NONCE="$2"; shift 2 ;; --measured-from) need_value "$@" ARG_MEASURED_FROM_SET=1 @@ -1298,6 +1300,15 @@ main() { if (( ARG_ENSURE && (ARG_WRITE_SET || ARG_REPO_SET || ARG_MEASURED_FROM_SET || ARG_INHERIT_SESSION_SET) )); then die '--ensure cannot be combined with --write, --repo, --measured-from, or --inherit-session' fi + if [[ -n $ARG_ACTIVATION_NONCE ]]; then + "$SCRIPT_DIR/workflow-activation.sh" ack \ + --repo-root "${ARG_ACTIVATION_ORIGIN:-${ARG_WORKTREE:-$PWD}}" \ + --session "$ARG_ACTIVATION_SESSION" --skill "$ARG_WORKFLOW" \ + --nonce "$ARG_ACTIVATION_NONCE" >/dev/null || { + printf 'agent-preflight: activation receipt failed; rerun with the exact --activation-nonce from your context\n' >&2 + return 1 + } + fi if [[ -n $ARG_ACTIVATION_SESSION || -n $ARG_WORKFLOW ]]; then "$SCRIPT_DIR/workflow-activation.sh" check --repo-root "${ARG_ACTIVATION_ORIGIN:-${ARG_WORKTREE:-$PWD}}" \ --target-root "${ARG_WORKTREE:-$PWD}" --session "$ARG_ACTIVATION_SESSION" \ diff --git a/agentkit/skills/.shared/scripts/lib/workflow-activation.py b/agentkit/skills/.shared/scripts/lib/workflow-activation.py index 55040612..b7465f81 100644 --- a/agentkit/skills/.shared/scripts/lib/workflow-activation.py +++ b/agentkit/skills/.shared/scripts/lib/workflow-activation.py @@ -225,9 +225,11 @@ def validate(args, record, skill=None, require=()): def ack_command(args, record): - return shlex.join([str(Path(args.skills) / ".shared/scripts/workflow-activation.sh"), "ack", - "--repo-root", record["repoRoot"], "--session", record["session"], - "--skill", record["workflow"], "--nonce", record["nonce"]]) + return shlex.join([str(Path(args.skills) / ".shared/scripts/agent-preflight.sh"), + "--activation-session", record["session"], + "--activation-origin", record["repoRoot"], + "--workflow", record["workflow"], + "--activation-nonce", record["nonce"]]) def deliver(args, evidence, workflow, source, capabilities, recovery=False): diff --git a/agentkit/skills/onboard-repo/SKILL.md b/agentkit/skills/onboard-repo/SKILL.md index 088ad6df..ba7a7057 100644 --- a/agentkit/skills/onboard-repo/SKILL.md +++ b/agentkit/skills/onboard-repo/SKILL.md @@ -12,10 +12,10 @@ description: >- ## Step 0 prerequisite: verified activation -First run UserPromptSubmit's exact `$agentkit/.shared/scripts/workflow-activation.sh ack` command; -stdout begins `agentkit: skill=onboard-repo version= hash=` (receipt, not registry proof). -Before work, require `workflow-activation.sh check --require pre-tool-use --repo-root R --session ID --skill onboard-repo`; -`check` needs no other flags here. `$agentkit/.shared/scripts/agent-preflight.sh` separately takes `--activation-session ID --activation-origin R --workflow onboard-repo`, with the same R. +First run UserPromptSubmit's exact `$agentkit/.shared/scripts/agent-preflight.sh` command; +stdout begins `skills=` (contract, not registry proof). +Before work, require `$agentkit/.shared/scripts/workflow-activation.sh check --require pre-tool-use --repo-root R --session ID --skill onboard-repo`; +`check` needs no other flags here. `$agentkit/.shared/scripts/agent-preflight.sh` separately takes `--activation-session ID --activation-origin R --workflow onboard-repo`, plus `--activation-nonce N`. Missing challenge: report `agentkit: activation-unavailable` and stop without substituting unless the user's own message explicitly requests the no-delivery reference use described below. Recovery: resubmit `$agentkit:onboard-repo`; natural triggers also deliver. diff --git a/agentkit/skills/parallel-issues/SKILL.md b/agentkit/skills/parallel-issues/SKILL.md index 7276c3c5..51d25dc0 100755 --- a/agentkit/skills/parallel-issues/SKILL.md +++ b/agentkit/skills/parallel-issues/SKILL.md @@ -16,10 +16,10 @@ description: >- ## Step 0 prerequisite: verified activation -First run UserPromptSubmit's exact `$agentkit/.shared/scripts/workflow-activation.sh ack` command; -stdout begins `agentkit: skill=parallel-issues version= hash=` (receipt, not registry proof). -Before dispatch, require `workflow-activation.sh check --require pre-tool-use --repo-root R --session ID --skill parallel-issues`; -`check` needs no other flags here. `$agentkit/.shared/scripts/agent-preflight.sh` separately takes `--activation-session ID --activation-origin R --workflow parallel-issues`, with the same R. +First run UserPromptSubmit's exact `$agentkit/.shared/scripts/agent-preflight.sh` command; +stdout begins `skills=` (contract, not registry proof). +Before dispatch, require `$agentkit/.shared/scripts/workflow-activation.sh check --require pre-tool-use --repo-root R --session ID --skill parallel-issues`; +`check` needs no other flags here. `$agentkit/.shared/scripts/agent-preflight.sh` separately takes `--activation-session ID --activation-origin R --workflow parallel-issues`, plus `--activation-nonce N`. Missing challenge: report `agentkit: activation-unavailable` and stop without substituting unless the user's own message explicitly requests the no-delivery reference use described below. For recovery, resubmit `$agentkit:parallel-issues`; advertised natural triggers also deliver. diff --git a/agentkit/skills/pr-to-green/SKILL.md b/agentkit/skills/pr-to-green/SKILL.md index a68c1e3d..537d763b 100644 --- a/agentkit/skills/pr-to-green/SKILL.md +++ b/agentkit/skills/pr-to-green/SKILL.md @@ -12,10 +12,10 @@ description: >- ## Step 0 prerequisite: verified activation -First run UserPromptSubmit's exact `$agentkit/.shared/scripts/workflow-activation.sh ack` command; -stdout begins `agentkit: skill=pr-to-green version= hash=` (receipt, not registry proof). -Before work, require `workflow-activation.sh check --require pre-tool-use --repo-root R --session ID --skill pr-to-green`; -`check` needs no other flags here. `$agentkit/.shared/scripts/agent-preflight.sh` separately takes `--activation-session ID --activation-origin R --workflow pr-to-green`, with the same R. +First run UserPromptSubmit's exact `$agentkit/.shared/scripts/agent-preflight.sh` command; +stdout begins `skills=` (contract, not registry proof). +Before work, require `$agentkit/.shared/scripts/workflow-activation.sh check --require pre-tool-use --repo-root R --session ID --skill pr-to-green`; +`check` needs no other flags here. `$agentkit/.shared/scripts/agent-preflight.sh` separately takes `--activation-session ID --activation-origin R --workflow pr-to-green`, plus `--activation-nonce N`. Missing challenge: report `agentkit: activation-unavailable` and stop without substituting unless the user's own message explicitly requests the no-delivery reference use described below. Recovery: resubmit `$agentkit:pr-to-green`; natural triggers also deliver. diff --git a/agentkit/skills/review-remote-pr/SKILL.md b/agentkit/skills/review-remote-pr/SKILL.md index 442ab9e5..2b2298ce 100755 --- a/agentkit/skills/review-remote-pr/SKILL.md +++ b/agentkit/skills/review-remote-pr/SKILL.md @@ -7,10 +7,10 @@ description: Use when asked to review, babysit, monitor, or clean up a remote PR ## Step 0 prerequisite: verified activation -First run UserPromptSubmit's exact `$agentkit/.shared/scripts/workflow-activation.sh ack` command; -stdout begins `agentkit: skill=review-remote-pr version= hash=` (receipt, not registry proof). -Before work, require `workflow-activation.sh check --require pre-tool-use --repo-root R --session ID --skill review-remote-pr`; -`check` needs no other flags here. `$agentkit/.shared/scripts/agent-preflight.sh` separately takes `--activation-session ID --activation-origin R --workflow review-remote-pr`, with the same R. +First run UserPromptSubmit's exact `$agentkit/.shared/scripts/agent-preflight.sh` command; +stdout begins `skills=` (contract, not registry proof). +Before work, require `$agentkit/.shared/scripts/workflow-activation.sh check --require pre-tool-use --repo-root R --session ID --skill review-remote-pr`; +`check` needs no other flags here. `$agentkit/.shared/scripts/agent-preflight.sh` separately takes `--activation-session ID --activation-origin R --workflow review-remote-pr`, plus `--activation-nonce N`. Missing challenge: report `agentkit: activation-unavailable` and stop without substituting unless the user's own message explicitly requests the no-delivery reference use described below. Recovery: resubmit `$agentkit:review-remote-pr`; natural triggers also deliver. diff --git a/tests/lint-helper-size.sh b/tests/lint-helper-size.sh index bd72eaf8..dc676fb4 100755 --- a/tests/lint-helper-size.sh +++ b/tests/lint-helper-size.sh @@ -25,7 +25,8 @@ declare -A KNOWN_OVERSIZE=( [hooks/lib/guard-lib.sh]="2288:24898:800" # #777: complete Step 0 recipe moved from injected prose into --help. # #777 absolute-path guard + #778 harness-bound runtime-tool record. - [skills/.shared/scripts/agent-preflight.sh]="1401:17157:800" + # activation-gate option B task 2: --activation-nonce flag + ack-before-check. + [skills/.shared/scripts/agent-preflight.sh]="1407:17228:800" # #731/#732/#776/#809/#874/PR #877: yielded-run status and lease lifecycle. # #873: bind repair evidence to the clean committed head tested before push. # #874 review repair: stable process identity and canonical fallback boundaries. @@ -199,7 +200,9 @@ readonly MAX_HELPER_TOKENS=10000 # #873/#874 + #875 + #876 provider-alias repair: exact combined helper tree measurement. # #873 PR repair: verification-scoped post-run cleanliness and setup compatibility. # #889: triage --help ends with the selection helper's shipped path: exact tree measurement. -readonly MAX_TREE_TOKENS=477442 +# activation-gate option B task 2: --activation-nonce flag folds the receipt +# into agent-preflight.sh's first call; exact combined helper tree measurement. +readonly MAX_TREE_TOKENS=477595 violations=0 checked=0 diff --git a/tests/test-agent-preflight.sh b/tests/test-agent-preflight.sh index 924772d3..1315fd2a 100755 --- a/tests/test-agent-preflight.sh +++ b/tests/test-agent-preflight.sh @@ -1453,8 +1453,10 @@ assert_contains "$cargo_writable_line" " CARGO_HOME=$cargo_writable_home/.cargo # issue #610: caches= grew CARGO_HOME/GOMODCACHE in place; issue #690 review: # +10 lines for the writable-default-cargo-home check the contract token now # mirrors from agent-run.sh's select_cargo_home. Ratchet down to the measured count. -assert_eq yes "$([[ $(wc -l < "$root/agentkit/skills/.shared/scripts/agent-preflight.sh") -le 1401 ]] && printf yes || printf no)" \ - 'agent-preflight.sh stays at or under 1401 lines (including absolute-path guard and harness-bound tools)' +# activation-gate option B task 2: +6 lines for the --activation-nonce flag and +# the ack-before-check block in main() that folds the receipt into preflight. +assert_eq yes "$([[ $(wc -l < "$root/agentkit/skills/.shared/scripts/agent-preflight.sh") -le 1407 ]] && printf yes || printf no)" \ + 'agent-preflight.sh stays at or under 1407 lines (including absolute-path guard and harness-bound tools)' assert_eq yes "$([[ $(wc -l < "$root/agentkit/skills/.shared/scripts/lib/gh-budget.sh") -le 42 ]] && printf yes || printf no)" \ 'lib/gh-budget.sh stays at or under 42 lines' assert_eq yes "$([[ $(wc -l < "$root/agentkit/skills/.shared/scripts/lib/sandbox-comparator.sh") -le 53 ]] && printf yes || printf no)" \ @@ -1507,4 +1509,28 @@ for workflow in pr-to-green review-remote-pr onboard-repo; do done done +# --activation-nonce folds the receipt into the one call the root already makes. +skills="$root/agentkit/skills" +nonce_repo=$(mktemp -d); git -C "$nonce_repo" init -q; install -d -m 700 "$nonce_repo/.agent" +nonce_session=nonce-session-$$ +jq -nc --arg s "$nonce_session" --arg c "$nonce_repo" \ + '{hook_event_name:"UserPromptSubmit", session_id:$s, cwd:$c, prompt:"$agentkit:parallel-issues 1"}' \ + | "$skills/.shared/scripts/workflow-activation.sh" hook >/dev/null +nonce_receipt=$(ls "$nonce_repo/.agent/activation/"*.json) +good_nonce=$(jq -r .nonce "$nonce_receipt") + +bad_rc=0 +bad_out=$("$script" --worktree "$nonce_repo" --activation-session "$nonce_session" --activation-origin "$nonce_repo" \ + --workflow parallel-issues --activation-nonce not-the-nonce 2>&1) || bad_rc=$? +assert_eq 1 "$bad_rc" 'a wrong nonce fails preflight' +assert_contains "$bad_out" 'session receipt challenge mismatch' 'a wrong nonce names the cause' +assert_eq pending "$(jq -r .status "$nonce_receipt")" 'a wrong nonce leaves the record pending' +[[ ! -e $nonce_repo/.agent/env-contract.txt ]] || assert_eq absent present 'a wrong nonce must not write the contract' + +good_out=$("$script" --worktree "$nonce_repo" --activation-session "$nonce_session" --activation-origin "$nonce_repo" \ + --workflow parallel-issues --activation-nonce "$good_nonce" 2>&1) +assert_eq active "$(jq -r .status "$nonce_receipt")" 'the right nonce promotes the record' +assert_contains "$good_out" 'agent-preflight: wrote' 'the right nonce completes preflight' +rm -rf "$nonce_repo" + finish diff --git a/tests/test-skill-invocations.sh b/tests/test-skill-invocations.sh index e60cccae..4b03be0e 100755 --- a/tests/test-skill-invocations.sh +++ b/tests/test-skill-invocations.sh @@ -412,7 +412,7 @@ assert_contains "$LINT_OUT" 'EXPECTED zero full resolver definitions in referenc for skill in parallel-issues pr-to-green review-remote-pr onboard-repo; do skill_text=$(<"$here/../agentkit/skills/$skill/SKILL.md") assert_contains "$skill_text" 'workflow-activation.sh' "$skill requires boundary receipt" - assert_contains "$skill_text" "agentkit: skill=$skill version= hash=" "$skill specifies first identity output" + assert_contains "$skill_text" 'stdout begins `skills=`' "$skill specifies first identity output" done finish diff --git a/tests/test-workflow-activation.sh b/tests/test-workflow-activation.sh index 390069ff..5a28fdcc 100755 --- a/tests/test-workflow-activation.sh +++ b/tests/test-workflow-activation.sh @@ -20,7 +20,8 @@ hook() { # event tool json-tool-input [extra-json-fields] # Arm a pending record exactly as UserPromptSubmit does. delivered=$(jq -nc --arg s "$session" --arg c "$repo" \ '{hook_event_name:"UserPromptSubmit", session_id:$s, cwd:$c, prompt:"$agentkit:parallel-issues 1"}' | "$wa" hook) -assert_contains "$delivered" 'workflow-activation.sh ack' 'delivery names the receipt command' +assert_contains "$delivered" 'agent-preflight.sh' 'delivery names the preflight line' +assert_contains "$delivered" '--activation-nonce' 'delivery carries the nonce flag' receipt=$(ls "$repo/.agent/activation/"*.json) assert_eq pending "$(jq -r .status "$receipt")" 'record starts pending' From 2adbeab7071e8b8cf4dee57dedd2b35290859e9b Mon Sep 17 00:00:00 2001 From: mergetest Date: Wed, 23 Sep 2026 16:47:52 -0700 Subject: [PATCH 08/14] perf(activation): deliver the receipt line, not the skill body The native skill injection already delivers the full body; the hook's copy was truncated and spilled to disk on both harnesses. Delivery is now the preflight line plus identity, under the context caps of either harness. The probe suite's stale-leaf and upgrade-resume tests asserted on the now-removed embedded body text; they now assert on deliveredDigest matching the current on-disk bytes instead. The advertised-invocation and explicit-selector tests asserted on a literal "--skill" flag that only appeared inside the removed body copy; the actual preflight command uses --workflow, so the assertions now match it. Co-Authored-By: Claude Fable 5.1 --- .../scripts/lib/workflow-activation.py | 22 +++++++++++++------ tests/probe/test-activation.py | 9 ++++---- tests/test-workflow-activation.sh | 20 +++++++++++++++++ 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/agentkit/skills/.shared/scripts/lib/workflow-activation.py b/agentkit/skills/.shared/scripts/lib/workflow-activation.py index b7465f81..292510c4 100644 --- a/agentkit/skills/.shared/scripts/lib/workflow-activation.py +++ b/agentkit/skills/.shared/scripts/lib/workflow-activation.py @@ -249,16 +249,22 @@ def deliver(args, evidence, workflow, source, capabilities, recovery=False): "run this exact receipt command, then resume the assigned work in the same worktree:\n") else: lead = ("agentkit invocation boundary: explicit workflow delivery, not native registry evidence. " - "Before any dispatch, edits, or other workflow, run this exact receipt command. " - "You may inspect the installed helper first; its first receipt stdout line is the workflow identity:\n") - context = (lead + ack_command(args, record) - + "\nMissing capability remains unknown. Do not substitute another workflow.\n" - + "Installed skills root: " + args.skills + "\n\n" + body.decode()) + "Run this exact preflight command first; it records the session receipt:\n") + context = (lead + ack_command(args, record) + "\n" + + "agentkit: skill=" + workflow + " version=" + identity(args) + + " hash=" + args.digest[:12] + "\n" + + "Installed skills root: " + args.skills + "\n" + + "Missing capability remains unknown. Do not substitute another workflow.") return record, context def inspection(args, root, tool, tool_input): - """Permit a bounded file inspection, never a general shell expression.""" + """Permit a bounded file inspection, never a general shell expression. + + Serves the stale-active path only: a content-mismatched active record still + permits bounded diagnostic reads and searches before validate() raises + ContentMismatch. + """ directory = False if tool == "Read": paths = [tool_input.get("file_path", "")] @@ -400,7 +406,9 @@ def hook(args): record["capabilities"]["pre-tool-use"] = "unknown" evidence.write(record) return {"hookSpecificOutput": {"hookEventName": event, "additionalContext": - "agentkit durable activation: " + json.dumps(record, sort_keys=True) + "agentkit durable activation: workflow=" + record["workflow"] + + " status=" + record.get("status", "unknown") + + " version=" + record.get("version", "unknown") + "; historical session receipt only, not proof of this context's native registry. " + ("" if record.get("status") == "active" else "Run: " + ack_command(args, record))}} return {} diff --git a/tests/probe/test-activation.py b/tests/probe/test-activation.py index e8ffd492..abd90138 100644 --- a/tests/probe/test-activation.py +++ b/tests/probe/test-activation.py @@ -462,7 +462,8 @@ def test_upgrade_resume_redelivers_and_preserves_saved_state(self): self.assertIn("$agentkit:parallel-issues", json.dumps(output)) self.assertNotEqual(self.check().returncode, 0) self.payload["prompt"] = "$agentkit:parallel-issues --yolo --fast-mode" - self.assertIn("Updated workflow content", json.dumps(self.prompt())) + self.prompt() + self.assertEqual(self.record()["deliveredDigest"], hashlib.sha256(body.read_bytes()).hexdigest()) self.assertEqual(self.record()["status"], "pending") self.assertNotEqual(old["nonce"], self.record()["nonce"]) denied = self.public_event("PreToolUse", tool_name="Agent", tool_input={"prompt": "run"}) @@ -506,8 +507,8 @@ def test_stale_leaf_receipt_hands_back_once_and_root_redelivers_to_same_worker(s "--session", handback["session"], "--skill", handback["workflow"]) self.assertEqual(delivery.returncode, 0, delivery.stderr) - self.assertIn("Same-version recovery content", delivery.stdout) refreshed = self.record() + self.assertEqual(refreshed["deliveredDigest"], hashlib.sha256(body.read_bytes()).hexdigest()) self.assertEqual(refreshed["deliverySource"], "root-redelivery") self.assertEqual(refreshed["status"], "pending") self.assertNotEqual(refreshed["nonce"], old["nonce"]) @@ -599,7 +600,7 @@ def test_advertised_invocations_deliver_fresh_challenges(self): self.payload.update(prompt=prompt, session_id="natural-" + workflow) output = self.prompt() self.assertIn("invocation boundary", json.dumps(output)) - self.assertIn("--skill " + workflow, json.dumps(output)) + self.assertIn("--workflow " + workflow, json.dumps(output)) def test_quoted_negated_and_question_prompts_do_not_activate(self): for prompt in ('"run these issues in parallel"', 'Do not resume parallel-issues', @@ -632,7 +633,7 @@ def test_explicit_selector_precedes_attached_workflow_mentions(self): for selector in ("$agentkit:parallel-issues", "/parallel-issues"): with self.subTest(selector=selector): self.payload["prompt"] = selector + " 57 54 — issue text mentions pr-to-green" - self.assertIn("--skill parallel-issues", json.dumps(self.prompt())) + self.assertIn("--workflow parallel-issues", json.dumps(self.prompt())) self.assertEqual(self.record()["workflow"], "parallel-issues") def test_pending_upgrade_resume_does_not_offer_stale_ack(self): diff --git a/tests/test-workflow-activation.sh b/tests/test-workflow-activation.sh index 5a28fdcc..c3b1058f 100755 --- a/tests/test-workflow-activation.sh +++ b/tests/test-workflow-activation.sh @@ -73,4 +73,24 @@ session=cold-session-$$ out=$(hook PreToolUse Bash '{"command":"git push -u origin fix/x"}') assert_eq '{}' "$out" 'no receipt: nothing is gated' +# Delivery carries identity and the first command, never the skill body. +session=delivery-session-$$ +delivered=$(jq -nc --arg s "$session" --arg c "$repo" \ + '{hook_event_name:"UserPromptSubmit", session_id:$s, cwd:$c, prompt:"$agentkit:parallel-issues 1"}' | "$wa" hook) +context=$(jq -r '.hookSpecificOutput.additionalContext' <<<"$delivered") +assert_not_contains "$context" '### Step' 'delivery does not embed the skill body' +assert_contains "$context" 'skill=parallel-issues version=' 'delivery names the workflow identity' +assert_contains "$context" '--activation-nonce' 'delivery names the preflight line' +(( ${#context} < 1500 )) || assert_eq 'under-1500' "${#context}" 'delivery stays under the harness context caps' +receipt="$repo/.agent/activation/$(printf '%s' "$session" | sha256sum | cut -d' ' -f1).json" +assert_eq "$(sha256sum "$skills/parallel-issues/SKILL.md" | cut -d' ' -f1)" "$(jq -r .deliveredDigest "$receipt")" \ + 'deliveredDigest is still the on-disk skill digest' + +# A resumed session with a pending record re-delivers the first command and stays short. +resumed=$(jq -nc --arg s "$session" --arg c "$repo" \ + '{hook_event_name:"SessionStart", source:"resume", session_id:$s, cwd:$c}' | "$wa" hook) +rcontext=$(jq -r '.hookSpecificOutput.additionalContext' <<<"$resumed") +assert_contains "$rcontext" '--activation-nonce' 'resume re-delivers the preflight line' +(( ${#rcontext} < 1500 )) || assert_eq 'under-1500' "${#rcontext}" 'resume context stays short' + finish From 2acae362de72e0605c7bc9ffeed51d21d6b84a3e Mon Sep 17 00:00:00 2001 From: mergetest Date: Wed, 23 Sep 2026 16:51:01 -0700 Subject: [PATCH 09/14] chore(release): move main to unpublished 0.9.14 v0.9.13 is tagged and shipped bytes changed under it on this branch, so tests/check-release-version.sh fails. Move source and generated manifests to the next unpublished patch (prepare-next-version.sh cannot run from a linked worktree, so this applies its bump-version.sh + build-plugin.sh steps by hand). Co-Authored-By: Claude Sonnet 5 --- agentkit/.claude-plugin/plugin.json | 2 +- agentkit/.codex-plugin/plugin.json | 2 +- opencode/package.json | 2 +- plugin/agentkit/.claude-plugin/plugin.json | 2 +- plugin/agentkit/.codex-plugin/plugin.json | 2 +- plugin/opencode/package.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/agentkit/.claude-plugin/plugin.json b/agentkit/.claude-plugin/plugin.json index a7036d38..d9fcc4e7 100644 --- a/agentkit/.claude-plugin/plugin.json +++ b/agentkit/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agentkit", - "version": "0.9.13", + "version": "0.9.14", "description": "Board-aware parallel issue and PR review skills, with lifecycle hooks and a per-repository contract.", "author": { "name": "wrzonance", diff --git a/agentkit/.codex-plugin/plugin.json b/agentkit/.codex-plugin/plugin.json index 9cf3162e..1c72cefe 100644 --- a/agentkit/.codex-plugin/plugin.json +++ b/agentkit/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agentkit", - "version": "0.9.13", + "version": "0.9.14", "description": "Board-aware parallel issue and PR review skills, with lifecycle hooks and a per-repository contract.", "author": { "name": "wrzonance", diff --git a/opencode/package.json b/opencode/package.json index d4fcbe01..780b1677 100644 --- a/opencode/package.json +++ b/opencode/package.json @@ -1,6 +1,6 @@ { "name": "@wrzonance/agentkit-opencode", - "version": "0.9.13", + "version": "0.9.14", "description": "Agent Kit plugin for OpenCode CLI: injects the environment contract into the model's system prompt at session start.", "type": "module", "main": "./index.js", diff --git a/plugin/agentkit/.claude-plugin/plugin.json b/plugin/agentkit/.claude-plugin/plugin.json index a7036d38..d9fcc4e7 100644 --- a/plugin/agentkit/.claude-plugin/plugin.json +++ b/plugin/agentkit/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agentkit", - "version": "0.9.13", + "version": "0.9.14", "description": "Board-aware parallel issue and PR review skills, with lifecycle hooks and a per-repository contract.", "author": { "name": "wrzonance", diff --git a/plugin/agentkit/.codex-plugin/plugin.json b/plugin/agentkit/.codex-plugin/plugin.json index 9cf3162e..1c72cefe 100644 --- a/plugin/agentkit/.codex-plugin/plugin.json +++ b/plugin/agentkit/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agentkit", - "version": "0.9.13", + "version": "0.9.14", "description": "Board-aware parallel issue and PR review skills, with lifecycle hooks and a per-repository contract.", "author": { "name": "wrzonance", diff --git a/plugin/opencode/package.json b/plugin/opencode/package.json index d4fcbe01..780b1677 100644 --- a/plugin/opencode/package.json +++ b/plugin/opencode/package.json @@ -1,6 +1,6 @@ { "name": "@wrzonance/agentkit-opencode", - "version": "0.9.13", + "version": "0.9.14", "description": "Agent Kit plugin for OpenCode CLI: injects the environment contract into the model's system prompt at session start.", "type": "module", "main": "./index.js", From 8952014ed5f64a52c43906c0e5be71e45881038d Mon Sep 17 00:00:00 2001 From: mergetest Date: Wed, 23 Sep 2026 16:52:45 -0700 Subject: [PATCH 10/14] bench(activation): record post-change ordering and denied-call counts Claude Code confirms ORDER=context-before-first-call with 0 denied calls against the rebuilt plugin tree, down from 2 pre-change. The Codex leg could not be exercised non-interactively: a per-repo .codex/hooks.json pointed at this branch's hooks is untrusted and silently skipped in favor of the already-trusted installed 0.9.13 cache, so its 1 denied call reflects the old hooks, not this change. Co-Authored-By: Claude Sonnet 5 --- bench/fixtures/activation-ordering/README.md | 27 ++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/bench/fixtures/activation-ordering/README.md b/bench/fixtures/activation-ordering/README.md index b7cd3568..ce073c7c 100644 --- a/bench/fixtures/activation-ordering/README.md +++ b/bench/fixtures/activation-ordering/README.md @@ -34,3 +34,30 @@ context reach the model before its first tool call? transcript entry — but the receipt file and the matched nonce are direct proof the hook ran and its context reached the model before its first tool call. + +## Post-change results (fix/activation-option-b, 2026-09-23) + +- **claude** (Claude Code 2.1.281): `ORDER=context-before-first-call + harness=claude`, run via `bench/activation-ordering.sh --harness claude + --repo --plugin-dir /plugin/agentkit` against the + rebuilt plugin tree (`tests/build-plugin.sh`, 0.9.14). Denied-call count: + `grep -c 'pending session acknowledgement' transcript.jsonl` = **0** + (down from 2 on the pre-change 2026-09-23 cable-tool run). The receipt's + `skillsRoot` points at the built worktree tree, confirming the branch's + hooks were the ones exercised. +- **codex** (codex-cli 0.155.1): probe attempted once by writing a + per-repo `.codex/hooks.json` in the clone, mirroring + `agentkit/hooks/hooks.json` with commands pointed at + `/plugin/agentkit/hooks/*.sh`, then running + `bench/activation-ordering.sh --harness codex --repo ` with no + `--dangerously-bypass-hook-trust` or other trust bypass. Codex ran the + probe (`ORDER=context-before-first-call`) but the written receipt's + `skillsRoot` is `/home/adam/.codex/plugins/cache/agent-kit/agentkit/0.9.13/skills` + — the untrusted per-repo hooks were silently skipped in favor of the + user's already-trusted installed plugin cache (still 0.9.13, pre-change), + and the denied-call count is **1**, matching pre-change behavior, not + this branch's. Codex has no per-session plugin-dir and only trusts hooks + by hash inside the interactive TUI's `/hooks` flow, so this branch's + hooks cannot be exercised non-interactively from an untrusted repo. This + leg is unverified pending a manual TUI trust step; see the PR's Testing + checklist for the follow-up. From b2a2a4c71e7e808e300c3157936e4fd0712bc95d Mon Sep 17 00:00:00 2001 From: mergetest Date: Wed, 23 Sep 2026 17:36:51 -0700 Subject: [PATCH 11/14] fix(activation): match dispatch commands as agents compose them Final-review fixes for fix/activation-option-b: - Important #1: executed_text() unwraps single-token quoted strings before stripping multi-token ones, so the kit's own documented quoted absolute helper-path invocation is matched instead of treated as inert data. - Important #2: git/gh DISPATCH_COMMANDS patterns use a non-path-boundary lookbehind with optional -c/-C groups instead of a shell-operator anchor, catching git -C, leading whitespace, and loop/conditional bodies that the anchor missed once heredocs and quotes are already stripped. - Minor #1: helper-name patterns anchor to command position so a read of a helper file (sed/rg) is no longer denied while pending. - Minor #2: agent-preflight.sh dies (exit 2) naming the missing flag when --activation-nonce is given without --activation-session/--workflow, instead of reaching ack with empty required flags. - Minor #3: <<- heredocs with a tab-indented terminator strip correctly. - Minor #5: Step 0's "separately takes" sentence no longer invites a redundant second preflight call, in all four SKILL.md files; all four end up smaller. - Minor #7: deliver() reuses record["version"] instead of a second identity(args) call. Co-Authored-By: Claude Fable 5.1 --- .../skills/.shared/scripts/agent-preflight.sh | 4 +++ .../scripts/lib/workflow-activation.py | 22 +++++++++------ agentkit/skills/onboard-repo/SKILL.md | 2 +- agentkit/skills/parallel-issues/SKILL.md | 2 +- agentkit/skills/pr-to-green/SKILL.md | 2 +- agentkit/skills/review-remote-pr/SKILL.md | 2 +- tests/lint-helper-size.sh | 7 +++-- tests/test-agent-preflight.sh | 16 +++++++++-- tests/test-rrp-remediation-contract.sh | 4 ++- tests/test-workflow-activation.sh | 28 +++++++++++++++++++ 10 files changed, 71 insertions(+), 18 deletions(-) diff --git a/agentkit/skills/.shared/scripts/agent-preflight.sh b/agentkit/skills/.shared/scripts/agent-preflight.sh index bb118e6d..fc1bf41e 100755 --- a/agentkit/skills/.shared/scripts/agent-preflight.sh +++ b/agentkit/skills/.shared/scripts/agent-preflight.sh @@ -1301,6 +1301,10 @@ main() { die '--ensure cannot be combined with --write, --repo, --measured-from, or --inherit-session' fi if [[ -n $ARG_ACTIVATION_NONCE ]]; then + local -a missing=() + [[ -n $ARG_ACTIVATION_SESSION ]] || missing+=("--activation-session") + [[ -n $ARG_WORKFLOW ]] || missing+=("--workflow") + (( ${#missing[@]} == 0 )) || die "--activation-nonce requires $(join_by ' and ' "${missing[@]}")" "$SCRIPT_DIR/workflow-activation.sh" ack \ --repo-root "${ARG_ACTIVATION_ORIGIN:-${ARG_WORKTREE:-$PWD}}" \ --session "$ARG_ACTIVATION_SESSION" --skill "$ARG_WORKFLOW" \ diff --git a/agentkit/skills/.shared/scripts/lib/workflow-activation.py b/agentkit/skills/.shared/scripts/lib/workflow-activation.py index 292510c4..68c80e72 100644 --- a/agentkit/skills/.shared/scripts/lib/workflow-activation.py +++ b/agentkit/skills/.shared/scripts/lib/workflow-activation.py @@ -251,7 +251,7 @@ def deliver(args, evidence, workflow, source, capabilities, recovery=False): lead = ("agentkit invocation boundary: explicit workflow delivery, not native registry evidence. " "Run this exact preflight command first; it records the session receipt:\n") context = (lead + ack_command(args, record) + "\n" - + "agentkit: skill=" + workflow + " version=" + identity(args) + + "agentkit: skill=" + workflow + " version=" + record["version"] + " hash=" + args.digest[:12] + "\n" + "Installed skills root: " + args.skills + "\n" + "Missing capability remains unknown. Do not substitute another workflow.") @@ -308,20 +308,24 @@ def inspection(args, root, tool, tool_input): DISPATCH_TOOLS = ("Agent", "Task", "spawn_agent", "Skill") DISPATCH_COMMANDS = ( - r"(^|/)create-issue-worktree\.sh(\s|$)", - r"(^|/)worktree-commit\.sh(\s|$)", - r"(^|/)chain-advance\.sh(\s|$)", - r"(?:^|[;&|(\n]\s*)git\s+(push|worktree\s+add)\b", - r"(?:^|[;&|(\n]\s*)gh\s+pr\s+(create|ready|merge)\b", + r"(?:^\s*|[;&|(\n]\s*)(?:\S*/)?create-issue-worktree\.sh(?:\s|$)", + r"(?:^\s*|[;&|(\n]\s*)(?:\S*/)?worktree-commit\.sh(?:\s|$)", + r"(?:^\s*|[;&|(\n]\s*)(?:\S*/)?chain-advance\.sh(?:\s|$)", + r"(?- First run UserPromptSubmit's exact `$agentkit/.shared/scripts/agent-preflight.sh` command; stdout begins `skills=` (contract, not registry proof). Before work, require `$agentkit/.shared/scripts/workflow-activation.sh check --require pre-tool-use --repo-root R --session ID --skill onboard-repo`; -`check` needs no other flags here. `$agentkit/.shared/scripts/agent-preflight.sh` separately takes `--activation-session ID --activation-origin R --workflow onboard-repo`, plus `--activation-nonce N`. +`check` needs no other flags here. `$agentkit/.shared/scripts/agent-preflight.sh` carries `--activation-session ID --activation-origin R --workflow onboard-repo --activation-nonce N`; run it once. Missing challenge: report `agentkit: activation-unavailable` and stop without substituting unless the user's own message explicitly requests the no-delivery reference use described below. Recovery: resubmit `$agentkit:onboard-repo`; natural triggers also deliver. diff --git a/agentkit/skills/parallel-issues/SKILL.md b/agentkit/skills/parallel-issues/SKILL.md index 51d25dc0..f0cebbc7 100755 --- a/agentkit/skills/parallel-issues/SKILL.md +++ b/agentkit/skills/parallel-issues/SKILL.md @@ -19,7 +19,7 @@ description: >- First run UserPromptSubmit's exact `$agentkit/.shared/scripts/agent-preflight.sh` command; stdout begins `skills=` (contract, not registry proof). Before dispatch, require `$agentkit/.shared/scripts/workflow-activation.sh check --require pre-tool-use --repo-root R --session ID --skill parallel-issues`; -`check` needs no other flags here. `$agentkit/.shared/scripts/agent-preflight.sh` separately takes `--activation-session ID --activation-origin R --workflow parallel-issues`, plus `--activation-nonce N`. +`check` needs no other flags here. `$agentkit/.shared/scripts/agent-preflight.sh` carries `--activation-session ID --activation-origin R --workflow parallel-issues --activation-nonce N`; run it once. Missing challenge: report `agentkit: activation-unavailable` and stop without substituting unless the user's own message explicitly requests the no-delivery reference use described below. For recovery, resubmit `$agentkit:parallel-issues`; advertised natural triggers also deliver. diff --git a/agentkit/skills/pr-to-green/SKILL.md b/agentkit/skills/pr-to-green/SKILL.md index 537d763b..d69d556d 100644 --- a/agentkit/skills/pr-to-green/SKILL.md +++ b/agentkit/skills/pr-to-green/SKILL.md @@ -15,7 +15,7 @@ description: >- First run UserPromptSubmit's exact `$agentkit/.shared/scripts/agent-preflight.sh` command; stdout begins `skills=` (contract, not registry proof). Before work, require `$agentkit/.shared/scripts/workflow-activation.sh check --require pre-tool-use --repo-root R --session ID --skill pr-to-green`; -`check` needs no other flags here. `$agentkit/.shared/scripts/agent-preflight.sh` separately takes `--activation-session ID --activation-origin R --workflow pr-to-green`, plus `--activation-nonce N`. +`check` needs no other flags here. `$agentkit/.shared/scripts/agent-preflight.sh` carries `--activation-session ID --activation-origin R --workflow pr-to-green --activation-nonce N`; run it once. Missing challenge: report `agentkit: activation-unavailable` and stop without substituting unless the user's own message explicitly requests the no-delivery reference use described below. Recovery: resubmit `$agentkit:pr-to-green`; natural triggers also deliver. diff --git a/agentkit/skills/review-remote-pr/SKILL.md b/agentkit/skills/review-remote-pr/SKILL.md index 2b2298ce..a4d47e3b 100755 --- a/agentkit/skills/review-remote-pr/SKILL.md +++ b/agentkit/skills/review-remote-pr/SKILL.md @@ -10,7 +10,7 @@ description: Use when asked to review, babysit, monitor, or clean up a remote PR First run UserPromptSubmit's exact `$agentkit/.shared/scripts/agent-preflight.sh` command; stdout begins `skills=` (contract, not registry proof). Before work, require `$agentkit/.shared/scripts/workflow-activation.sh check --require pre-tool-use --repo-root R --session ID --skill review-remote-pr`; -`check` needs no other flags here. `$agentkit/.shared/scripts/agent-preflight.sh` separately takes `--activation-session ID --activation-origin R --workflow review-remote-pr`, plus `--activation-nonce N`. +`check` needs no other flags here. `$agentkit/.shared/scripts/agent-preflight.sh` carries `--activation-session ID --activation-origin R --workflow review-remote-pr --activation-nonce N`; run it once. Missing challenge: report `agentkit: activation-unavailable` and stop without substituting unless the user's own message explicitly requests the no-delivery reference use described below. Recovery: resubmit `$agentkit:review-remote-pr`; natural triggers also deliver. diff --git a/tests/lint-helper-size.sh b/tests/lint-helper-size.sh index dc676fb4..344aa7e5 100755 --- a/tests/lint-helper-size.sh +++ b/tests/lint-helper-size.sh @@ -26,7 +26,9 @@ declare -A KNOWN_OVERSIZE=( # #777: complete Step 0 recipe moved from injected prose into --help. # #777 absolute-path guard + #778 harness-bound runtime-tool record. # activation-gate option B task 2: --activation-nonce flag + ack-before-check. - [skills/.shared/scripts/agent-preflight.sh]="1407:17228:800" + # final-review Minor #2: +4 lines / +67 tokens so a lone --activation-nonce + # dies (exit 2) naming the missing --activation-session/--workflow flag. + [skills/.shared/scripts/agent-preflight.sh]="1411:17295:800" # #731/#732/#776/#809/#874/PR #877: yielded-run status and lease lifecycle. # #873: bind repair evidence to the clean committed head tested before push. # #874 review repair: stable process identity and canonical fallback boundaries. @@ -202,7 +204,8 @@ readonly MAX_HELPER_TOKENS=10000 # #889: triage --help ends with the selection helper's shipped path: exact tree measurement. # activation-gate option B task 2: --activation-nonce flag folds the receipt # into agent-preflight.sh's first call; exact combined helper tree measurement. -readonly MAX_TREE_TOKENS=477595 +# final-review Minor #2: +67 tokens from agent-preflight.sh's usage-error guard. +readonly MAX_TREE_TOKENS=477662 violations=0 checked=0 diff --git a/tests/test-agent-preflight.sh b/tests/test-agent-preflight.sh index 1315fd2a..8b86a526 100755 --- a/tests/test-agent-preflight.sh +++ b/tests/test-agent-preflight.sh @@ -1455,8 +1455,11 @@ assert_contains "$cargo_writable_line" " CARGO_HOME=$cargo_writable_home/.cargo # mirrors from agent-run.sh's select_cargo_home. Ratchet down to the measured count. # activation-gate option B task 2: +6 lines for the --activation-nonce flag and # the ack-before-check block in main() that folds the receipt into preflight. -assert_eq yes "$([[ $(wc -l < "$root/agentkit/skills/.shared/scripts/agent-preflight.sh") -le 1407 ]] && printf yes || printf no)" \ - 'agent-preflight.sh stays at or under 1407 lines (including absolute-path guard and harness-bound tools)' +# final-review Minor #2: +4 lines so a lone --activation-nonce dies (exit 2) +# naming the missing --activation-session/--workflow flag instead of reaching +# ack with empty required flags. +assert_eq yes "$([[ $(wc -l < "$root/agentkit/skills/.shared/scripts/agent-preflight.sh") -le 1411 ]] && printf yes || printf no)" \ + 'agent-preflight.sh stays at or under 1411 lines (including absolute-path guard and harness-bound tools)' assert_eq yes "$([[ $(wc -l < "$root/agentkit/skills/.shared/scripts/lib/gh-budget.sh") -le 42 ]] && printf yes || printf no)" \ 'lib/gh-budget.sh stays at or under 42 lines' assert_eq yes "$([[ $(wc -l < "$root/agentkit/skills/.shared/scripts/lib/sandbox-comparator.sh") -le 53 ]] && printf yes || printf no)" \ @@ -1533,4 +1536,13 @@ assert_eq active "$(jq -r .status "$nonce_receipt")" 'the right nonce promotes t assert_contains "$good_out" 'agent-preflight: wrote' 'the right nonce completes preflight' rm -rf "$nonce_repo" +# --activation-nonce alone, without --activation-session/--workflow, is bad input: +# die() with the standard usage-error exit (2), naming the missing flag, not the +# "rerun with the exact --activation-nonce" recovery line (which implies the nonce +# itself was wrong). +lone_nonce_rc=0 +lone_nonce_out=$("$script" --activation-nonce abc 2>&1) || lone_nonce_rc=$? +assert_eq 2 "$lone_nonce_rc" 'a lone --activation-nonce is a usage error (exit 2)' +assert_contains "$lone_nonce_out" '--activation-session' 'a lone --activation-nonce names the missing --activation-session flag' + finish diff --git a/tests/test-rrp-remediation-contract.sh b/tests/test-rrp-remediation-contract.sh index 33c09223..be09b073 100755 --- a/tests/test-rrp-remediation-contract.sh +++ b/tests/test-rrp-remediation-contract.sh @@ -125,8 +125,10 @@ for skill in review-remote-pr pr-to-green onboard-repo parallel-issues; do "$skill Step 0 keeps preflight flags out of the check sentence" # Preflight runs from a linked worktree, where the ack receipt is not; it # needs --activation-origin naming the checkout check received. - assert_contains "$step0" '`$agentkit/.shared/scripts/agent-preflight.sh` separately takes '"\`--activation-session ID --activation-origin R --workflow $skill\`" \ + assert_contains "$step0" '`$agentkit/.shared/scripts/agent-preflight.sh` carries '"\`--activation-session ID --activation-origin R --workflow $skill --activation-nonce N\`" \ "$skill Step 0 attributes the session flags, including the activation origin, to preflight" + assert_not_contains "$step0" 'separately takes' \ + "$skill Step 0 no longer invites a redundant second preflight call" done # --- adversarial findings on #873: the receipt block runs as written ---------- diff --git a/tests/test-workflow-activation.sh b/tests/test-workflow-activation.sh index c3b1058f..e57ae204 100755 --- a/tests/test-workflow-activation.sh +++ b/tests/test-workflow-activation.sh @@ -61,6 +61,34 @@ newline_input=$(jq -nc --arg c $'cd /tmp\ngit push origin main' '{command:$c}') out=$(hook PreToolUse Bash "$newline_input") assert_contains "$out" 'pending session acknowledgement' 'pending: a dispatch command on its own line after a newline is still denied' +# A quoted absolute helper path is the kit's own documented invocation form +# and must still be denied while pending, not treated as inert quoted data. +quoted_helper=$(jq -nc --arg c "\"$skills/parallel-issues/scripts/create-issue-worktree.sh\" --issue 1" '{command:$c}') +out=$(hook PreToolUse Bash "$quoted_helper") +assert_contains "$out" 'pending session acknowledgement' 'pending: a quoted absolute helper path is still denied' + +# git/gh prefixes agents actually compose: -C/-c flags, leading whitespace, loops. +out=$(hook PreToolUse Bash '{"command":"git -C .worktrees/x push origin fix/x"}') +assert_contains "$out" 'pending session acknowledgement' 'pending: git -C push is denied' +out=$(hook PreToolUse Bash '{"command":" git push origin fix/x"}') +assert_contains "$out" 'pending session acknowledgement' 'pending: leading-whitespace git push is denied' +loop_input=$(jq -nc --arg c $'for b in x; do git push origin $b; done' '{command:$c}') +out=$(hook PreToolUse Bash "$loop_input") +assert_contains "$out" 'pending session acknowledgement' 'pending: git push inside a for-loop body is denied' + +# Helper-name patterns match the invoked command, not a read of the helper file. +read_helper=$(jq -nc --arg c "sed -n 1,20p $skills/parallel-issues/scripts/create-issue-worktree.sh" '{command:$c}') +out=$(hook PreToolUse Bash "$read_helper") +assert_eq '{}' "$out" 'pending: reading the helper file with sed is allowed' +invoke_helper=$(jq -nc --arg c "$skills/parallel-issues/scripts/create-issue-worktree.sh --issue 1" '{command:$c}') +out=$(hook PreToolUse Bash "$invoke_helper") +assert_contains "$out" 'pending session acknowledgement' 'pending: invoking the absolute helper path is still denied' + +# <<- heredocs with a tab-indented terminator strip like plain heredocs. +tab_heredoc=$(jq -nc --arg c $'cat <<-EOF\n\tgit push origin main\n\tEOF' '{command:$c}') +out=$(hook PreToolUse Bash "$tab_heredoc") +assert_eq '{}' "$out" 'pending: a <<- heredoc with a tab-indented terminator is allowed' + # Promote, then everything is allowed. nonce=$(jq -r .nonce "$receipt") "$wa" ack --repo-root "$repo" --session "$session" --skill parallel-issues --nonce "$nonce" >/dev/null From 90bb892e5890c6fa6cdf7094a05fdfef204a58de Mon Sep 17 00:00:00 2001 From: mergetest Date: Wed, 23 Sep 2026 19:24:49 -0700 Subject: [PATCH 12/14] fix(activation): classify shell -c bodies as executed text; recovery requires re-reading the skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two P2s from a blind adversarial review of PR #894: 1. executed_text() stripped quoted strings as inert data before matching DISPATCH_COMMANDS, so `bash -c 'git push origin HEAD'` was allowed while pending — the kit's own recipes route real commands through `bash -c '...'` on the zsh harness shell. Unwrap `(bash|sh|zsh|dash) -c '...'` bodies into executed text before the generic quote strip runs. 2. deliver(..., recovery=True) told a worker to run the receipt command without ever requiring it to re-read the changed SKILL.md body (neither harness delivers the full body through hook context). The recovery lead now makes reading SKILL.md in full an explicit, ordered prerequisite of the receipt command. Co-Authored-By: Claude Fable 5.1 --- .../.shared/scripts/lib/workflow-activation.py | 18 ++++++++++++++---- tests/probe/test-activation.py | 12 ++++++++++++ tests/test-workflow-activation.sh | 11 +++++++++++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/agentkit/skills/.shared/scripts/lib/workflow-activation.py b/agentkit/skills/.shared/scripts/lib/workflow-activation.py index 68c80e72..38bea927 100644 --- a/agentkit/skills/.shared/scripts/lib/workflow-activation.py +++ b/agentkit/skills/.shared/scripts/lib/workflow-activation.py @@ -244,9 +244,10 @@ def deliver(args, evidence, workflow, source, capabilities, recovery=False): "nonce": secrets.token_hex(24), "capabilities": capabilities} evidence.write(record) if recovery: - lead = ("agentkit root-mediated activation recovery: current workflow bytes are delivered " - "only to refresh this receipt. Do not run or dispatch the orchestration workflow; " - "run this exact receipt command, then resume the assigned work in the same worktree:\n") + lead = ("agentkit root-mediated activation recovery: the workflow content changed. " + "Read " + str(skill) + " in full now (reads are permitted while the receipt " + "is pending), then run this exact receipt command and resume the assigned " + "work in the same worktree:\n") else: lead = ("agentkit invocation boundary: explicit workflow delivery, not native registry evidence. " "Run this exact preflight command first; it records the session receipt:\n") @@ -324,7 +325,16 @@ def executed_text(command): path or invocation and must still match as executed text.""" stripped = re.sub(r"<<-?\s*['\"]?(\w+)['\"]?[^\n]*\n.*?^\t*\1\s*$", " ", command, flags=re.DOTALL | re.MULTILINE) - unwrapped = re.sub(r"'([^'\s]*)'|\"([^\"\s]*)\"", r"\1\2", stripped) + # Unwrap `bash -c '...'` (and sh/zsh/dash, single or double quoted) into executed text + # BEFORE quoted strings are stripped as data: the kit's own recipes wrap commands this + # way (the harness shell is zsh), so the -c body is executed, not inert. One pass only; + # a `bash -c` nested inside another `bash -c` body stays unwrapped as a known gap. + shell_c = re.sub( + r"(?:^|(?<=[\s;&|(]))(?:bash|sh|zsh|dash)\s+(?:-[a-zA-Z]+\s+)*-c\s+" + r"(?:'([^']*)'|\"([^\"]*)\")", + lambda m: " " + (m.group(1) if m.group(1) is not None else m.group(2)) + " ", + stripped) + unwrapped = re.sub(r"'([^'\s]*)'|\"([^\"\s]*)\"", r"\1\2", shell_c) return re.sub(r"'[^']*'|\"[^\"]*\"", " ", unwrapped) diff --git a/tests/probe/test-activation.py b/tests/probe/test-activation.py index abd90138..5befea27 100644 --- a/tests/probe/test-activation.py +++ b/tests/probe/test-activation.py @@ -213,6 +213,18 @@ def test_preflight_reads_origin_receipt_and_measures_linked_target(self): self.assertIn("worktree=" + str(target), result.stdout) self.assertNotIn("worktree=" + str(self.repo) + "\n", result.stdout) + def test_redeliver_requires_rereading_the_skill(self): + self.prompt() + self.assertEqual(self.acknowledge().returncode, 0) + skill = self.plugin / "skills/parallel-issues/SKILL.md" + skill.write_text(skill.read_text() + "\n\n") + result = self.invoke("redeliver", "--repo-root", str(self.repo), "--session", "test-session", + "--skill", "parallel-issues") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("SKILL.md in full", result.stdout) + self.assertIn("--activation-nonce", result.stdout) + self.assertLess(len(result.stdout), 1500) + def test_installed_loaded_mismatch_names_both_versions(self): self.prompt() self.acknowledge() diff --git a/tests/test-workflow-activation.sh b/tests/test-workflow-activation.sh index e57ae204..f055234d 100755 --- a/tests/test-workflow-activation.sh +++ b/tests/test-workflow-activation.sh @@ -89,6 +89,17 @@ tab_heredoc=$(jq -nc --arg c $'cat <<-EOF\n\tgit push origin main\n\tEOF' '{comm out=$(hook PreToolUse Bash "$tab_heredoc") assert_eq '{}' "$out" 'pending: a <<- heredoc with a tab-indented terminator is allowed' +# bash -c bodies are executed text (the kit's own recipes wrap commands this +# way; the harness shell is zsh), not inert quoted data. +out=$(hook PreToolUse Bash '{"command":"bash -c '\''git push origin HEAD'\''"}') +assert_contains "$out" 'pending session acknowledgement' 'pending: bash -c git push is denied' +out=$(hook PreToolUse Bash '{"command":"bash -c \"cd /tmp && git push origin x\""}') +assert_contains "$out" 'pending session acknowledgement' 'pending: bash -c with double quotes and a shell operator is denied' +out=$(hook PreToolUse Bash '{"command":"bash -c '\''printf \"git push\"'\''"}') +assert_eq '{}' "$out" 'pending: dispatch-shaped text inside a nested quote of a bash -c body is still inert' +out=$(hook PreToolUse Bash '{"command":"bash -c '\''ls -la'\''"}') +assert_eq '{}' "$out" 'pending: a harmless bash -c body is allowed' + # Promote, then everything is allowed. nonce=$(jq -r .nonce "$receipt") "$wa" ack --repo-root "$repo" --session "$session" --skill parallel-issues --nonce "$nonce" >/dev/null From 4dd29ef68c6869180be372f5dbef891b17f1e8ba Mon Sep 17 00:00:00 2001 From: mergetest Date: Wed, 23 Sep 2026 19:54:36 -0700 Subject: [PATCH 13/14] fix(activation): match dispatch commands only in command position `git push` and `gh pr create` counted as dispatch wherever a space preceded them, so `echo git push origin main` cost a denied turn while a receipt was pending. The matcher now requires command position: the start of the text, a shell operator, or a known wrapper (env, VAR=x, timeout, nohup, sudo, xargs, exec, do/then). Absolute git paths are covered by the same rule. Co-Authored-By: Claude Fable 5.1 --- .../.shared/scripts/lib/workflow-activation.py | 18 +++++++++++++----- tests/test-workflow-activation.sh | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/agentkit/skills/.shared/scripts/lib/workflow-activation.py b/agentkit/skills/.shared/scripts/lib/workflow-activation.py index 38bea927..7f028c7e 100644 --- a/agentkit/skills/.shared/scripts/lib/workflow-activation.py +++ b/agentkit/skills/.shared/scripts/lib/workflow-activation.py @@ -308,12 +308,20 @@ def inspection(args, root, tool, tool_input): DISPATCH_TOOLS = ("Agent", "Task", "spawn_agent", "Skill") +# Command position: the start of the text or a shell operator, then any number of the wrappers +# agents actually compose (env, VAR=x, timeout N, nohup, sudo, xargs, exec, command, time, and the +# loop/conditional keywords do/then/else). A word that is not a wrapper (echo, printf, grep) means +# the text after it is an argument, never a command, so `echo git push` is not dispatch. +COMMAND_POSITION = ( + r"(?:^|[;&|(\n{])\s*" + r"(?:(?:do|then|else|exec|command|time|nohup|sudo|env|xargs|timeout\s+\S+|\w+=\S*)\s+)*" +) DISPATCH_COMMANDS = ( - r"(?:^\s*|[;&|(\n]\s*)(?:\S*/)?create-issue-worktree\.sh(?:\s|$)", - r"(?:^\s*|[;&|(\n]\s*)(?:\S*/)?worktree-commit\.sh(?:\s|$)", - r"(?:^\s*|[;&|(\n]\s*)(?:\S*/)?chain-advance\.sh(?:\s|$)", - r"(? Date: Wed, 23 Sep 2026 21:05:21 -0700 Subject: [PATCH 14/14] fix(activation): close the review's executed-text and probe-selector gaps CodeRabbit's review of PR #894 found four ways a pending receipt could still be bypassed or a real command mis-read, and two probe selectors that could pick the wrong call. Heredoc stripping now keeps the header line's own commands; shell -c bodies are unwrapped for bundled flags such as -lc; multi-token quoted strings become a placeholder token so `git -C '' push` keeps its subcommand; a workflow selected from the operator's words (no native skill injection) is told the exact skill path and to read it in full before dispatch. The ordering probe now takes the earliest tool attempt of any kind as one JSON value, so blocked attempts and multi-line commands are seen. Co-Authored-By: Claude Fable 5.1 --- .../scripts/lib/workflow-activation.py | 34 +++++++++++++------ bench/activation-ordering.sh | 7 ++-- tests/test-workflow-activation.sh | 21 ++++++++++++ 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/agentkit/skills/.shared/scripts/lib/workflow-activation.py b/agentkit/skills/.shared/scripts/lib/workflow-activation.py index 7f028c7e..00df478f 100644 --- a/agentkit/skills/.shared/scripts/lib/workflow-activation.py +++ b/agentkit/skills/.shared/scripts/lib/workflow-activation.py @@ -232,7 +232,10 @@ def ack_command(args, record): "--activation-nonce", record["nonce"]]) -def deliver(args, evidence, workflow, source, capabilities, recovery=False): +def deliver(args, evidence, workflow, source, capabilities, recovery=False, native=True): + """Build the pending record and the delivered context. `native` is False when the + workflow was selected from the operator's words rather than a `$`/`/` invocation: + no harness injected the skill body in that case, so the delivery must require it.""" skill = Path(args.skills) / workflow / "SKILL.md" if not skill.is_file() or skill.is_symlink(): fail("workflow-unavailable: " + workflow) @@ -248,9 +251,13 @@ def deliver(args, evidence, workflow, source, capabilities, recovery=False): "Read " + str(skill) + " in full now (reads are permitted while the receipt " "is pending), then run this exact receipt command and resume the assigned " "work in the same worktree:\n") - else: + elif native: lead = ("agentkit invocation boundary: explicit workflow delivery, not native registry evidence. " "Run this exact preflight command first; it records the session receipt:\n") + else: + lead = ("agentkit invocation boundary: this workflow was selected from your words, so no " + "skill body was loaded natively. Run this exact preflight command first; it records " + "the session receipt. Then read " + str(skill) + " in full before any dispatch:\n") context = (lead + ack_command(args, record) + "\n" + "agentkit: skill=" + workflow + " version=" + record["version"] + " hash=" + args.digest[:12] + "\n" @@ -331,19 +338,25 @@ def executed_text(command): A single-token quoted string (no internal whitespace) is unwrapped first, not stripped, because it is the kit's own documented form for an absolute helper path or invocation and must still match as executed text.""" - stripped = re.sub(r"<<-?\s*['\"]?(\w+)['\"]?[^\n]*\n.*?^\t*\1\s*$", " ", command, + # Drop only the heredoc BODY and its terminator; the rest of the header line is + # executed text (`cat < "$out/transcript.jsonl" 2> "$out/stderr.log" || true - first_call=$(jq -r 'select(.type=="item.completed" and .item.type=="command_execution") | .item.command' "$out/transcript.jsonl" | head -1) + # The earliest tool ATTEMPT of any kind (started before completed; a blocked + # command still starts), as one JSON value so a multi-line command survives. + first_call=$(jq -rn 'first(inputs | select(.type=="item.started" or .type=="item.completed") | .item | select(.type != "agent_message" and .type != "reasoning") | (.command // .arguments // .input // .) | if type=="string" then . else tojson end)' "$out/transcript.jsonl") if [[ -z $first_call ]]; then # The PreToolUse hook may block the command before it becomes a # command_execution item; codex still logs the attempted command. @@ -45,7 +47,8 @@ case $harness in # (tests/build-plugin.sh), so the probe measures the branch's hooks and never # touches the user's installed plugins. (cd -- "$repo" && claude -p --output-format stream-json --verbose --allowedTools='Bash(printf:*)' --plugin-dir "$plugin_dir" "$prompt") > "$out/transcript.jsonl" 2> "$out/stderr.log" || true - first_call=$(jq -r 'select(.type=="assistant") | .message.content[]? | select(.type=="tool_use" and .name=="Bash") | .input.command' "$out/transcript.jsonl" | head -1) + # The earliest tool_use of ANY tool, as one JSON value so a multi-line command survives. + first_call=$(jq -rn 'first(inputs | select(.type=="assistant") | .message.content[]? | select(.type=="tool_use") | (.input.command // (.input | tojson)))' "$out/transcript.jsonl") if [[ -z $first_call ]]; then # The PreToolUse hook may block the command before it becomes a # tool_use content block; fall back to the composed command as diff --git a/tests/test-workflow-activation.sh b/tests/test-workflow-activation.sh index 5d75a569..79e35c1a 100755 --- a/tests/test-workflow-activation.sh +++ b/tests/test-workflow-activation.sh @@ -88,6 +88,16 @@ out=$(hook PreToolUse Bash '{"command":"/usr/bin/git push origin fix/x"}') assert_contains "$out" 'pending session acknowledgement' 'pending: absolute-path git push is denied' out=$(hook PreToolUse Bash '{"command":"echo gh pr create --draft"}') assert_eq '{}' "$out" 'pending: echo of gh pr create words is allowed' +# CodeRabbit review of PR #894: gaps in executed-text classification. +out=$(hook PreToolUse Bash "{\"command\":\"git -C '/tmp/my repo' push origin fix/x\"}") +assert_contains "$out" 'pending session acknowledgement' 'pending: git -C with a quoted path containing spaces is still denied' +header_heredoc=$(jq -nc --arg c $'cat <