From 40071a42375d873800f5ba524d8f123b11115f3d Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 6 Nov 2026 22:35:20 -0800 Subject: [PATCH] fix(capy-install): read project pins with an isolated Bun (GST-1747) (#4115) * fix(capy-install): read project pins with an isolated Bun (GST-1747) bin/gstack-capy-install runs bin/gstack-runtime-pins.ts from the caller's project directory. Its three calls (read_pins, pins_accept, node_accept) now pass --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}", so the project's bunfig.toml and .env do not load into the installer. test/bun-entry-isolation.test.ts also checks Bun runners held in a variable: any variable at a command position launching a TypeScript file, and any Bun-named variable running -e or a script. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(capy-install): note that the pins parser runs with an isolated Bun (GST-1747) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-capy-install | 7 +++-- .../capy-gst-1747-installer-bun-isolation.md | 4 +++ docs/capy-install.md | 4 ++- test/bun-entry-isolation.test.ts | 29 +++++++++++++++++-- test/gstack-capy-install.test.ts | 19 ++++++++++++ 5 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 changelog.d/capy-gst-1747-installer-bun-isolation.md diff --git a/bin/gstack-capy-install b/bin/gstack-capy-install index b194527da..643577a9a 100755 --- a/bin/gstack-capy-install +++ b/bin/gstack-capy-install @@ -52,6 +52,7 @@ # Exit: 0 ok, 1 a component failed, 2 usage, 3 preflight stopped before ./setup. set -u BASH_COMPAT=50 +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac SELF="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="${GSTACK_CAPY_ROOT:-$SELF}" @@ -190,7 +191,7 @@ PINS_OUT="" PINS_MODE=none RUNNER="" PIN="" PIN_SRC="" PIN_EXACT="" PIN_ALTS="" read_pins() { RUNNER="$1" PINS_OUT="" PINS_MODE=none PIN="" PIN_SRC="" PIN_EXACT="" PIN_ALTS="" [ -n "$PROJECT" ] && [ "$PROFILE" -eq 0 ] || return 0 - [ -n "$RUNNER" ] && [ -f "$ROOT/bin/gstack-runtime-pins.ts" ] && PINS_OUT=$("$RUNNER" "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT" 2>/dev/null) + [ -n "$RUNNER" ] && [ -f "$ROOT/bin/gstack-runtime-pins.ts" ] && PINS_OUT=$("$RUNNER" --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT" 2>/dev/null) if printf '%s\n' "$PINS_OUT" | grep -q '^PINS_VERDICT: '; then PINS_MODE=parser _exact=$(printf '%s\n' "$PINS_OUT" | sed -n 's/^PIN_EXACT_REQUIRED: bun \([^ ]*\) \([^ ]*\)$/\1 \2/p') @@ -229,7 +230,7 @@ read_pins() { # pins_accept VERSION — the resolver's eligibility hook: VERSION satisfies every project pin. pins_accept() { case "$PINS_MODE" in - parser) "$RUNNER" "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT" --bun "$1" 2>/dev/null | grep -Eq '^PINS_VERDICT: (pass|none)$' ;; + parser) "$RUNNER" --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT" --bun "$1" 2>/dev/null | grep -Eq '^PINS_VERDICT: (pass|none)$' ;; jq) case "$PIN" in '>='*) _gstack_bun_below "$1" "${PIN#>=}"; [ $? -eq 1 ] ;; [0-9]*.[0-9]*.[0-9]*) [ "$1" = "$PIN" ] ;; @@ -352,7 +353,7 @@ fi # satisfies the pins, and never installs. NODE_STORE=$(gstack_node_store "$GSTACK_STATE_ROOT") NODE_BIN="" NODE_VER="" NODE_RULE="" NODE_WANT="" -node_accept() { ! "$RUNNER" "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT" --node "$1" 2>/dev/null | grep -q '^PIN_FAIL: node '; } +node_accept() { ! "$RUNNER" --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT" --node "$1" 2>/dev/null | grep -q '^PIN_FAIL: node '; } node_pins_text() { printf '%s\n' "$PINS_OUT" | sed -n 's/^PIN_SUMMARY: //p' | grep '^node' | tr '\n' ';' | sed 's/;$//; s/;/; /g'; } if [ "$PINS_MODE" = parser ] && printf '%s\n' "$PINS_OUT" | grep -q '^PIN: node '; then _nreq=$(printf '%s\n' "$PINS_OUT" | sed -n 's/^PIN_EXACT_REQUIRED: node \([^ ]*\) \([^ ]*\)$/\1 \2/p') diff --git a/changelog.d/capy-gst-1747-installer-bun-isolation.md b/changelog.d/capy-gst-1747-installer-bun-isolation.md new file mode 100644 index 000000000..2720a7323 --- /dev/null +++ b/changelog.d/capy-gst-1747-installer-bun-isolation.md @@ -0,0 +1,4 @@ +--- +important: security +--- +- **The Capy installer reads project pins with an isolated Bun.** `bin/gstack-capy-install` runs `bin/gstack-runtime-pins.ts` from the caller's project directory to read its Bun and Node pins. Those three calls now pass `--no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}"` like every other Bun gstack starts, so a project's `bunfig.toml` and `.env` no longer load into the installer. `test/bun-entry-isolation.test.ts` now also checks Bun runners held in a variable (`"$RUNNER" x.ts`, and any variable whose name says Bun running `-e` or a script) in shipped shell files. (GST-1747) diff --git a/docs/capy-install.md b/docs/capy-install.md index 75153f896..6b915f45c 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -295,7 +295,9 @@ uses it to refuse a base merge under an open run runs; the pins, the doctor and the install verdict all use the resolved directory) with the doctor's own parser (`bin/gstack-runtime-pins.ts`: engines, packageManager, `.tool-versions`, `.bun-version` and CI `bun-version:` lanes - for this platform). CI exact pins for this platform are alternatives: a + for this platform), run with an isolated Bun (`--no-env-file + --config="${GSTACK_BUN_CONFIG:-/dev/null}"`) so the project's `bunfig.toml` + and `.env` never load into the installer. CI exact pins for this platform are alternatives: a repository may test several Bun versions (gstack's own CI pins 1.4.2 and runs one job on 1.4.4), so a Bun equal to any one of them satisfies CI; CI ranges and every non-CI pin stay required. One resolver picks the Bun (`gstack_bun_resolve` in diff --git a/test/bun-entry-isolation.test.ts b/test/bun-entry-isolation.test.ts index da0bbe85b..977ba0912 100644 --- a/test/bun-entry-isolation.test.ts +++ b/test/bun-entry-isolation.test.ts @@ -43,11 +43,16 @@ function firstLine(file: string): string { } finally { fs.closeSync(fd); } } -const BUN_WORD = /(^|[\s;&|({`"']|\$\()(bun|"\$\{?BUN(?:_[A-Z]+)?\}?"|\$\{?BUN(?:_[A-Z]+)?\}?)(?=[ \t])/g; +const BUN_WORD = /(^|[\s;&|({`"']|\$\()(bun|"\$\{?\w*(?:BUN|[Bb]un)\w*\}?"|\$\{?\w*(?:BUN|[Bb]un)\w*\}?)(?=[ \t])/g; +/** Any variable at a command position (`"$RUNNER" x.ts`, `OUT=$("$RUNNER" ...)`, `case) "$RUNNER" ...`); it is a Bun when it launches a TypeScript file. */ +const VAR_COMMAND = /(^\s*|[;&|({)`!]\s*|\$\(\s*|\b(?:then|do|else|exec)\s+)("\$\{?[A-Za-z_]\w*\}?"|\$\{?[A-Za-z_]\w*\}?)(?=[ \t])/g; + +const afterFlags = (rest: string) => rest.replace(/^\s+/, '').replace(/^(?:--(?:no-env-file|cwd[= ]\S+|config(?:=\S*|\s+\S+))\s+)*/, ''); +const runsTypeScript = (rest: string) => /^(?:run\s+)?["']?[^\s"'|;&)]*\.(?:ts|tsx|mts)(?:["'\s)|;&\\`]|$)/.test(afterFlags(rest)); /** Whether the text after a `bun` word starts a program: -e/-p, a script path, `run ` or a bin path. A package script (`bun run build`) is not one. */ function runsCode(rest: string): boolean { - const r = rest.replace(/^\s+/, '').replace(/^(?:--(?:no-env-file|cwd[= ]\S+|config(?:=\S*|\s+\S+))\s+)*/, ''); + const r = afterFlags(rest); return /^(?:run\s+)?(?:-e|-p|--eval|--print)(?:\s|=|$)/.test(r) || /^(?:run\s+)?["']?[^\s"'|;&)]*\.(?:ts|js|mjs|cjs|tsx)(?:["'\s)|;&\\`]|$)/.test(r) || /^run\s+["']?\$\{?[A-Za-z_]/.test(r) @@ -64,6 +69,12 @@ function unisolatedBunCalls(line: string, flags = FLAGS): string[] { const rest = line.slice(at + m[2]!.length); if (!rest.startsWith(` ${flags}`) && runsCode(rest)) out.push(line.slice(at, at + 80)); } + for (const m of line.matchAll(VAR_COMMAND)) { + const at = m.index! + m[1]!.length; + if (new RegExp(BUN_WORD.source).test(m[2]!)) continue; + const rest = line.slice(at + m[2]!.length); + if (!rest.startsWith(` ${flags}`) && runsTypeScript(rest)) out.push(line.slice(at, at + 80)); + } return out; } @@ -126,6 +137,12 @@ describe('GSTA-56: gstack never takes configuration from the audited repository' '"$BUN" -e \'1\'', 'bun --no-env-file run "$X/bin/gstack-design-detect.ts"', 'running=$(bun -p process.execPath)', + 'PINS_OUT=$("$RUNNER" "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT" 2>/dev/null)', + ' parser) "$RUNNER" "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT" --bun "$1" | grep -q x ;;', + 'node_accept() { ! "$RUNNER" "$ROOT/bin/gstack-runtime-pins.ts" --node "$1"; }', + '"$RUNNER" run "$ROOT/bin/gstack-runtime-pins.ts"', + 'v=$("$REC_BUN" -e "console.log(1)")', + '"$_bun" run "$X/bin/gstack-design-detect"', ]) expect(unisolatedBunCalls(bad)).not.toEqual([]); for (const ok of [ `bun ${FLAGS} run ~/.claude/skills/gstack/bin/gstack-render.ts x.html`, @@ -136,6 +153,13 @@ describe('GSTA-56: gstack never takes configuration from the audited repository' '# bun run scripts/x.ts in a comment', 'say "installing bun $WANT from its release archive"', 'ln -sf bun "$HOME/.bun/bin/bunx"', + `PINS_OUT=$("$RUNNER" ${FLAGS} "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT")`, + '[ -f "$ROOT/bin/gstack-runtime-pins.ts" ] && echo yes', + 'cp "$SRC" "$DEST/pins.ts"', + 'echo "$X" a.ts', + '"$NODE" -e "console.log(1)"', + '_got=$("$RUNNER" --version 2>/dev/null | head -1)', + 'out=$("$GSTACK_BUN_CONFIG")', ]) expect(unisolatedBunCalls(ok)).toEqual([]); }); @@ -150,6 +174,7 @@ describe('GSTA-56: gstack never takes configuration from the audited repository' `printf x | bun ${SHELL_FLAGS} -e "console.log(1)"`, `exec bun ${SHELL_FLAGS} "$HERE/question-log-hook.ts"`, `probe_cmd="bun ${SHELL_FLAGS} --eval 'import x from \\"y\\"'"`, + `PINS_OUT=$("$RUNNER" ${SHELL_FLAGS} "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT")`, ]) expect(unisolatedBunCalls(ok, SHELL_FLAGS)).toEqual([]); }); diff --git a/test/gstack-capy-install.test.ts b/test/gstack-capy-install.test.ts index 7c2f8eca9..2fbdb0034 100644 --- a/test/gstack-capy-install.test.ts +++ b/test/gstack-capy-install.test.ts @@ -1372,6 +1372,25 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the project expect(JSON.parse(fs.readFileSync(onPath.stdout.match(/GSTACK_INSTALL_VERDICT=(\S+)/)![1], 'utf8'))).toMatchObject({ node_path: path.join(f.stub, 'node'), node_rule: 'PATH', launch_path: '' }); }); + test('the pins parser ignores the project\'s bunfig.toml: install and --check read the Bun and Node pins from the project directory without running its preload', () => { + const f = fixture(); + withPins(f); + realBun(path.join(f.stub, 'bun'), '1.4.2'); + fakeNode(path.join(f.stub, 'node'), '24.21.0'); + const nvmNode = path.join(f.home, '.nvm/versions/node/v24.18.0/bin/node'); + fakeNode(nvmNode, '24.18.0'); + const project = nodeProject(f, '24.18.0'); + const marker = path.join(f.base, 'preload-ran.log'); + write(path.join(project, 'bunfig.toml'), 'preload = ["./preload.ts"]\n'); + write(path.join(project, 'preload.ts'), `require('fs').appendFileSync(${JSON.stringify(marker)}, process.argv.slice(2).join(' ') + '\\n');\n`); + const r = run(f, ['--for', 'review'], {}, project); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(r.stdout).toContain(`node 24.18.0 at ${nvmNode} (nvm; satisfies the project's Node pins`); + const c = run(f, ['--check', '--for', 'review'], {}, project); + expect(c.status, c.stdout + c.stderr).toBe(0); + expect(fs.existsSync(marker) ? fs.readFileSync(marker, 'utf8') : '').toBe(''); + }); + test('no installed Node satisfies the pin: --check installs nothing; install downloads it into the runtime store checked against SHASUMS256.txt; a mismatch installs nothing', () => { const f = fixture(); withPins(f);