Skip to content

Move hook session state off the shared /tmp path to a per-user root#36

Merged
cgraf78 merged 1 commit into
mainfrom
fix-hook-state-xdg
Jul 2, 2026
Merged

Move hook session state off the shared /tmp path to a per-user root#36
cgraf78 merged 1 commit into
mainfrom
fix-hook-state-xdg

Conversation

@cgraf78

@cgraf78 cgraf78 commented Jul 2, 2026

Copy link
Copy Markdown
Owner

Summary

Store per-session hook state under a per-user XDG root instead of a
predictable, world-writable /tmp path, closing a shared-host tamper
and write-redirection hazard.

Hook state (prompt-cycle markers, once-per-session markers, edit-churn
counters, MCP-failure counters, the session-start "context emitted"
marker) lived at ${TMPDIR:-/tmp}/hook-state/<session>. The fixed
/tmp/hook-state name in a world-writable directory lets another user on
a shared host pre-create it (as a directory they own, or a symlink) and
then tamper with a victim's guard state, deny writes, or redirect the
writes into a victim's tree (CWE-377/CWE-59).

  • add _hook_state_root, resolving a per-user root: prefer
    XDG_RUNTIME_DIR (a per-user 0700 directory the system clears on
    logout — the canonical home for ephemeral runtime state, so stale
    per-session dirs don't accumulate), then XDG_STATE_HOME, then
    ~/.local/state, and only a uid-scoped ${TMPDIR:-/tmp} path when
    neither a runtime dir nor HOME exists, so hooks never hard-fail
  • add _hook_mkstate, which creates state dirs under umask 077 so the
    XDG_STATE_HOME/~/.local/state fallback tiers are 0700 too, matching
    the privacy the runtime tier gives for free (session activity metadata
    is not readable by other users on a shared host)
  • document the new root in both README copies

Out of scope, consciously accepted: the daily plugin-update stamp in
bin/agent-hook-session-start-claude (/tmp/claude-plugins-updated-$(id -u)) is already uid-scoped and has a different (daily, persistent)
lifecycle, so it is left as-is rather than folded into the ephemeral
session-state root. A separate, pre-existing test-hermeticity gap
(XDG_CACHE_HOME is not scrubbed by the suite, so the protected-bare
classifier cache writes to the real ~/.cache) is unrelated to this
change and left for its own fix.

Testing

  • update the test harness to scrub ambient XDG and re-point
    XDG_RUNTIME_DIR at the test tmpdir so fixtures and the test process's
    own _hook_* helpers stay hermetic; document the hermeticity contract
  • rewrite the source-time state-dir tests to drive the resolver per tier,
    and add cascade tests: XDG_RUNTIME_DIR wins, then XDG_STATE_HOME,
    then ~/.local/state, then a uid-scoped tmp last resort
  • verified the created state dirs are 0700 across tiers
  • full suite green (./test/agentguard-test: ok, all suites 0 failed);
    checkrun lint and shellcheck -x lib/agentguard/hook-helpers.sh clean
  • reviewed by two independent adversarial fresh-eyes passes; their
    confirmed items (state-dir privacy on the fallback tiers, hermeticity
    contract) are folded into this change

Summary

Store per-session hook state under a per-user XDG root instead of a
predictable, world-writable `/tmp` path, closing a shared-host tamper
and write-redirection hazard.

Hook state (prompt-cycle markers, once-per-session markers, edit-churn
counters, MCP-failure counters, the session-start "context emitted"
marker) lived at `${TMPDIR:-/tmp}/hook-state/<session>`. The fixed
`/tmp/hook-state` name in a world-writable directory lets another user on
a shared host pre-create it (as a directory they own, or a symlink) and
then tamper with a victim's guard state, deny writes, or redirect the
writes into a victim's tree (CWE-377/CWE-59).

- add `_hook_state_root`, resolving a per-user root: prefer
  `XDG_RUNTIME_DIR` (a per-user 0700 directory the system clears on
  logout — the canonical home for ephemeral runtime state, so stale
  per-session dirs don't accumulate), then `XDG_STATE_HOME`, then
  `~/.local/state`, and only a uid-scoped `${TMPDIR:-/tmp}` path when
  neither a runtime dir nor `HOME` exists, so hooks never hard-fail
- add `_hook_mkstate`, which creates state dirs under `umask 077` so the
  `XDG_STATE_HOME`/`~/.local/state` fallback tiers are 0700 too, matching
  the privacy the runtime tier gives for free (session activity metadata
  is not readable by other users on a shared host)
- document the new root in both README copies

Out of scope, consciously accepted: the daily plugin-update stamp in
`bin/agent-hook-session-start-claude` (`/tmp/claude-plugins-updated-$(id
-u)`) is already uid-scoped and has a different (daily, persistent)
lifecycle, so it is left as-is rather than folded into the ephemeral
session-state root. A separate, pre-existing test-hermeticity gap
(`XDG_CACHE_HOME` is not scrubbed by the suite, so the protected-bare
classifier cache writes to the real `~/.cache`) is unrelated to this
change and left for its own fix.

Testing

- update the test harness to scrub ambient XDG and re-point
  `XDG_RUNTIME_DIR` at the test tmpdir so fixtures and the test process's
  own `_hook_*` helpers stay hermetic; document the hermeticity contract
- rewrite the source-time state-dir tests to drive the resolver per tier,
  and add cascade tests: `XDG_RUNTIME_DIR` wins, then `XDG_STATE_HOME`,
  then `~/.local/state`, then a uid-scoped tmp last resort
- verified the created state dirs are 0700 across tiers
- full suite green (`./test/agentguard-test`: ok, all suites 0 failed);
  `checkrun lint` and `shellcheck -x lib/agentguard/hook-helpers.sh` clean
- reviewed by two independent adversarial fresh-eyes passes; their
  confirmed items (state-dir privacy on the fallback tiers, hermeticity
  contract) are folded into this change
@cgraf78 cgraf78 merged commit 6d3373a into main Jul 2, 2026
6 checks passed
@cgraf78 cgraf78 deleted the fix-hook-state-xdg branch July 2, 2026 02:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant