feat(TASK-048): install polish, hooks-dir mixin scan, script and probe hardening

- one shared exec-bit predicate (Language-recognized extensions => 0755) used by
  every hook/agent installer; non-script files in hooks dirs stay non-executable
- mixin discovery scans the global hooks dir and each agent's hooks dir;
  .builtin-manifest is never scanned
- NTFS 8.3 short-name aliases rejected as builtin-manifest entries
- hook-script install announcements restricted to real hook locations
- COYOTE_HOOKS_DIR override warning compares canonicalized paths
- embedded assets decode via checked from_utf8 instead of from_utf8_unchecked
- log-events.sh defaults under ${XDG_STATE_HOME:-$HOME/.local/state}/coyote/hooks.log
  keeping 0600/symlink-refusal/secret-filter hardening; notify.sh tty fallback strips
  control bytes; both use UTC ISO-8601 timestamps
- payload files orphaned by a write failure are unlinked; stale coyote-hook-*.json
  payloads swept best-effort at startup
- sweep_removed_agent_hooks never follows symlinked agent dirs
- usage-probe-hooks.sh replaces bare sleeps with a bounded poll, re-verifies 0755 on
  restored scripts, and covers roles/agent hook locations
- TestConfigDirGuard hoisted into crate::testing
- .gitattributes pins LF line endings for bundled scripts

hooks is now a reserved bundle name.
This commit is contained in:
Dark-Alex-17 committed 2026-09-16 18:30:02 -06:00
1 parent e7bd75947c
commit 89a9398c28
16 files changed
+627 -353

No files matched your search

+52 -14
View File
@@ -52,6 +52,21 @@ fail() {
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"
@@ -95,7 +110,8 @@ write_dryrun_config "$CFG_A" "hooks:" \
" 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
sleep 1
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)"
@@ -120,12 +136,12 @@ CFG_BASE="$WORKDIR/base"
mkdir -p "$CFG_BASE"
write_dryrun_config "$CFG_BASE"
exit_b=0
COYOTE_CONFIG_DIR="$CFG_B" "$BIN" --no-stream "say exactly: hi" > "$WORKDIR/out_b.txt" 2> "$WORKDIR/err_b.txt" || exit_b=$?
exit_base=0
COYOTE_CONFIG_DIR="$CFG_BASE" "$BIN" --no-stream "say exactly: hi" > "$WORKDIR/out_base.txt" 2> "$WORKDIR/err_base.txt" || exit_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=$?
[ "$exit_b" = "$exit_base" ] || fail "exit code differs: with-hooks=$exit_b baseline=$exit_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"
@@ -155,6 +171,12 @@ fi
# 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"
@@ -162,7 +184,15 @@ grep -q "LOCALLY MODIFIED" "$CFG_C/hooks/notify.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"
echo "PASS: --install-builtins hooks installs both scripts 0755; re-runs keep modified files and restore missing ones"
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"
@@ -198,7 +228,7 @@ write_dryrun_config "$CFG_C3" "hooks:" \
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
sleep 1
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" ] \
@@ -219,6 +249,7 @@ 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" \
@@ -228,13 +259,20 @@ write_dryrun_config "$CFG_D" "hooks:" \
" command: \"echo AGENT_DONE >> $AGENT_LOG\"" \
" agent.failed:" \
" - name: probe-agent" \
" command: \"echo AGENT_FAILED >> $AGENT_LOG\""
" 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
sleep 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"
@@ -290,10 +328,10 @@ grep -q 'old-hook.sh' "$CFG_F/hooks/.builtin-manifest" \
# not fail the command.
printf '\xff\xfe\x00' > "$CFG_F/hooks/.builtin-manifest"
echo "keep" > "$CFG_F/hooks/orphan.sh"
exit_f=0
COYOTE_CONFIG_DIR="$CFG_F" "$BIN" --install-builtins hooks > "$WORKDIR/install_f.out" 2>&1 || exit_f=$?
[ "$exit_f" = "0" ] \
|| fail "reinstall failed on a malformed manifest (exit $exit_f): $(cat "$WORKDIR/install_f.out")"
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" ] \