From 0c7ef235ede8532af1c291237d63427844af61d1 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 18 May 2026 20:31:32 -0700 Subject: [PATCH] fix(gstack-paths): guard CLAUDE_PLUGIN_DATA against cross-plugin contamination (#1569) gstack-paths previously trusted CLAUDE_PLUGIN_DATA as a fallback for GSTACK_STATE_ROOT whenever GSTACK_HOME was unset. When another plugin (e.g. Codex) persists its own CLAUDE_PLUGIN_DATA into the session env via CLAUDE_ENV_FILE, gstack picked it up and wrote checkpoints, analytics, and learnings into that plugin's directory. Anyone with the Codex plugin installed alongside gstack hit this silently. Fix: guard the CLAUDE_PLUGIN_DATA branch so it only fires when CLAUDE_PLUGIN_ROOT confirms we're running as the gstack plugin (path contains "gstack"). Skill installs fall through to \$HOME/.gstack. Contributed by @ElliotDrel via #1570. Closes #1569. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/gstack-paths | 8 ++++++-- test/gstack-paths.test.ts | 24 ++++++++++++++++++++---- 2 files changed, 26 insertions(+), 6 deletions(-) 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', () => {