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) <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-05-18 20:31:32 -07:00
parent 026751ea20
commit 0c7ef235ed
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
2 changed files with 26 additions and 6 deletions

View File

@ -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"

View File

@ -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', () => {