Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions lib/agentguard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 36 additions & 4 deletions lib/agentguard/hook-helpers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion test/suites/agent-hook-edit-test
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions test/suites/agent-hook-gemini-test
Original file line number Diff line number Diff line change
Expand Up @@ -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}'
Expand All @@ -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"}}'
Expand Down
88 changes: 58 additions & 30 deletions test/suites/agent-hook-helpers-test
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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" "$?"

Expand All @@ -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" "$?"

Expand All @@ -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" "$?"
Expand Down Expand Up @@ -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" "$?"

Expand All @@ -316,42 +316,69 @@ _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
(
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" "$?"

Expand All @@ -360,22 +387,22 @@ _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" "$?"

# _HOOK_STATE_DIR can be refreshed from hook JSON session_id when no agent env
# 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" "$?"

Expand All @@ -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 </dev/null
[ "$_HOOK_STATE_DIR" = "/tmp/parse-session-test/hook-state/parse-session-123" ] && echo "PASS" || echo "FAIL: $_HOOK_STATE_DIR"
[ "$_HOOK_STATE_DIR" = "/tmp/parse-session-test/agentguard/hook-state/parse-session-123" ] && echo "PASS" || echo "FAIL: $_HOOK_STATE_DIR"
) | _stdin_contains PASS
_assert_eq "state_dir: parse_command refreshes input session_id" "0" "$?"

Expand Down
19 changes: 18 additions & 1 deletion test/suites/agent-hook-helpers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ export AGENTGUARD_PROCESS_DETECT=0
# it explicitly in their own subshell.
unset CLAUDE_CODE_SESSION_ID CLAUDE_CODE_CURRENT_SESSION_ID \
CODEX_THREAD_ID CODEX_INTERNAL_ORIGINATOR_OVERRIDE GEMINI_PROJECT_DIR
# The hook state root resolves from XDG_RUNTIME_DIR/XDG_STATE_HOME. Scrub the
# ambient values (devservers set XDG_RUNTIME_DIR=/run/user/<uid>) 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.
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
}
Loading
Loading