diff --git a/bin/gstack-paths b/bin/gstack-paths index eee603d61..1a7e07306 100755 --- a/bin/gstack-paths +++ b/bin/gstack-paths @@ -9,7 +9,7 @@ # CI / container env where HOME may be unset. # # Chains: -# GSTACK_STATE_ROOT: GSTACK_HOME -> CLAUDE_PLUGIN_DATA -> $HOME/.gstack -> .gstack +# GSTACK_STATE_ROOT: GSTACK_HOME -> CLAUDE_PLUGIN_DATA (only when CLAUDE_PLUGIN_ROOT=*gstack*) -> $HOME/.gstack -> .gstack # PLAN_ROOT: GSTACK_PLAN_DIR -> CLAUDE_PLANS_DIR -> $HOME/.claude/plans -> .claude/plans # TMP_ROOT: TMPDIR -> TMP -> .gstack/tmp (and mkdir -p, best-effort) # @@ -21,7 +21,11 @@ set -u # State root: where gstack writes projects/, sessions/, analytics/. if [ -n "${GSTACK_HOME:-}" ]; then _state_root="$GSTACK_HOME" -elif [ -n "${CLAUDE_PLUGIN_DATA:-}" ]; then +elif [ -n "${CLAUDE_PLUGIN_DATA:-}" ] && echo "${CLAUDE_PLUGIN_ROOT:-}" | grep -qi "gstack"; then + # Guard: only trust CLAUDE_PLUGIN_DATA when CLAUDE_PLUGIN_ROOT confirms we are + # running as the gstack plugin. Without this, a CLAUDE_PLUGIN_DATA from another + # plugin (e.g. codex) that leaked into the session env via CLAUDE_ENV_FILE would + # be picked up, writing all gstack state into the wrong directory. _state_root="$CLAUDE_PLUGIN_DATA" elif [ -n "${HOME:-}" ]; then _state_root="$HOME/.gstack" diff --git a/test/gstack-paths.test.ts b/test/gstack-paths.test.ts index a63be45e0..42c13c3ac 100644 --- a/test/gstack-paths.test.ts +++ b/test/gstack-paths.test.ts @@ -41,12 +41,28 @@ describe('gstack-paths', () => { expect(got.GSTACK_STATE_ROOT).toBe('/tmp/explicit-state'); }); - test('CLAUDE_PLUGIN_DATA wins over HOME when GSTACK_HOME unset', () => { - const got = run({ - CLAUDE_PLUGIN_DATA: '/tmp/plugin-data', + test('CLAUDE_PLUGIN_DATA ignored when CLAUDE_PLUGIN_ROOT is absent or non-gstack', () => { + // Without CLAUDE_PLUGIN_ROOT, falls through to HOME path. + const noRoot = run({ CLAUDE_PLUGIN_DATA: '/tmp/plugin-data', HOME: '/tmp/home' }); + expect(noRoot.GSTACK_STATE_ROOT).toBe('/tmp/home/.gstack'); + + // With a CLAUDE_PLUGIN_ROOT that doesn't contain "gstack" (e.g. the codex plugin), + // still falls through to HOME path — this is the cross-plugin contamination scenario. + const wrongRoot = run({ + CLAUDE_PLUGIN_DATA: '/tmp/codex-data', + CLAUDE_PLUGIN_ROOT: '/tmp/openai-codex', HOME: '/tmp/home', }); - expect(got.GSTACK_STATE_ROOT).toBe('/tmp/plugin-data'); + expect(wrongRoot.GSTACK_STATE_ROOT).toBe('/tmp/home/.gstack'); + }); + + test('CLAUDE_PLUGIN_DATA respected when CLAUDE_PLUGIN_ROOT identifies gstack', () => { + const got = run({ + CLAUDE_PLUGIN_DATA: '/tmp/gstack-plugin-data', + CLAUDE_PLUGIN_ROOT: '/tmp/gstack-garrytan', + HOME: '/tmp/home', + }); + expect(got.GSTACK_STATE_ROOT).toBe('/tmp/gstack-plugin-data'); }); test('HOME-derived state root when GSTACK_HOME and CLAUDE_PLUGIN_DATA unset', () => {