diff --git a/README.md b/README.md index 6c7d462..5552b14 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,12 @@ available, because nested Codex launches can inherit an outer process key, Claude uses `CLAUDE_CODE_CURRENT_SESSION_ID`, Gemini uses `gemini-$PPID`, and unknown agents fall back to `$$`. +The state root itself is per-user, never a shared, predictable `/tmp` +directory: `$XDG_RUNTIME_DIR/agentguard/hook-state` when available (a per-user +directory the system clears on logout), falling back to `$XDG_STATE_HOME`, then +`~/.local/state`, and finally a uid-scoped tmp path only when neither a runtime +dir nor `HOME` is set. + Launchers should set `AGENTGUARD_NAME` with `AGENTGUARD_SESSION_ID` when they know the concrete agent. `AGENTGUARD_SESSION_ID` alone falls back to the generic `agent` identity. diff --git a/lib/agentguard/README.md b/lib/agentguard/README.md index 445d4c8..1e85b68 100644 --- a/lib/agentguard/README.md +++ b/lib/agentguard/README.md @@ -113,6 +113,12 @@ available, because nested Codex launches can inherit an outer process key, Claude uses `CLAUDE_CODE_CURRENT_SESSION_ID`, Gemini uses `gemini-$PPID`, and unknown agents fall back to `$$`. +The state root itself is per-user, never a shared, predictable `/tmp` +directory: `$XDG_RUNTIME_DIR/agentguard/hook-state` when available (a per-user +directory the system clears on logout), falling back to `$XDG_STATE_HOME`, then +`~/.local/state`, and finally a uid-scoped tmp path only when neither a runtime +dir nor `HOME` is set. + Launchers should set `AGENTGUARD_NAME` with `AGENTGUARD_SESSION_ID` when they know the concrete agent. `AGENTGUARD_SESSION_ID` alone falls back to the generic `agent` identity. diff --git a/lib/agentguard/hook-helpers.sh b/lib/agentguard/hook-helpers.sh index f04722b..b89dab3 100644 --- a/lib/agentguard/hook-helpers.sh +++ b/lib/agentguard/hook-helpers.sh @@ -75,6 +75,38 @@ _hook_codex_process_key() { fi } +# Per-user root for hook session state. Session markers, counters, and edit +# churn tracking are ephemeral and per-user; they must never live under a +# shared, predictable /tmp path. A fixed name like /tmp/hook-state lets another +# user on a shared host pre-create it (as a dir they own, or a symlink) and then +# tamper with another session's guard state, deny writes, or redirect them into +# a victim's tree (CWE-377/CWE-59). Prefer XDG_RUNTIME_DIR: a per-user 0700 +# directory the system wipes on logout, which is the canonical home for +# ephemeral runtime state and keeps stale per-session dirs from accumulating. +# Fall back to XDG_STATE_HOME, then ~/.local/state, and only to a uid-scoped tmp +# path when neither a runtime dir nor HOME is available, so hooks never +# hard-fail. +_hook_state_root() { + local base="${XDG_RUNTIME_DIR:-}" + [ -n "$base" ] || base="${XDG_STATE_HOME:-}" + [ -n "$base" ] || { [ -n "${HOME:-}" ] && base="$HOME/.local/state"; } + if [ -n "$base" ]; then + printf '%s/agentguard/hook-state' "$base" + else + printf '%s/agentguard-hook-state-%s' "${TMPDIR:-/tmp}" "$(id -u 2>/dev/null || echo 0)" + fi +} + +# Create a hook state directory (and parents) privately. XDG_RUNTIME_DIR is +# already 0700, but the XDG_STATE_HOME/~/.local/state fallbacks inherit the +# caller's umask (typically 0755), which would let another user on a shared host +# read a session's activity metadata (which MCP servers failed, per-file edit +# churn). Creating under `umask 077` keeps every tier's state dirs 0700 to match +# the privacy the runtime tier gives for free. The subshell contains the umask. +_hook_mkstate() { + (umask 077 && mkdir -p "$1") 2>/dev/null +} + # Session key for state directory isolation. Prefer stable runtime ids. Codex # hook commands can inherit CODEX_THREAD_ID from an outer Codex process when a # user launches nested Codex, so once Codex JSON has been read its session_id is @@ -111,7 +143,7 @@ _hook_refresh_state_dir() { [ -n "$session_key" ] || session_key="$$" _HOOK_SESSION_KEY="$session_key" - _HOOK_STATE_DIR="${TMPDIR:-/tmp}/hook-state/$_HOOK_SESSION_KEY" + _HOOK_STATE_DIR="$(_hook_state_root)/$_HOOK_SESSION_KEY" } _hook_refresh_state_dir @@ -156,7 +188,7 @@ _hook_mark_once() { # If state cannot be written, fail open and show the reminder or warning. # Missing a de-duplication marker is less harmful than silently dropping # behavioral steer. - mkdir -p "$dir" 2>/dev/null || return 0 + _hook_mkstate "$dir" || return 0 : >"$marker" 2>/dev/null || true return 0 } @@ -197,7 +229,7 @@ _hook_counter_increment() { local file="$1" dir count dir="${file%/*}" if [ "$dir" != "$file" ]; then - mkdir -p "$dir" 2>/dev/null || return 0 + _hook_mkstate "$dir" || return 0 fi count=$(_hook_counter_read "$file") printf '%s\n' "$((count + 1))" >"$file" 2>/dev/null || true @@ -640,7 +672,7 @@ _hook_hm_session_start() { # _hook_hm_event also reads stdin for callers that did not already do so, # so derive the marker path from the post-event state as a final guard. marker="$_HOOK_STATE_DIR/hm-session-start-context-emitted" - mkdir -p "$_HOOK_STATE_DIR" 2>/dev/null || return 0 + _hook_mkstate "$_HOOK_STATE_DIR" || return 0 : >"$marker" 2>/dev/null || true fi } diff --git a/test/suites/agent-hook-edit-test b/test/suites/agent-hook-edit-test index 14de2a4..201b0a0 100755 --- a/test/suites/agent-hook-edit-test +++ b/test/suites/agent-hook-edit-test @@ -308,7 +308,7 @@ _reset_state # Post-edit with no file_path should not create churn state _run_hook "$POST_EDIT" '{"tool_input":{}}' -churn_dir="$TEST_TMPDIR/hook-state/$TEST_SID/edit-churn" +churn_dir="$TEST_TMPDIR/agentguard/hook-state/$TEST_SID/edit-churn" if [ -d "$churn_dir" ] && [ -n "$(ls -A "$churn_dir" 2>/dev/null)" ]; then _fail "churn empty AGENTGUARD_EDIT_FILE: no churn files should exist" else diff --git a/test/suites/agent-hook-gemini-test b/test/suites/agent-hook-gemini-test index 2d3ce68..267e964 100755 --- a/test/suites/agent-hook-gemini-test +++ b/test/suites/agent-hook-gemini-test @@ -42,8 +42,8 @@ _assert_eq "gemini: session-start emits Gemini additionalContext" "0" "$?" # MCP circuit breaker works under Gemini env (PPID-based state dir) # Clean any leftover Gemini state from prior tests. The PPID inside the # hook child is the test runner's PID ($$), so the state dir is -# $TEST_TMPDIR/hook-state/gemini-$$. Clean it before the test. -rm -rf "$TEST_TMPDIR/hook-state/gemini-$$" +# $TEST_TMPDIR/agentguard/hook-state/gemini-$$. Clean it before the test. +rm -rf "$TEST_TMPDIR/agentguard/hook-state/gemini-$$" # Accumulate 3 failures via post-mcp under Gemini env _run_hook_gemini "$POST_MCP" '{"tool_name":"mcp__gsrv__tool","tool_result_is_error":true}' @@ -60,7 +60,7 @@ _run_hook_gemini "$PRE_MCP" '{"tool_name":"mcp__gsrv__tool","tool_input":{}}' _assert_eq "gemini: circuit breaker resets after success" "0" "$HOOK_EXIT" # knowledge_load warn-once works under Gemini env -rm -rf "$TEST_TMPDIR/hook-state/gemini-$$" +rm -rf "$TEST_TMPDIR/agentguard/hook-state/gemini-$$" _run_hook_gemini "$PRE_MCP" '{"tool_name":"mcp__gsrv__knowledge_load","tool_input":{"url":"https://example.com"}}' _assert_contains "gemini: knowledge_load first call warns" "WARNING" "$HOOK_STDERR" _run_hook_gemini "$PRE_MCP" '{"tool_name":"mcp__gsrv__knowledge_load","tool_input":{"url":"https://other.com"}}' diff --git a/test/suites/agent-hook-helpers-test b/test/suites/agent-hook-helpers-test index a6733d0..46128a2 100755 --- a/test/suites/agent-hook-helpers-test +++ b/test/suites/agent-hook-helpers-test @@ -197,9 +197,9 @@ _assert_contains "hook PATH fallback: empty PATH keeps system tools" \ AGENTGUARD_SESSION_ID="neutral-session" CODEX_THREAD_ID="codex-thread-123" CLAUDE_CODE_CURRENT_SESSION_ID="test123" - TMPDIR="/tmp/test" + XDG_RUNTIME_DIR="/tmp/test" source "$HELPERS" - if [ "$_HOOK_STATE_DIR" = "/tmp/test/hook-state/neutral-session" ]; then + if [ "$_HOOK_STATE_DIR" = "/tmp/test/agentguard/hook-state/neutral-session" ]; then echo "PASS" else echo "FAIL: $_HOOK_STATE_DIR" @@ -212,9 +212,9 @@ _assert_eq "state_dir: neutral session id wins" "0" "$?" ( unset CODEX_THREAD_ID 2>/dev/null CLAUDE_CODE_CURRENT_SESSION_ID="test123" - TMPDIR="/tmp/test" + XDG_RUNTIME_DIR="/tmp/test" source "$HELPERS" - [ "$_HOOK_STATE_DIR" = "/tmp/test/hook-state/test123" ] && echo "PASS" || echo "FAIL: $_HOOK_STATE_DIR" + [ "$_HOOK_STATE_DIR" = "/tmp/test/agentguard/hook-state/test123" ] && echo "PASS" || echo "FAIL: $_HOOK_STATE_DIR" ) | _stdin_contains PASS _assert_eq "state_dir: claude env vars" "0" "$?" @@ -225,9 +225,9 @@ _assert_eq "state_dir: claude env vars" "0" "$?" ( CODEX_THREAD_ID="codex-thread-123" CLAUDE_CODE_CURRENT_SESSION_ID="test123" - TMPDIR="/tmp/test" + XDG_RUNTIME_DIR="/tmp/test" source "$HELPERS" - [ "$_HOOK_STATE_DIR" = "/tmp/test/hook-state/codex-thread-123" ] && echo "PASS" || echo "FAIL: $_HOOK_STATE_DIR" + [ "$_HOOK_STATE_DIR" = "/tmp/test/agentguard/hook-state/codex-thread-123" ] && echo "PASS" || echo "FAIL: $_HOOK_STATE_DIR" ) | _stdin_contains PASS _assert_eq "state_dir: codex thread id wins over claude env" "0" "$?" @@ -238,10 +238,10 @@ _assert_eq "state_dir: codex thread id wins over claude env" "0" "$?" env -u CODEX_THREAD_ID -u CLAUDE_CODE_CURRENT_SESSION_ID \ AGENTGUARD_NAME="codex" \ AGENTGUARD_PROCESS_DETECT=0 \ - TMPDIR="/tmp/codex-process-test" \ + XDG_RUNTIME_DIR="/tmp/codex-process-test" \ bash -c ' source "$1" - [[ "$_HOOK_STATE_DIR" == "/tmp/codex-process-test/hook-state/codex-"* ]] && echo "PASS" || echo "FAIL: $_HOOK_STATE_DIR" + [[ "$_HOOK_STATE_DIR" == "/tmp/codex-process-test/agentguard/hook-state/codex-"* ]] && echo "PASS" || echo "FAIL: $_HOOK_STATE_DIR" ' _ "$HELPERS" ) | _stdin_contains PASS _assert_eq "state_dir: codex process key fallback" "0" "$?" @@ -302,10 +302,10 @@ _assert_eq "codex_process_key: argv mention is not misattributed to codex" "0" " ( unset CODEX_THREAD_ID CLAUDE_CODE_CURRENT_SESSION_ID 2>/dev/null AGENTGUARD_NAME="codex" - TMPDIR="/tmp/codex-json-test" + XDG_RUNTIME_DIR="/tmp/codex-json-test" _HOOK_INPUT='{"session_id":"codex-json-session"}' source "$HELPERS" - [ "$_HOOK_STATE_DIR" = "/tmp/codex-json-test/hook-state/codex-json-session" ] && echo "PASS" || echo "FAIL: $_HOOK_STATE_DIR" + [ "$_HOOK_STATE_DIR" = "/tmp/codex-json-test/agentguard/hook-state/codex-json-session" ] && echo "PASS" || echo "FAIL: $_HOOK_STATE_DIR" ) | _stdin_contains PASS _assert_eq "state_dir: codex JSON session id wins over process fallback" "0" "$?" @@ -316,32 +316,59 @@ _assert_eq "state_dir: codex JSON session id wins over process fallback" "0" "$? AGENTGUARD_NAME="codex" AGENTGUARD_SESSION_ID="outer-session" CODEX_THREAD_ID="outer-session" - TMPDIR="/tmp/codex-nested-test" + XDG_RUNTIME_DIR="/tmp/codex-nested-test" _HOOK_INPUT='{"session_id":"inner-session"}' source "$HELPERS" - [ "$_HOOK_STATE_DIR" = "/tmp/codex-nested-test/hook-state/inner-session" ] && echo "PASS" || echo "FAIL: $_HOOK_STATE_DIR" + [ "$_HOOK_STATE_DIR" = "/tmp/codex-nested-test/agentguard/hook-state/inner-session" ] && echo "PASS" || echo "FAIL: $_HOOK_STATE_DIR" ) | _stdin_contains PASS _assert_eq "state_dir: codex JSON session id beats inherited env ids" "0" "$?" -# _HOOK_STATE_DIR uses TMPDIR for the tmp root +# _hook_state_root cascade: XDG_RUNTIME_DIR is preferred as the per-user, +# system-cleaned root for ephemeral session state, so it wins over XDG_STATE_HOME. # shellcheck disable=SC2034 # env vars consumed by sourced helpers, not direct use ( unset AGENTGUARD_NAME CODEX_INTERNAL_ORIGINATOR_OVERRIDE CODEX_THREAD_ID \ CLAUDE_CODE_CURRENT_SESSION_ID 2>/dev/null - TMPDIR="/tmp/custom" + XDG_RUNTIME_DIR="/tmp/runtime" + XDG_STATE_HOME="/tmp/state" + source "$HELPERS" + [[ "$_HOOK_STATE_DIR" == "/tmp/runtime/agentguard/hook-state/"* ]] && echo "PASS" || echo "FAIL: $_HOOK_STATE_DIR" +) | _stdin_contains PASS +_assert_eq "state_dir: XDG_RUNTIME_DIR wins" "0" "$?" + +# Falls back to XDG_STATE_HOME when no runtime dir is available (macOS, cron). +# shellcheck disable=SC2034 # env vars consumed by sourced helpers, not direct use +( + unset AGENTGUARD_NAME CODEX_INTERNAL_ORIGINATOR_OVERRIDE CODEX_THREAD_ID \ + CLAUDE_CODE_CURRENT_SESSION_ID XDG_RUNTIME_DIR 2>/dev/null + XDG_STATE_HOME="/tmp/state" + source "$HELPERS" + [[ "$_HOOK_STATE_DIR" == "/tmp/state/agentguard/hook-state/"* ]] && echo "PASS" || echo "FAIL: $_HOOK_STATE_DIR" +) | _stdin_contains PASS +_assert_eq "state_dir: XDG_STATE_HOME fallback" "0" "$?" + +# Falls back to ~/.local/state when neither XDG var is set but HOME is. +# shellcheck disable=SC2034 # env vars consumed by sourced helpers, not direct use +( + unset AGENTGUARD_NAME CODEX_INTERNAL_ORIGINATOR_OVERRIDE CODEX_THREAD_ID \ + CLAUDE_CODE_CURRENT_SESSION_ID XDG_RUNTIME_DIR XDG_STATE_HOME 2>/dev/null + HOME="/tmp/fakehome" source "$HELPERS" - [[ "$_HOOK_STATE_DIR" == "/tmp/custom/hook-state/"* ]] && echo "PASS" || echo "FAIL: $_HOOK_STATE_DIR" + [[ "$_HOOK_STATE_DIR" == "/tmp/fakehome/.local/state/agentguard/hook-state/"* ]] && echo "PASS" || echo "FAIL: $_HOOK_STATE_DIR" ) | _stdin_contains PASS -_assert_eq "state_dir: TMPDIR fallback" "0" "$?" +_assert_eq "state_dir: ~/.local/state fallback" "0" "$?" -# _HOOK_STATE_DIR falls back to /tmp and PID when no env vars set +# Last resort: a uid-scoped tmp path when no runtime dir, XDG state, or HOME is +# available, so hooks never hard-fail. The uid suffix avoids a shared /tmp name. +# shellcheck disable=SC2034 # env vars consumed by sourced helpers, not direct use ( unset AGENTGUARD_NAME CODEX_INTERNAL_ORIGINATOR_OVERRIDE CODEX_THREAD_ID \ - CLAUDE_CODE_CURRENT_SESSION_ID TMPDIR 2>/dev/null + CLAUDE_CODE_CURRENT_SESSION_ID XDG_RUNTIME_DIR XDG_STATE_HOME HOME 2>/dev/null + TMPDIR="/tmp/custom" source "$HELPERS" - [[ "$_HOOK_STATE_DIR" == "/tmp/hook-state/"* ]] && echo "PASS" || echo "FAIL: $_HOOK_STATE_DIR" + [[ "$_HOOK_STATE_DIR" == "/tmp/custom/agentguard-hook-state-$(id -u)/"* ]] && echo "PASS" || echo "FAIL: $_HOOK_STATE_DIR" ) | _stdin_contains PASS -_assert_eq "state_dir: falls back to /tmp and PID" "0" "$?" +_assert_eq "state_dir: uid-scoped tmp last resort" "0" "$?" # _HOOK_STATE_DIR uses gemini-$PPID when GEMINI_PROJECT_DIR is set # shellcheck disable=SC2034 # env vars consumed by sourced helpers, not direct use @@ -349,9 +376,9 @@ _assert_eq "state_dir: falls back to /tmp and PID" "0" "$?" unset AGENTGUARD_NAME CODEX_INTERNAL_ORIGINATOR_OVERRIDE CODEX_THREAD_ID \ CLAUDE_CODE_CURRENT_SESSION_ID 2>/dev/null GEMINI_PROJECT_DIR="/tmp/project" - TMPDIR="/tmp/gemtest" + XDG_RUNTIME_DIR="/tmp/gemtest" source "$HELPERS" - [[ "$_HOOK_STATE_DIR" == "/tmp/gemtest/hook-state/gemini-"* ]] && echo "PASS" || echo "FAIL: $_HOOK_STATE_DIR" + [[ "$_HOOK_STATE_DIR" == "/tmp/gemtest/agentguard/hook-state/gemini-"* ]] && echo "PASS" || echo "FAIL: $_HOOK_STATE_DIR" ) | _stdin_contains PASS _assert_eq "state_dir: gemini uses PPID-based key" "0" "$?" @@ -360,10 +387,10 @@ _assert_eq "state_dir: gemini uses PPID-based key" "0" "$?" ( unset CODEX_THREAD_ID 2>/dev/null CLAUDE_CODE_CURRENT_SESSION_ID="test123" - TMPDIR="/tmp/test" + XDG_RUNTIME_DIR="/tmp/test" GEMINI_PROJECT_DIR="/tmp/project" source "$HELPERS" - [ "$_HOOK_STATE_DIR" = "/tmp/test/hook-state/test123" ] && echo "PASS" || echo "FAIL: $_HOOK_STATE_DIR" + [ "$_HOOK_STATE_DIR" = "/tmp/test/agentguard/hook-state/test123" ] && echo "PASS" || echo "FAIL: $_HOOK_STATE_DIR" ) | _stdin_contains PASS _assert_eq "state_dir: claude session ID wins over gemini" "0" "$?" @@ -371,11 +398,11 @@ _assert_eq "state_dir: claude session ID wins over gemini" "0" "$?" # vars provide a stable session key. ( unset CODEX_THREAD_ID CLAUDE_CODE_CURRENT_SESSION_ID GEMINI_PROJECT_DIR 2>/dev/null - TMPDIR="/tmp/json-session-test" + XDG_RUNTIME_DIR="/tmp/json-session-test" source "$HELPERS" _HOOK_INPUT='{"session_id":"json-session-123"}' _hook_refresh_state_dir - [ "$_HOOK_STATE_DIR" = "/tmp/json-session-test/hook-state/json-session-123" ] && echo "PASS" || echo "FAIL: $_HOOK_STATE_DIR" + [ "$_HOOK_STATE_DIR" = "/tmp/json-session-test/agentguard/hook-state/json-session-123" ] && echo "PASS" || echo "FAIL: $_HOOK_STATE_DIR" ) | _stdin_contains PASS _assert_eq "state_dir: refresh uses input session_id" "0" "$?" @@ -384,23 +411,24 @@ _assert_eq "state_dir: refresh uses input session_id" "0" "$?" ( unset CODEX_THREAD_ID 2>/dev/null CLAUDE_CODE_CURRENT_SESSION_ID="test123" - TMPDIR="/tmp/test" + XDG_RUNTIME_DIR="/tmp/test" source "$HELPERS" _HOOK_INPUT='{"session_id":"json-session-123"}' _hook_refresh_state_dir - [ "$_HOOK_STATE_DIR" = "/tmp/test/hook-state/test123" ] && echo "PASS" || echo "FAIL: $_HOOK_STATE_DIR" + [ "$_HOOK_STATE_DIR" = "/tmp/test/agentguard/hook-state/test123" ] && echo "PASS" || echo "FAIL: $_HOOK_STATE_DIR" ) | _stdin_contains PASS _assert_eq "state_dir: claude env wins over input session_id" "0" "$?" # Parsing command JSON refreshes state from session_id for agents without env # session IDs. +# shellcheck disable=SC2034 # env vars consumed by sourced helpers, not direct use ( unset CODEX_THREAD_ID CLAUDE_CODE_CURRENT_SESSION_ID GEMINI_PROJECT_DIR 2>/dev/null - TMPDIR="/tmp/parse-session-test" + XDG_RUNTIME_DIR="/tmp/parse-session-test" source "$HELPERS" _HOOK_INPUT='{"session_id":"parse-session-123","tool_input":{"command":"echo ok"}}' _hook_parse_command 2>/dev/null ) so nothing +# leaks into the real per-user state; the harness re-points XDG_RUNTIME_DIR at +# the test tmpdir once TEST_TMPDIR exists (see below). +unset XDG_RUNTIME_DIR XDG_STATE_HOME # Dotfiles and other consumers may tune or bypass edit-churn globally. Test # default behavior against repo defaults; individual tests set custom values # where that contract is under test. @@ -49,6 +54,18 @@ HELPERS="$AGENTGUARD_ROOT/lib/agentguard/hook-helpers.sh" TEST_TMPDIR=$(_tmpdir) TEST_SID="agent-hook-test-$$" +# Point the hook state root at the test tmpdir for every fixture and child +# process (hooks resolve state from XDG_RUNTIME_DIR first). Exporting once here +# keeps both spawned hooks and the test process's own _hook_* helpers hermetic, +# so nothing writes into the real ~/.local/state. +# +# Hermeticity contract for new fixtures: rely on this inherited export. A fixture +# that unsets/overrides XDG_RUNTIME_DIR (e.g. to exercise a fallback tier) must +# EITHER only compute the state path without writing, OR also point HOME at a +# temp dir (`_mock_home`) so a resolved ~/.local/state still can't reach the real +# home. Never run a hook that writes state with the ambient HOME and no XDG root. +export XDG_RUNTIME_DIR="$TEST_TMPDIR" + PRE_MCP="$BIN_DIR/agent-hook-pre-mcp" POST_MCP="$BIN_DIR/agent-hook-post-mcp" PRE_BASH="$BIN_DIR/agent-hook-pre-bash" @@ -194,5 +211,5 @@ _run_hook_gemini() { # Clean hook state between tests _reset_state() { - rm -rf "$TEST_TMPDIR/hook-state/$TEST_SID" + rm -rf "$TEST_TMPDIR/agentguard/hook-state/$TEST_SID" } diff --git a/test/suites/agent-hook-mcp-test b/test/suites/agent-hook-mcp-test index 18b83c6..787fe60 100755 --- a/test/suites/agent-hook-mcp-test +++ b/test/suites/agent-hook-mcp-test @@ -20,30 +20,30 @@ _assert_eq "no failures: no stderr" "" "$HOOK_STDERR" # 2 failures → still passes _reset_state -mkdir -p "$TEST_TMPDIR/hook-state/$TEST_SID" -echo 2 >"$TEST_TMPDIR/hook-state/$TEST_SID/mcp-failures-srv" +mkdir -p "$TEST_TMPDIR/agentguard/hook-state/$TEST_SID" +echo 2 >"$TEST_TMPDIR/agentguard/hook-state/$TEST_SID/mcp-failures-srv" _run_hook "$PRE_MCP" '{"tool_name":"mcp__srv__tool","tool_input":{}}' _assert_eq "2 failures: exit 0" "0" "$HOOK_EXIT" # 3 failures → blocks -echo 3 >"$TEST_TMPDIR/hook-state/$TEST_SID/mcp-failures-srv" +echo 3 >"$TEST_TMPDIR/agentguard/hook-state/$TEST_SID/mcp-failures-srv" _run_hook "$PRE_MCP" '{"tool_name":"mcp__srv__tool","tool_input":{}}' _assert_eq "3 failures: exit 2" "2" "$HOOK_EXIT" _assert_contains "3 failures: BLOCKED msg" "BLOCKED" "$HOOK_STDERR" # 5 failures → still blocks -echo 5 >"$TEST_TMPDIR/hook-state/$TEST_SID/mcp-failures-srv" +echo 5 >"$TEST_TMPDIR/agentguard/hook-state/$TEST_SID/mcp-failures-srv" _run_hook "$PRE_MCP" '{"tool_name":"mcp__srv__tool","tool_input":{}}' _assert_eq "5 failures: exit 2" "2" "$HOOK_EXIT" # Empty fail file → does not crash (treated as 0) -: >"$TEST_TMPDIR/hook-state/$TEST_SID/mcp-failures-srv" +: >"$TEST_TMPDIR/agentguard/hook-state/$TEST_SID/mcp-failures-srv" _run_hook "$PRE_MCP" '{"tool_name":"mcp__srv__tool","tool_input":{}}' _assert_eq "empty file: exit 0" "0" "$HOOK_EXIT" _assert_not_contains "empty file: no error" "integer expression" "$HOOK_STDERR" # Non-numeric fail file → does not crash -echo "garbage" >"$TEST_TMPDIR/hook-state/$TEST_SID/mcp-failures-srv" +echo "garbage" >"$TEST_TMPDIR/agentguard/hook-state/$TEST_SID/mcp-failures-srv" _run_hook "$PRE_MCP" '{"tool_name":"mcp__srv__tool","tool_input":{}}' _assert_eq "garbage file: exit 0" "0" "$HOOK_EXIT" @@ -56,15 +56,15 @@ echo "=== pre-mcp: server name extraction ===" # Multi-segment server name (plugin_mux_alpha) _reset_state -mkdir -p "$TEST_TMPDIR/hook-state/$TEST_SID" -echo 3 >"$TEST_TMPDIR/hook-state/$TEST_SID/mcp-failures-plugin_mux_alpha" +mkdir -p "$TEST_TMPDIR/agentguard/hook-state/$TEST_SID" +echo 3 >"$TEST_TMPDIR/agentguard/hook-state/$TEST_SID/mcp-failures-plugin_mux_alpha" _run_hook "$PRE_MCP" '{"tool_name":"mcp__plugin_mux_alpha__search_files","tool_input":{}}' _assert_eq "multi-segment server: blocked" "2" "$HOOK_EXIT" # Single-segment server name _reset_state -mkdir -p "$TEST_TMPDIR/hook-state/$TEST_SID" -echo 3 >"$TEST_TMPDIR/hook-state/$TEST_SID/mcp-failures-github" +mkdir -p "$TEST_TMPDIR/agentguard/hook-state/$TEST_SID" +echo 3 >"$TEST_TMPDIR/agentguard/hook-state/$TEST_SID/mcp-failures-github" _run_hook "$PRE_MCP" '{"tool_name":"mcp__github__create_issue","tool_input":{}}' _assert_eq "single-segment server: blocked" "2" "$HOOK_EXIT" @@ -136,7 +136,7 @@ _run_hook "$PRE_MCP" '{"tool_name":"mcp__srv__knowledge_load","tool_input":{"url _assert_not_contains "second call: no warning" "WARNING" "$HOOK_STDERR" # Flag file exists -if [ -f "$TEST_TMPDIR/hook-state/$TEST_SID/knowledge-load-warned" ]; then +if [ -f "$TEST_TMPDIR/agentguard/hook-state/$TEST_SID/knowledge-load-warned" ]; then _pass "warn-once: flag file created" else _fail "warn-once: flag file created" @@ -229,7 +229,7 @@ _reset_state # Successful call → no fail file _run_hook "$POST_MCP" '{"tool_name":"mcp__srv__tool","tool_result_is_error":false}' -if [ ! -f "$TEST_TMPDIR/hook-state/$TEST_SID/mcp-failures-srv" ]; then +if [ ! -f "$TEST_TMPDIR/agentguard/hook-state/$TEST_SID/mcp-failures-srv" ]; then _pass "success: no fail file" else _fail "success: no fail file" @@ -237,8 +237,8 @@ fi # Failed call → fail file with count 1 _run_hook "$POST_MCP" '{"tool_name":"mcp__srv__tool","tool_result_is_error":true}' -if [ -f "$TEST_TMPDIR/hook-state/$TEST_SID/mcp-failures-srv" ]; then - count=$(cat "$TEST_TMPDIR/hook-state/$TEST_SID/mcp-failures-srv") +if [ -f "$TEST_TMPDIR/agentguard/hook-state/$TEST_SID/mcp-failures-srv" ]; then + count=$(cat "$TEST_TMPDIR/agentguard/hook-state/$TEST_SID/mcp-failures-srv") _assert_eq "first failure: count 1" "1" "$count" else _fail "first failure: fail file created" @@ -246,12 +246,12 @@ fi # Second failure → count 2 _run_hook "$POST_MCP" '{"tool_name":"mcp__srv__tool","tool_result_is_error":true}' -count=$(cat "$TEST_TMPDIR/hook-state/$TEST_SID/mcp-failures-srv") +count=$(cat "$TEST_TMPDIR/agentguard/hook-state/$TEST_SID/mcp-failures-srv") _assert_eq "second failure: count 2" "2" "$count" # Success after failures → resets counter _run_hook "$POST_MCP" '{"tool_name":"mcp__srv__tool","tool_result_is_error":false}' -if [ ! -f "$TEST_TMPDIR/hook-state/$TEST_SID/mcp-failures-srv" ]; then +if [ ! -f "$TEST_TMPDIR/agentguard/hook-state/$TEST_SID/mcp-failures-srv" ]; then _pass "success resets: fail file removed" else _fail "success resets: fail file removed" @@ -261,8 +261,8 @@ fi _run_hook "$POST_MCP" '{"tool_name":"mcp__alpha__tool","tool_result_is_error":true}' _run_hook "$POST_MCP" '{"tool_name":"mcp__beta__tool","tool_result_is_error":true}' _run_hook "$POST_MCP" '{"tool_name":"mcp__alpha__tool","tool_result_is_error":true}' -count_a=$(cat "$TEST_TMPDIR/hook-state/$TEST_SID/mcp-failures-alpha") -count_b=$(cat "$TEST_TMPDIR/hook-state/$TEST_SID/mcp-failures-beta") +count_a=$(cat "$TEST_TMPDIR/agentguard/hook-state/$TEST_SID/mcp-failures-alpha") +count_b=$(cat "$TEST_TMPDIR/agentguard/hook-state/$TEST_SID/mcp-failures-beta") _assert_eq "independent servers: alpha=2" "2" "$count_a" _assert_eq "independent servers: beta=1" "1" "$count_b"