Files
coyote/scripts/usage-probe-hooks.sh
T
Dark-Alex-17 611900057c test(TASK-050): wait on every expected marker before asserting hook counts
Hook processes are fire-and-forget: the binary's exit drain acknowledges
spawns only, so the STARTED writer can land after the terminal one. The
macro-bracketing tests waited only for the terminal marker and then
asserted STARTED's count immediately, flaking on Windows CI and loaded
Linux runners. The bounded wait now covers every expected marker before
the exact-count and absence assertions run; the probe script's scenario K
comment is corrected to state the spawn-ack-only contract it already
waits around.
2026-09-17 00:33:37 -06:00

767 lines
35 KiB
Bash
Executable File

#!/usr/bin/env bash
# Black-box usage-pattern probe for the coyote hook system.
#
# Verifies, against a REAL built `coyote` binary in a clean/isolated
# COYOTE_CONFIG_DIR (dry_run:true, no network/LLM credentials required):
# A) exactly one turn.started + one turn.completed + one llm.request.completed
# fire per headless run, with correctly scoped env vars (no cross-event
# extras leakage).
# B) a failing/missing-script hook never changes coyote's stdout, stderr, or
# exit code.
# C) `coyote --install-builtins hooks` installs both bundled example scripts
# 0755 into <config_dir>/hooks via the real CLI; a non-interactive re-run
# keeps locally modified scripts while still installing missing ones.
# D) `coyote --agent <bundled-agent> --info` (an inspection flag) fires ZERO
# agent.* hook events.
# E) `coyote --install-builtins hooks` reconciles manifest-owned leftovers
# in roles/hooks and each bundled agent's hooks dir, sparing user files.
# F) top-level hooks-dir manifest reconcile: a stale shipped hook is removed
# on reinstall; a malformed manifest deletes NOTHING (fail-safe).
# G) a plain startup sweeps manifest-owned hooks of removed builtin agents
# while leaving user agent dirs and user files untouched.
# H) the INSTALLED bundled scripts behave as documented when run directly:
# log-events.sh defaults under ${XDG_STATE_HOME:-$HOME/.local/state}/coyote/
# (0600, UTC ISO-8601 header, secrets filtered, symlink at the default
# path refused) and notify.sh's no-notifier/no-tty fallback strips
# ANSI/OSC control bytes; the hooks-dir .builtin-manifest is never
# installed executable.
# I) `--session <name>` discriminates the session events end-to-end: a
# fresh named session fires exactly one session.started (and no
# session.resumed); resuming a persisted session fires exactly one
# session.resumed (and no session.started).
# J) pressing ctrl-c in a real terminal during a live headless --agent run
# fires exactly one agent.interrupted with NO COYOTE_AGENT_ERROR, and
# never agent.failed or agent.completed (needs python3 for the pty and
# the hanging LLM endpoint; skipped when python3 is unavailable).
#
# Not covered here (see src/function/mod.rs tests for tool.* + payload-file
# coverage instead): a live tool.started firing requires a real LLM round
# trip that actually decides to call a tool; dry_run's echo_messages() path
# never invokes tools, so it cannot be exercised via this recipe.
#
# Usage: scripts/usage-probe-hooks.sh
# Requires: a `coyote` binary on PATH or at ./target/debug/coyote or
# ./target/release/coyote (build with `cargo build --bin coyote` first).
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BIN="${COYOTE_BIN:-}"
if [ -z "$BIN" ]; then
if [ -x "$REPO_ROOT/target/debug/coyote" ]; then
BIN="$REPO_ROOT/target/debug/coyote"
elif [ -x "$REPO_ROOT/target/release/coyote" ]; then
BIN="$REPO_ROOT/target/release/coyote"
else
echo "No coyote binary found; run 'cargo build --bin coyote' first." >&2
exit 2
fi
fi
WORKDIR="$(mktemp -d)"
trap 'rm -rf "$WORKDIR"' EXIT
fail() {
echo "FAIL: $1" >&2
exit 1
}
# Bounded poll: retries the given command every 0.1s until it succeeds or the
# ~10s deadline expires, then fails loudly. Hooks are fire-and-forget child
# processes, so every wait on their side effects goes through this helper —
# never a bare sleep.
wait_for() {
local what="$1"
shift
local tries=0
until "$@"; do
tries=$((tries + 1))
[ "$tries" -ge 100 ] && fail "timed out waiting for $what"
sleep 0.1
done
}
write_dryrun_config() {
# $1 = config dir, $2..$n = extra YAML fragment lines appended verbatim
local dir="$1"
shift
cat > "$dir/config.yaml" <<'BASE'
model: dryrun:dry-model
dry_run: true
clients:
- type: openai
name: dryrun
auth: none
api_key: 'unused'
models:
- name: dry-model
max_input_tokens: 100000
supports_function_calling: true
save: false
memory: false
stream: false
BASE
if [ "$#" -gt 0 ]; then
printf '%s\n' "$@" >> "$dir/config.yaml"
fi
}
echo "== Scenario A: turn/llm hook cadence and env scoping =="
CFG_A="$WORKDIR/a"
mkdir -p "$CFG_A"
TS_LOG="$WORKDIR/turn-started.log"
TC_LOG="$WORKDIR/turn-completed.log"
LC_LOG="$WORKDIR/llm-completed.log"
write_dryrun_config "$CFG_A" "hooks:" \
" turn.started:" \
" - name: probe" \
" command: \"{ echo \\\"EVENT=\$COYOTE_EVENT NAME=\$COYOTE_HOOK_NAME\\\"; } >> $TS_LOG\"" \
" turn.completed:" \
" - name: probe" \
" command: \"{ echo \\\"EVENT=\$COYOTE_EVENT NAME=\$COYOTE_HOOK_NAME\\\"; } >> $TC_LOG\"" \
" llm.request.completed:" \
" - name: probe-llm" \
" command: \"{ echo \\\"EVENT=\$COYOTE_EVENT PROVIDER=\$COYOTE_LLM_PROVIDER MODEL=\$COYOTE_LLM_MODEL\\\"; } >> $LC_LOG\""
COYOTE_CONFIG_DIR="$CFG_A" "$BIN" --no-stream "say exactly: hi" > /dev/null
scenario_a_logs_ready() { [ -s "$TS_LOG" ] && [ -s "$TC_LOG" ] && [ -s "$LC_LOG" ]; }
wait_for "turn/llm hook logs" scenario_a_logs_ready
[ "$(grep -c '^EVENT=turn.started' "$TS_LOG" 2>/dev/null || echo 0)" = "1" ] \
|| fail "expected exactly one turn.started, got: $(cat "$TS_LOG" 2>/dev/null)"
[ "$(grep -c '^EVENT=turn.completed' "$TC_LOG" 2>/dev/null || echo 0)" = "1" ] \
|| fail "expected exactly one turn.completed, got: $(cat "$TC_LOG" 2>/dev/null)"
[ "$(grep -c '^EVENT=llm.request.completed' "$LC_LOG" 2>/dev/null || echo 0)" = "1" ] \
|| fail "expected exactly one llm.request.completed, got: $(cat "$LC_LOG" 2>/dev/null)"
grep -q 'PROVIDER=dryrun MODEL=dryrun:dry-model' "$LC_LOG" \
|| fail "llm.request.completed missing expected COYOTE_LLM_PROVIDER/MODEL: $(cat "$LC_LOG")"
echo "PASS: exactly 1 turn.started + 1 turn.completed + 1 llm.request.completed, correctly scoped"
echo "== Scenario B: failing/missing-script hooks never affect output or exit code =="
CFG_B="$WORKDIR/b"
mkdir -p "$CFG_B"
write_dryrun_config "$CFG_B" "hooks:" \
" turn.completed:" \
" - name: failing" \
" command: /nonexistent/script/does/not/exist" \
" - name: exit1" \
" command: \"exit 1\""
CFG_BASE="$WORKDIR/base"
mkdir -p "$CFG_BASE"
write_dryrun_config "$CFG_BASE"
rc_b=0
COYOTE_CONFIG_DIR="$CFG_B" "$BIN" --no-stream "say exactly: hi" > "$WORKDIR/out_b.txt" 2> "$WORKDIR/err_b.txt" || rc_b=$?
rc_base=0
COYOTE_CONFIG_DIR="$CFG_BASE" "$BIN" --no-stream "say exactly: hi" > "$WORKDIR/out_base.txt" 2> "$WORKDIR/err_base.txt" || rc_base=$?
[ "$rc_b" = "$rc_base" ] || fail "exit code differs: with-hooks=$rc_b baseline=$rc_base"
diff -q "$WORKDIR/out_b.txt" "$WORKDIR/out_base.txt" > /dev/null || fail "stdout differs when a hook fails/is missing"
diff -q "$WORKDIR/err_b.txt" "$WORKDIR/err_base.txt" > /dev/null || fail "stderr differs when a hook fails/is missing"
echo "PASS: failing/missing-script hooks are invisible to stdout/stderr/exit code"
echo "== Scenario C: coyote --install-builtins hooks (real CLI, clean config dir) =="
CFG_C="$WORKDIR/c"
mkdir -p "$CFG_C"
out_install1="$WORKDIR/install1.out"
COYOTE_CONFIG_DIR="$CFG_C" "$BIN" --install-builtins hooks > "$out_install1" 2>&1
[ -f "$CFG_C/hooks/notify.sh" ] || fail "notify.sh was not installed: $(cat "$out_install1")"
[ -f "$CFG_C/hooks/log-events.sh" ] || fail "log-events.sh was not installed: $(cat "$out_install1")"
grep -q "Reinstalled bundled hooks" "$out_install1" \
|| fail "expected confirmation message, got: $(cat "$out_install1")"
if command -v stat > /dev/null 2>&1; then
mode_notify="$(stat -c '%a' "$CFG_C/hooks/notify.sh" 2>/dev/null || stat -f '%Lp' "$CFG_C/hooks/notify.sh")"
mode_log="$(stat -c '%a' "$CFG_C/hooks/log-events.sh" 2>/dev/null || stat -f '%Lp' "$CFG_C/hooks/log-events.sh")"
[ "$mode_notify" = "755" ] || fail "notify.sh mode is $mode_notify, expected 755"
[ "$mode_log" = "755" ] || fail "log-events.sh mode is $mode_log, expected 755"
fi
# Modify one script locally and delete the other, then re-run
# --install-builtins hooks: without a terminal the conflict resolution keeps
# local files, so the edit must survive while the missing script is
# reinstalled from the packaged content.
echo "# LOCALLY MODIFIED" > "$CFG_C/hooks/notify.sh"
rm "$CFG_C/hooks/log-events.sh"
# The refresh also touches the roles and per-agent hook locations: user files
# living there must survive it untouched.
AGENT_C="$(ls "$CFG_C/agents" | head -1)"
mkdir -p "$CFG_C/roles/hooks" "$CFG_C/agents/$AGENT_C/hooks"
echo "mine-role" > "$CFG_C/roles/hooks/user-role.sh"
echo "mine-agent" > "$CFG_C/agents/$AGENT_C/hooks/user-agent.sh"
COYOTE_CONFIG_DIR="$CFG_C" "$BIN" --install-builtins hooks > "$WORKDIR/install2.out" 2>&1
grep -q "LOCALLY MODIFIED" "$CFG_C/hooks/notify.sh" \
|| fail "non-interactive reinstall overwrote the locally modified notify.sh"
[ -f "$CFG_C/hooks/log-events.sh" ] \
|| fail "reinstall did not restore the deleted log-events.sh"
grep -q '^#!/usr/bin/env bash' "$CFG_C/hooks/log-events.sh" \
|| fail "log-events.sh was not restored to the packaged script"
if command -v stat > /dev/null 2>&1; then
mode_restored="$(stat -c '%a' "$CFG_C/hooks/log-events.sh" 2>/dev/null || stat -f '%Lp' "$CFG_C/hooks/log-events.sh")"
[ "$mode_restored" = "755" ] || fail "restored log-events.sh mode is $mode_restored, expected 755"
fi
[ "$(cat "$CFG_C/roles/hooks/user-role.sh")" = "mine-role" ] \
|| fail "user file in roles/hooks was touched by the refresh"
[ "$(cat "$CFG_C/agents/$AGENT_C/hooks/user-agent.sh")" = "mine-agent" ] \
|| fail "user file in the agent hooks dir was touched by the refresh"
echo "PASS: --install-builtins hooks installs both scripts 0755; re-runs keep modified files (all hook locations) and restore missing ones 0755"
echo "== Scenario C2: cold-start auto-bootstrap fills the hooks dir but never forces =="
CFG_C2="$WORKDIR/c2"
mkdir -p "$CFG_C2/hooks"
write_dryrun_config "$CFG_C2"
# Every coyote invocation calls install_builtins() (force=false) before
# dispatching any flag, so a plain run on a config dir with no hooks/ yet
# must auto-populate it...
rmdir "$CFG_C2/hooks" 2>/dev/null || true
COYOTE_CONFIG_DIR="$CFG_C2" "$BIN" --no-stream "say exactly: hi" > /dev/null
[ -f "$CFG_C2/hooks/notify.sh" ] || fail "cold-start run did not auto-install notify.sh"
# ...but once a file exists, the non-force path must NEVER touch it again.
echo "# LOCALLY MODIFIED" > "$CFG_C2/hooks/notify.sh"
COYOTE_CONFIG_DIR="$CFG_C2" "$BIN" --no-stream "say exactly: hi" > /dev/null
grep -q "LOCALLY MODIFIED" "$CFG_C2/hooks/notify.sh" \
|| fail "a plain (non --install-builtins) invocation overwrote a locally modified hook script"
echo "PASS: plain invocations auto-bootstrap missing hooks but never force-overwrite existing ones"
echo "== Scenario C3: installed log-events.sh fires end-to-end via the documented relative path =="
# Exercises the exact wiring promised in the script headers:
# command: ./hooks/log-events.sh (resolves against the config dir)
# and the script's own contract: COYOTE_HOOK_LOG override honored, log file
# created 0600, COYOTE_SECRET_* values excluded from the snapshot.
CFG_C3="$WORKDIR/c3"
mkdir -p "$CFG_C3"
HOOK_LOG="$WORKDIR/c3-hook.log"
write_dryrun_config "$CFG_C3" "hooks:" \
" turn.completed:" \
" - name: log-events" \
" command: ./hooks/log-events.sh"
COYOTE_CONFIG_DIR="$CFG_C3" "$BIN" --install-builtins hooks > /dev/null 2>&1
COYOTE_CONFIG_DIR="$CFG_C3" COYOTE_HOOK_LOG="$HOOK_LOG" COYOTE_SECRET_PROBE=hunter2 \
"$BIN" --no-stream "say exactly: hi" > /dev/null
wait_for "installed log-events.sh output" test -s "$HOOK_LOG"
[ -f "$HOOK_LOG" ] || fail "installed log-events.sh did not write to COYOTE_HOOK_LOG via relative-path wiring"
[ "$(grep -c '^=== ' "$HOOK_LOG")" = "1" ] \
|| fail "expected exactly one event block in the hook log, got: $(cat "$HOOK_LOG")"
grep -q 'COYOTE_EVENT=turn.completed' "$HOOK_LOG" \
|| fail "hook log missing COYOTE_EVENT=turn.completed: $(cat "$HOOK_LOG")"
grep -q 'hunter2' "$HOOK_LOG" \
&& fail "COYOTE_SECRET_* value leaked into the hook log"
grep -q 'COYOTE_SECRET_' "$HOOK_LOG" \
&& fail "COYOTE_SECRET_ variable name leaked into the hook log"
if command -v stat > /dev/null 2>&1; then
mode_hook_log="$(stat -c '%a' "$HOOK_LOG" 2>/dev/null || stat -f '%Lp' "$HOOK_LOG")"
[ "$mode_hook_log" = "600" ] || fail "hook log mode is $mode_hook_log, expected 600"
fi
echo "PASS: relative-path wiring fires the installed log-events.sh; log is 0600 with secrets excluded"
echo "== Scenario D: --agent <bundled> --info fires zero agent.* events =="
CFG_D="$WORKDIR/d"
mkdir -p "$CFG_D/agents"
AGENT_LOG="$WORKDIR/agent-events.log"
SENTINEL_LOG="$WORKDIR/d-sentinel.log"
write_dryrun_config "$CFG_D" "hooks:" \
" agent.started:" \
" - name: probe-agent" \
" command: \"echo AGENT_STARTED >> $AGENT_LOG\"" \
" agent.completed:" \
" - name: probe-agent" \
" command: \"echo AGENT_DONE >> $AGENT_LOG\"" \
" agent.failed:" \
" - name: probe-agent" \
" command: \"echo AGENT_FAILED >> $AGENT_LOG\"" \
" turn.completed:" \
" - name: sentinel" \
" command: \"echo SENTINEL >> $SENTINEL_LOG\""
AGENT_NAME="$(ls "$REPO_ROOT/assets/agents" | head -1)"
cp -r "$REPO_ROOT/assets/agents/$AGENT_NAME" "$CFG_D/agents/$AGENT_NAME"
COYOTE_CONFIG_DIR="$CFG_D" "$BIN" --agent "$AGENT_NAME" --info > /dev/null 2>&1
# Proving absence needs a positive signal to wait on: a follow-up plain run
# fires turn.completed, and its sentinel landing bounds how long the --info
# run's (nonexistent) agent.* children could have taken to write.
COYOTE_CONFIG_DIR="$CFG_D" "$BIN" --no-stream "say exactly: hi" > /dev/null
wait_for "scenario D sentinel" test -s "$SENTINEL_LOG"
[ -s "$AGENT_LOG" ] && fail "expected zero agent.* events from --agent --info, got: $(cat "$AGENT_LOG")"
echo "PASS: --agent $AGENT_NAME --info fired zero agent.* hook events"
echo "== Scenario E: --install-builtins hooks reconciles roles/hooks and agent hooks dirs =="
CFG_E="$WORKDIR/e"
AGENT_E="$(ls "$REPO_ROOT/assets/agents" | head -1)"
mkdir -p "$CFG_E/roles/hooks" "$CFG_E/agents/$AGENT_E/hooks"
# Manifest-owned leftovers a previous release shipped, plus user files that
# must survive the refresh in both locations.
printf 'stale-role.sh\n' > "$CFG_E/roles/hooks/.builtin-manifest"
echo "stale" > "$CFG_E/roles/hooks/stale-role.sh"
echo "mine" > "$CFG_E/roles/hooks/user-role.sh"
printf 'stale-agent.sh\n' > "$CFG_E/agents/$AGENT_E/hooks/.builtin-manifest"
echo "stale" > "$CFG_E/agents/$AGENT_E/hooks/stale-agent.sh"
echo "mine" > "$CFG_E/agents/$AGENT_E/hooks/user-agent.sh"
COYOTE_CONFIG_DIR="$CFG_E" "$BIN" --install-builtins hooks > "$WORKDIR/install_e.out" 2>&1 \
|| fail "--install-builtins hooks failed: $(cat "$WORKDIR/install_e.out")"
[ -f "$CFG_E/hooks/notify.sh" ] || fail "global hooks were not installed"
[ ! -f "$CFG_E/roles/hooks/stale-role.sh" ] \
|| fail "manifest-owned stale role hook survived the refresh"
[ "$(cat "$CFG_E/roles/hooks/user-role.sh")" = "mine" ] \
|| fail "user file in roles/hooks was touched by the refresh"
[ ! -f "$CFG_E/agents/$AGENT_E/hooks/stale-agent.sh" ] \
|| fail "manifest-owned stale agent hook survived the refresh"
[ "$(cat "$CFG_E/agents/$AGENT_E/hooks/user-agent.sh")" = "mine" ] \
|| fail "user file in the agent hooks dir was touched by the refresh"
echo "PASS: refresh reconciles roles/hooks and bundled-agent hooks, sparing user files"
echo "== Scenario F: top-level manifest reconcile removes stale hooks; malformed manifest deletes nothing =="
CFG_F="$WORKDIR/f"
mkdir -p "$CFG_F"
COYOTE_CONFIG_DIR="$CFG_F" "$BIN" --install-builtins hooks > /dev/null 2>&1
[ -f "$CFG_F/hooks/.builtin-manifest" ] \
|| fail "install did not write a .builtin-manifest to the top-level hooks dir"
# Simulate an upgrade from a release that also shipped old-hook.sh: the
# manifest records it, so reinstall must remove it — while a user script
# absent from the manifest survives.
printf 'log-events.sh\nnotify.sh\nold-hook.sh\n' > "$CFG_F/hooks/.builtin-manifest"
echo "stale" > "$CFG_F/hooks/old-hook.sh"
echo "mine" > "$CFG_F/hooks/user-hook.sh"
COYOTE_CONFIG_DIR="$CFG_F" "$BIN" --install-builtins hooks > /dev/null 2>&1
[ ! -f "$CFG_F/hooks/old-hook.sh" ] \
|| fail "stale shipped hook recorded in the manifest was not removed on reinstall"
[ "$(cat "$CFG_F/hooks/user-hook.sh")" = "mine" ] \
|| fail "user hook script was deleted or modified by manifest reconcile"
grep -q 'old-hook.sh' "$CFG_F/hooks/.builtin-manifest" \
&& fail "manifest still lists the removed stale hook"
# Fail-safe: a malformed (non-UTF8) manifest must delete NOTHING and must
# not fail the command.
printf '\xff\xfe\x00' > "$CFG_F/hooks/.builtin-manifest"
echo "keep" > "$CFG_F/hooks/orphan.sh"
rc_f=0
COYOTE_CONFIG_DIR="$CFG_F" "$BIN" --install-builtins hooks > "$WORKDIR/install_f.out" 2>&1 || rc_f=$?
[ "$rc_f" = "0" ] \
|| fail "reinstall failed on a malformed manifest (exit $rc_f): $(cat "$WORKDIR/install_f.out")"
[ -f "$CFG_F/hooks/orphan.sh" ] \
|| fail "a malformed manifest caused a deletion (fail-safe broken)"
[ -f "$CFG_F/hooks/notify.sh" ] && [ -f "$CFG_F/hooks/log-events.sh" ] \
|| fail "shipped hooks missing after malformed-manifest reinstall"
echo "PASS: stale manifest-owned hook removed; user files survive; malformed manifest deletes nothing"
echo "== Scenario G: plain startup sweeps removed-builtin-agent hooks, spares user agents =="
CFG_G="$WORKDIR/g"
mkdir -p "$CFG_G"
write_dryrun_config "$CFG_G"
# ghost: a removed builtin agent (has a manifest, absent from the embed)
# with a user file beside the manifest-owned hook -> hooks dir survives
# with only the user file.
mkdir -p "$CFG_G/agents/ghost/hooks"
printf 'shipped.sh\n' > "$CFG_G/agents/ghost/hooks/.builtin-manifest"
echo "stale" > "$CFG_G/agents/ghost/hooks/shipped.sh"
echo "mine" > "$CFG_G/agents/ghost/hooks/user.sh"
# ghost2: same, but nothing user-owned -> the emptied hooks dir is removed.
mkdir -p "$CFG_G/agents/ghost2/hooks"
printf 'shipped.sh\n' > "$CFG_G/agents/ghost2/hooks/.builtin-manifest"
echo "stale" > "$CFG_G/agents/ghost2/hooks/shipped.sh"
# mine: a user agent with no manifest -> never touched.
mkdir -p "$CFG_G/agents/mine/hooks"
echo "mine" > "$CFG_G/agents/mine/hooks/mine.sh"
COYOTE_CONFIG_DIR="$CFG_G" "$BIN" --no-stream "say exactly: hi" > /dev/null
[ ! -f "$CFG_G/agents/ghost/hooks/shipped.sh" ] \
|| fail "removed-agent sweep left the manifest-owned hook behind"
[ ! -f "$CFG_G/agents/ghost/hooks/.builtin-manifest" ] \
|| fail "removed-agent sweep left the manifest behind"
[ "$(cat "$CFG_G/agents/ghost/hooks/user.sh")" = "mine" ] \
|| fail "removed-agent sweep deleted a user file"
[ ! -d "$CFG_G/agents/ghost2/hooks" ] \
|| fail "emptied hooks dir of a removed agent was not removed"
[ -d "$CFG_G/agents/ghost2" ] \
|| fail "sweep removed more than the hooks dir of a removed agent"
[ "$(cat "$CFG_G/agents/mine/hooks/mine.sh")" = "mine" ] \
|| fail "sweep touched a user agent dir without a manifest"
echo "PASS: startup sweep removes only manifest-owned hooks of removed agents"
echo "== Scenario H: bundled hook scripts behave as documented when run directly =="
# The two shipped scripts are consumer-facing artifacts in their own right.
# Exercise the INSTALLED copies (real CLI install) the way a hook runner or a
# manual invocation would: clean env, no COYOTE_HOOK_LOG, no notifier
# binaries, no controlling terminal.
CFG_H="$WORKDIR/h"
mkdir -p "$CFG_H"
COYOTE_CONFIG_DIR="$CFG_H" "$BIN" --install-builtins hooks > /dev/null 2>&1
LOG_SCRIPT="$CFG_H/hooks/log-events.sh"
NOTIFY_SCRIPT="$CFG_H/hooks/notify.sh"
[ -x "$LOG_SCRIPT" ] || fail "installed log-events.sh missing or non-executable"
[ -x "$NOTIFY_SCRIPT" ] || fail "installed notify.sh missing or non-executable"
# The manifest the installer drops beside the scripts is NOT a script: the
# unified exec-bit predicate must leave it non-executable.
if [ -f "$CFG_H/hooks/.builtin-manifest" ]; then
[ ! -x "$CFG_H/hooks/.builtin-manifest" ] \
|| fail ".builtin-manifest in the hooks dir is executable; non-script files must stay non-executable"
fi
# H1: with COYOTE_HOOK_LOG unset the log defaults under XDG_STATE_HOME, is
# created 0600, carries a UTC ISO-8601 header, and never logs COYOTE_SECRET_*.
XDG_H="$WORKDIR/h-xdg"
env -u COYOTE_HOOK_LOG XDG_STATE_HOME="$XDG_H" HOME="$WORKDIR/h-home-unused" \
COYOTE_EVENT=probe.event COYOTE_HOOK_NAME=probe COYOTE_SECRET_TOKEN=hunter2 \
bash "$LOG_SCRIPT" || fail "log-events.sh exited non-zero on the XDG default path"
HLOG="$XDG_H/coyote/hooks.log"
[ -f "$HLOG" ] || fail "log-events.sh did not default to \$XDG_STATE_HOME/coyote/hooks.log"
if command -v stat > /dev/null 2>&1; then
mode_hlog="$(stat -c '%a' "$HLOG" 2>/dev/null || stat -f '%Lp' "$HLOG")"
[ "$mode_hlog" = "600" ] || fail "default hook log mode is $mode_hlog, expected 600"
fi
grep -Eq '^=== [0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z probe\.event$' "$HLOG" \
|| fail "log header is not a UTC ISO-8601 timestamp: $(head -1 "$HLOG")"
grep -q '^COYOTE_EVENT=probe.event$' "$HLOG" \
|| fail "log snapshot is missing COYOTE_EVENT"
! grep -q 'hunter2' "$HLOG" \
|| fail "COYOTE_SECRET_* value leaked into the hook log"
# H2: with XDG_STATE_HOME also unset, the log falls back to
# $HOME/.local/state/coyote/hooks.log.
HOME_H="$WORKDIR/h-home"
mkdir -p "$HOME_H"
env -u COYOTE_HOOK_LOG -u XDG_STATE_HOME HOME="$HOME_H" COYOTE_EVENT=probe.event \
bash "$LOG_SCRIPT" || fail "log-events.sh exited non-zero on the HOME default path"
[ -f "$HOME_H/.local/state/coyote/hooks.log" ] \
|| fail "log-events.sh did not fall back to ~/.local/state/coyote/hooks.log"
# H3: a symlink planted at the DEFAULT log path is refused — exit 0, target
# untouched (the hardening must hold on the new default location too).
HOME_S="$WORKDIR/h-sym"
mkdir -p "$HOME_S/.local/state/coyote"
VICTIM_H="$WORKDIR/h-victim.txt"
echo "untouched" > "$VICTIM_H"
ln -s "$VICTIM_H" "$HOME_S/.local/state/coyote/hooks.log"
env -u COYOTE_HOOK_LOG -u XDG_STATE_HOME HOME="$HOME_S" COYOTE_EVENT=probe.event \
bash "$LOG_SCRIPT" || fail "log-events.sh exited non-zero when refusing a symlink"
[ "$(cat "$VICTIM_H")" = "untouched" ] \
|| fail "log-events.sh wrote through a symlink planted at the default log path"
# H4: notify.sh with no notifier on PATH and no controlling terminal falls
# back to stdout with control bytes (ESC/BEL, i.e. ANSI + OSC) stripped from
# model-influenced values.
BIN_H="$WORKDIR/h-bin"
mkdir -p "$BIN_H"
ln -s "$(command -v tr)" "$BIN_H/tr"
NOTIFY_OUT="$WORKDIR/h-notify.out"
rc_h=0
setsid -w env -i PATH="$BIN_H" \
COYOTE_EVENT=turn.completed \
COYOTE_TOOL_NAME="$(printf 'evil\033]0;own\007\033[31mred')" \
"$(command -v bash)" "$NOTIFY_SCRIPT" > "$NOTIFY_OUT" 2> /dev/null || rc_h=$?
[ "$rc_h" = "0" ] || fail "notify.sh fallback exited $rc_h"
grep -Fq '[coyote] turn.completed tool=evil]0;own[31mred' "$NOTIFY_OUT" \
|| fail "notify.sh fallback output missing/unsanitized: $(cat "$NOTIFY_OUT")"
! LC_ALL=C grep -q "$(printf '\033')" "$NOTIFY_OUT" \
|| fail "ANSI/OSC escape bytes leaked through the notify.sh tty fallback"
echo "PASS: installed hook scripts honor XDG default, 0600, UTC timestamps, symlink refusal, secret filter, and ANSI-stripped notify fallback"
echo "== Scenario I: session.started vs session.resumed via --session =="
CFG_I="$WORKDIR/i"
mkdir -p "$CFG_I"
SESS_LOG="$WORKDIR/i-sess.log"
write_dryrun_config "$CFG_I" "hooks:" \
" session.started:" \
" - name: probe-sess" \
" command: \"echo STARTED >> $SESS_LOG\"" \
" session.resumed:" \
" - name: probe-sess" \
" command: \"echo RESUMED >> $SESS_LOG\""
# A fresh named session (no file on disk yet) fires session.started only.
COYOTE_CONFIG_DIR="$CFG_I" "$BIN" --session probe-fresh --no-stream "say exactly: hi" > /dev/null
wait_for "session.started log" test -s "$SESS_LOG"
[ "$(grep -c '^STARTED$' "$SESS_LOG" 2>/dev/null || echo 0)" = "1" ] \
|| fail "expected exactly one session.started for a fresh session, got: $(cat "$SESS_LOG")"
grep -q '^RESUMED$' "$SESS_LOG" \
&& fail "a fresh named session must not fire session.resumed: $(cat "$SESS_LOG")"
# A persisted session on disk: resuming it fires session.resumed, and never
# session.started (the inverse gate).
mkdir -p "$CFG_I/sessions"
printf 'model: dryrun:dry-model\nmessages: []\n' > "$CFG_I/sessions/probe-resume.yaml"
COYOTE_CONFIG_DIR="$CFG_I" "$BIN" --session probe-resume --no-stream "say exactly: hi" > /dev/null
scenario_i_resumed() { grep -q '^RESUMED$' "$SESS_LOG"; }
wait_for "session.resumed log" scenario_i_resumed
[ "$(grep -c '^RESUMED$' "$SESS_LOG" 2>/dev/null || echo 0)" = "1" ] \
|| fail "expected exactly one session.resumed for a resumed session, got: $(cat "$SESS_LOG")"
[ "$(grep -c '^STARTED$' "$SESS_LOG" 2>/dev/null || echo 0)" = "1" ] \
|| fail "resuming a session must not fire session.started: $(cat "$SESS_LOG")"
echo "PASS: fresh session fires session.started only; resume fires session.resumed only"
echo "== Scenario J: terminal ctrl-c during a live headless --agent run fires agent.interrupted =="
if ! command -v python3 > /dev/null 2>&1; then
echo "SKIP: python3 not available; cannot allocate a pty or hanging LLM endpoint" >&2
else
CFG_J="$WORKDIR/j"
mkdir -p "$CFG_J/agents/probe-int"
INT_LOG="$WORKDIR/j-int.log"
# A local "LLM endpoint" that accepts connections and never answers keeps
# the agent run in flight (spinner active, its ctrl-c watcher polling)
# while the probe delivers ^C. It writes its port to $1 once listening and
# touches $2 on the first accepted connection, so the runner below can
# gate the ^C on the request being demonstrably in flight.
python3 - "$WORKDIR/j-port" "$WORKDIR/j-conn" <<'PY' &
import socket, sys
s = socket.socket()
s.bind(("127.0.0.1", 0))
s.listen(8)
open(sys.argv[1], "w").write(str(s.getsockname()[1]))
conns = []
while True:
c, _ = s.accept()
if not conns:
open(sys.argv[2], "w").write("1")
conns.append(c)
PY
SRV_J=$!
trap 'kill "$SRV_J" 2>/dev/null || true; rm -rf "$WORKDIR"' EXIT
wait_for "hang-server port" test -s "$WORKDIR/j-port"
PORT_J="$(cat "$WORKDIR/j-port")"
cat > "$CFG_J/config.yaml" <<EOF
model: hangc:hang-model
clients:
- type: openai
name: hangc
auth: none
api_key: 'unused'
api_base: http://127.0.0.1:$PORT_J/v1
models:
- name: hang-model
max_input_tokens: 100000
supports_function_calling: true
save: false
memory: false
stream: false
EOF
# Agent-level hooks resolve without any global_hooks whitelisting, so the
# probe agent carries its own markers for every terminal agent event.
cat > "$CFG_J/agents/probe-int/config.yaml" <<EOF
name: probe-int
description: ctrl-c interruption probe agent
version: 0.1.0
instructions: |
You are a probe agent. Reply briefly.
hooks:
agent.started:
- name: mark
command: "echo STARTED >> $INT_LOG"
agent.completed:
- name: mark
command: "echo COMPLETED >> $INT_LOG"
agent.failed:
- name: mark
command: "echo \"FAILED err=\${COYOTE_AGENT_ERROR:-unset}\" >> $INT_LOG"
agent.interrupted:
- name: mark
command: "echo \"INTERRUPTED err=\${COYOTE_AGENT_ERROR:-unset}\" >> $INT_LOG"
EOF
# Spawn coyote on a real pty (the spinner's ctrl-c watcher only runs on a
# terminal), wait until the agent.started marker AND the first accepted
# LLM connection prove the run is in flight, then send a literal ^C
# through the pty line discipline — exactly what a terminal user does.
cat > "$WORKDIR/j-pty.py" <<'PY'
import os, pty, sys, time
int_log, conn_marker = sys.argv[1], sys.argv[2]
argv = sys.argv[3:]
pid, master = pty.fork()
if pid == 0:
os.execvp(argv[0], argv)
os.set_blocking(master, False)
def drain():
try:
while os.read(master, 4096):
pass
except OSError:
pass
def marker_seen():
try:
with open(int_log) as f:
return "STARTED" in f.read()
except FileNotFoundError:
return False
deadline = time.time() + 15
while time.time() < deadline:
drain()
if marker_seen() and os.path.exists(conn_marker):
break
time.sleep(0.1)
else:
print("TIMEOUT: agent.started marker / LLM connection never appeared", flush=True)
os.kill(pid, 9)
os.waitpid(pid, 0)
sys.exit(3)
os.write(master, b"\x03") # ^C via the pty line discipline
deadline = time.time() + 15
while time.time() < deadline:
drain()
got, status = os.waitpid(pid, os.WNOHANG)
if got == pid:
if os.WIFSIGNALED(status):
print(f"FATAL: coyote died on signal {os.WTERMSIG(status)} — no ctrl-c handler was installed", flush=True)
sys.exit(4)
sys.exit(0)
time.sleep(0.1)
print("TIMEOUT: coyote did not exit within 15s of ^C", flush=True)
os.kill(pid, 9)
os.waitpid(pid, 0)
sys.exit(3)
PY
COYOTE_CONFIG_DIR="$CFG_J" python3 "$WORKDIR/j-pty.py" "$INT_LOG" "$WORKDIR/j-conn" \
"$BIN" --agent probe-int --no-stream "say exactly: hi" \
|| fail "pty ctrl-c run did not exit cleanly (see message above)"
scenario_j_interrupted() { grep -qs '^INTERRUPTED err=unset$' "$INT_LOG"; }
wait_for "agent.interrupted log" scenario_j_interrupted
[ "$(grep -c '^STARTED$' "$INT_LOG" 2>/dev/null || echo 0)" = "1" ] \
|| fail "expected exactly one agent.started, got: $(cat "$INT_LOG")"
[ "$(grep -c '^INTERRUPTED' "$INT_LOG" 2>/dev/null || echo 0)" = "1" ] \
|| fail "expected exactly one agent.interrupted, got: $(cat "$INT_LOG")"
grep -q '^FAILED' "$INT_LOG" \
&& fail "ctrl-c must fire agent.interrupted, never agent.failed: $(cat "$INT_LOG")"
grep -q '^COMPLETED' "$INT_LOG" \
&& fail "an interrupted run must not fire agent.completed: $(cat "$INT_LOG")"
kill "$SRV_J" 2>/dev/null || true
echo "PASS: terminal ctrl-c fires exactly one agent.interrupted with no COYOTE_AGENT_ERROR; never failed/completed"
fi
echo "== Scenario K: global_hooks wildcard grammar (exact, <event>.*, *.<name>, *.*, invalid globs) =="
# Global hooks: agent.started has one hook named 'mark'; agent.completed has
# two hooks named 'mark' and 'other'. Each sub-case runs the same
# `--agent wc-probe --macro probe` recipe in its own config dir with a
# different global_hooks whitelist, then asserts exactly which markers were
# admitted. The binary's exit drain only acknowledges hook SPAWNS — the hook
# processes finish on their own after exit — so every expected marker gets
# its own wait_for before any absence check reads the log.
k_run() {
# $1 = label, $2 = marker log, $3.. = global_hooks entries (verbatim)
local label="$1" klog="$2"
shift 2
local kcfg="$WORKDIR/k-$label"
mkdir -p "$kcfg/agents/wc-probe" "$kcfg/macros"
write_dryrun_config "$kcfg" "hooks:" \
" agent.started:" \
" - name: mark" \
" command: \"echo S-mark >> $klog\"" \
" agent.completed:" \
" - name: mark" \
" command: \"echo C-mark >> $klog\"" \
" - name: other" \
" command: \"echo C-other >> $klog\""
{
printf 'name: wc-probe\n'
printf 'description: wildcard whitelist probe agent\n'
printf 'version: 0.1.0\n'
printf 'instructions: |\n'
printf ' You are a probe agent. Reply briefly.\n'
printf 'global_hooks:\n'
local entry
for entry in "$@"; do
printf ' - "%s"\n' "$entry"
done
} > "$kcfg/agents/wc-probe/config.yaml"
printf 'steps:\n - ".set temperature 0.5"\n' > "$kcfg/macros/probe.yaml"
COYOTE_CONFIG_DIR="$kcfg" "$BIN" --agent wc-probe --macro probe > /dev/null 2>&1 \
|| fail "scenario K ($label): --agent wc-probe --macro probe exited nonzero"
}
marker_count_is() { # $1 = log, $2 = marker, $3 = expected count
local n
n="$(grep -c -x -- "$2" "$1" 2>/dev/null)" || n=0
[ "${n:-0}" = "$3" ]
}
# K1: exact <event>.<name> admits only that hook on that event (unchanged).
KLOG1="$WORKDIR/k1.log"
k_run exact "$KLOG1" "agent.completed.mark"
wait_for "K1 C-mark" marker_count_is "$KLOG1" C-mark 1
marker_count_is "$KLOG1" S-mark 0 || fail "K1: exact entry leaked agent.started: $(cat "$KLOG1")"
marker_count_is "$KLOG1" C-other 0 || fail "K1: exact entry leaked sibling hook 'other': $(cat "$KLOG1")"
# K2: <event>.* admits every hook of that event, nothing else.
KLOG2="$WORKDIR/k2.log"
k_run event-star "$KLOG2" "agent.completed.*"
wait_for "K2 C-mark" marker_count_is "$KLOG2" C-mark 1
wait_for "K2 C-other" marker_count_is "$KLOG2" C-other 1
marker_count_is "$KLOG2" S-mark 0 || fail "K2: agent.completed.* leaked agent.started: $(cat "$KLOG2")"
# K3: *.<name> admits that hook name on every event, nothing else.
KLOG3="$WORKDIR/k3.log"
k_run star-name "$KLOG3" "*.mark"
wait_for "K3 S-mark" marker_count_is "$KLOG3" S-mark 1
wait_for "K3 C-mark" marker_count_is "$KLOG3" C-mark 1
marker_count_is "$KLOG3" C-other 0 || fail "K3: *.mark leaked hook 'other': $(cat "$KLOG3")"
# K4: *.* admits everything (the bare * form is pinned by tests/macro_bracketing.rs).
KLOG4="$WORKDIR/k4.log"
k_run star-star "$KLOG4" "*.*"
wait_for "K4 S-mark" marker_count_is "$KLOG4" S-mark 1
wait_for "K4 C-mark" marker_count_is "$KLOG4" C-mark 1
wait_for "K4 C-other" marker_count_is "$KLOG4" C-other 1
# K5: partial/inner globs are NOT wildcards — they admit nothing, and the run
# is otherwise unaffected (exit code checked inside k_run). The exact
# agent.started.mark entry is the positive signal bounding the absence checks.
KLOG5="$WORKDIR/k5.log"
k_run invalid-globs "$KLOG5" "agent.completed.ma*" "agent.*" "ag*.completed.mark" "agent.started.mark"
wait_for "K5 S-mark" marker_count_is "$KLOG5" S-mark 1
marker_count_is "$KLOG5" C-mark 0 || fail "K5: a partial glob admitted agent.completed.mark: $(cat "$KLOG5")"
marker_count_is "$KLOG5" C-other 0 || fail "K5: a partial glob admitted agent.completed.other: $(cat "$KLOG5")"
echo "PASS: wildcard whitelist grammar admits exactly the spec'd sets; partial globs stay inert"
echo "== Scenario L: --macro without --agent fires zero agent.* events =="
CFG_L="$WORKDIR/l"
mkdir -p "$CFG_L/macros"
L_AGENT_LOG="$WORKDIR/l-agent-events.log"
L_SENTINEL_LOG="$WORKDIR/l-sentinel.log"
write_dryrun_config "$CFG_L" "hooks:" \
" agent.started:" \
" - name: mark" \
" command: \"echo STARTED >> $L_AGENT_LOG\"" \
" agent.completed:" \
" - name: mark" \
" command: \"echo COMPLETED >> $L_AGENT_LOG\"" \
" agent.failed:" \
" - name: mark" \
" command: \"echo FAILED >> $L_AGENT_LOG\"" \
" turn.completed:" \
" - name: sentinel" \
" command: \"echo SENTINEL >> $L_SENTINEL_LOG\""
printf 'steps:\n - ".set temperature 0.5"\n' > "$CFG_L/macros/probe.yaml"
COYOTE_CONFIG_DIR="$CFG_L" "$BIN" --macro probe > /dev/null 2>&1 \
|| fail "scenario L: --macro without --agent exited nonzero"
# Proving absence needs a positive signal to wait on: a follow-up plain run
# fires turn.completed, and its sentinel landing bounds how long the macro
# run's (nonexistent) agent.* children could have taken to write.
COYOTE_CONFIG_DIR="$CFG_L" "$BIN" --no-stream "say exactly: hi" > /dev/null
wait_for "scenario L sentinel" test -s "$L_SENTINEL_LOG"
[ -s "$L_AGENT_LOG" ] && fail "expected zero agent.* events from --macro without --agent, got: $(cat "$L_AGENT_LOG")"
echo "PASS: --macro without --agent fired zero agent.* hook events and exited cleanly"
echo
echo "ALL SCENARIOS PASSED"