diff --git a/bin/gstack-paths b/bin/gstack-paths index 1a7e073065..2d86207ba3 100755 --- a/bin/gstack-paths +++ b/bin/gstack-paths @@ -13,9 +13,15 @@ # PLAN_ROOT: GSTACK_PLAN_DIR -> CLAUDE_PLANS_DIR -> $HOME/.claude/plans -> .claude/plans # TMP_ROOT: TMPDIR -> TMP -> .gstack/tmp (and mkdir -p, best-effort) # -# Security: output values are not sanitized — callers may receive paths with -# shell-special characters if env vars contain them. Skills should always quote -# expansions ("$GSTACK_STATE_ROOT", not $GSTACK_STATE_ROOT). +# Output: values are emitted shell-quoted (printf %q) so `eval` round-trips them +# byte-for-byte. This matters on Windows, where $TMP is a backslash path like +# C:\Users\me\AppData\Local\Temp — with a bare `echo`, eval consumes the +# backslashes as escapes and the caller gets C:UsersmeAppDataLocalTemp. A value +# containing a space (C:\Program Files\Temp) is worse: eval word-splits it and +# the variable ends up empty. Quoting here is the only fix that works, because +# the corruption happens during eval, before the caller has anything to quote. +# Callers should still quote expansions ("$GSTACK_STATE_ROOT") for the same +# reason any path variable needs quoting. set -u # State root: where gstack writes projects/, sessions/, analytics/. @@ -60,6 +66,6 @@ fi # will discover that on their own write attempt. Don't fail the eval here. mkdir -p "$_tmp_root" 2>/dev/null || true -echo "GSTACK_STATE_ROOT=$_state_root" -echo "PLAN_ROOT=$_plan_root" -echo "TMP_ROOT=$_tmp_root" +printf 'GSTACK_STATE_ROOT=%q\n' "$_state_root" +printf 'PLAN_ROOT=%q\n' "$_plan_root" +printf 'TMP_ROOT=%q\n' "$_tmp_root" diff --git a/test/gstack-paths.test.ts b/test/gstack-paths.test.ts index 42c13c3acd..628f599194 100644 --- a/test/gstack-paths.test.ts +++ b/test/gstack-paths.test.ts @@ -104,6 +104,57 @@ describe('gstack-paths', () => { expect(got).toHaveProperty('TMP_ROOT'); }); + // Regression: values must survive `eval "$(gstack-paths)"`, which is the + // documented calling convention. A bare `echo` emits an unquoted RHS, so eval + // re-parses it: backslashes become escapes and spaces become word separators. + // On Windows $TMP is always a backslash path, so every skill that then runs + // mktemp "$TMP_ROOT/..." fails and the bash block dies before doing any work. + // These run identically on POSIX — the values are just strings. + function evalRoundTrip(env: Record, varName: string): string { + const result = spawnSync( + 'bash', + ['-c', `eval "$(bash "$1")"; printf '%s' "\${${varName}}"`, 'sh', BIN], + { + env: { PATH: process.env.PATH, USERPROFILE: '', ...env } as Record, + encoding: 'utf-8', + }, + ); + if (result.status !== 0) { + throw new Error(`eval round-trip failed (status ${result.status}): ${result.stderr}`); + } + return result.stdout; + } + + // Values are POSIX-shaped on purpose: MSYS/Git Bash rewrites `C:\...` env + // values to `/c/...` before bash sees them, so a literal Windows path would + // assert the translation layer rather than the quoting. A backslash is a + // backslash to eval either way, which is the behavior under test. + test('eval round-trip preserves backslashes (#2374)', () => { + // Skip on Windows: MSYS also rewrites backslashes to forward slashes in + // env values, so a literal backslash cannot be injected through the + // environment on a Git Bash runner. The escape-eating this guards against + // is pure eval semantics, so exercising it on Linux/macOS CI is sufficient + // — same reasoning as the HOME-unset skips above. + if (process.platform === 'win32') return; + const backslashed = '/tmp/back\\slash/dir'; + expect(evalRoundTrip({ TMPDIR: backslashed, HOME: '/h' }, 'TMP_ROOT')).toBe(backslashed); + }); + + test('eval round-trip preserves spaces (#2374)', () => { + // Bare echo made eval word-split this, leaving the variable empty and + // emitting `: command not found`. + const spaced = '/tmp/two words/dir'; + expect(evalRoundTrip({ TMPDIR: spaced, HOME: '/h' }, 'TMP_ROOT')).toBe(spaced); + }); + + test('eval round-trip preserves quotes, and leaves plain paths alone (#2374)', () => { + expect(evalRoundTrip({ TMPDIR: "/tmp/o'brien", HOME: '/h' }, 'TMP_ROOT')).toBe("/tmp/o'brien"); + expect(evalRoundTrip({ GSTACK_HOME: '/tmp/state root' }, 'GSTACK_STATE_ROOT')).toBe( + '/tmp/state root', + ); + expect(evalRoundTrip({ HOME: '/tmp/myhome' }, 'PLAN_ROOT')).toBe('/tmp/myhome/.claude/plans'); + }); + test('output is shell-evalable: only KEY=VALUE lines, no extra prose', () => { const result = spawnSync('bash', [BIN], { env: { PATH: process.env.PATH, USERPROFILE: '', HOME: '/tmp/h' } as Record,