Repository navigation
Description
Pre-submission checklist
- I have searched existing issues and this bug has not already been reported
GSD Version
1.17.0 (tag commit 962c1192e). Also reproduced on next at 030e7ca20; hooks/gsd-secret-read-guard.js is unchanged from there to next de4927d2a. Every line cited below is read at 030e7ca20.
Runtime
Claude Code
Operating System
Linux (WSL2)
Node.js Version
v24.21.0
Shell
GNU bash 5.3.20 (the official bash:5.3 container image), with the host's bash 5.2.21 as the contrast
Installation Method
Updated from a previous version
What happened?
The secret-read guard never scans the command inside a bash 5.3 function substitution, so echo ${ cat .env; } and echo ${| cat .env; } are allowed while echo $(cat .env) is denied. bash 5.3 runs the read.
bash 5.3 added two command substitutions that run in the current shell: ${ cmd; } (its value is the command's output) and ${| cmd; } (its value is $REPLY). Both work unquoted and inside double quotes.
The guard's tokenizer treats ${ as a parameter expansion. At top level it copies everything up to the next } into the word as text:
gsd-core/hooks/gsd-secret-read-guard.js
Lines 628 to 634 in 030e7ca
| if (ch === '$' && str[i + 1] === '{') { | |
| hasWord = true; | |
| const j = str.indexOf('}', i); | |
| const e = j === -1 ? str.length - 1 : j; | |
| buf += str.slice(i, e + 1); | |
| i = e + 1; | |
| continue; |
if (ch === '$' && str[i + 1] === '{') {
hasWord = true;
const j = str.indexOf('}', i);
const e = j === -1 ? str.length - 1 : j;
buf += str.slice(i, e + 1);
i = e + 1;
continue;
}Inside double quotes only $( and backticks are recursed:
gsd-core/hooks/gsd-secret-read-guard.js
Lines 592 to 597 in 030e7ca
| if (c === '$' && str[i + 1] === '(') { | |
| const e = findParenClose(str, i + 2); | |
| nested.push(str.slice(i + 2, e)); | |
| i = e + 1; | |
| continue; | |
| } |
$( itself pushes its body onto nested for a full scan, which is why echo $(cat .env) is denied:
gsd-core/hooks/gsd-secret-read-guard.js
Lines 620 to 626 in 030e7ca
| if (ch === '$' && str[i + 1] === '(') { | |
| hasWord = true; | |
| const e = findParenClose(str, i + 2); | |
| nested.push(str.slice(i + 2, e)); | |
| i = e + 1; | |
| continue; | |
| } |
This came out of the review round on #5309: its span reader keeps a list of constructs that run a command and constructs that are opaque text, and ${ … } is a member of the first set that bash 5.3 added after next's tokenizer was written.
What did you expect?
Each of these is denied, as echo $(cat .env) is: echo ${ cat .env; }, echo "${ cat .env; }", echo ${| cat .env; REPLY=x; }, echo "$(echo ${ cat .env; })". Under bash 5.3.20 all four run the read. Under bash 5.2 they are a "bad substitution" and nothing runs.
Steps to reproduce
Run from the release tree (git clone --depth 1 --branch v1.17.0 https://github.com/open-gsd/gsd-core && cd gsd-core), so the guard's hooks/lib/ siblings load. The script feeds each command to the hook through the stdin contract, then writes each one as a script with : > RAN.flag in place of cat .env; the read counts as run only if the flag file exists. A bash 5.3 runs the scripts.
cat > /tmp/funsub.cjs <<'EOF'
const { spawnSync } = require('node:child_process');
const fs = require('node:fs'), path = require('node:path');
const rows = [
['funsub-unquoted', 'echo ${ cat .env; }'],
['funsub-in-dquotes', 'echo "${ cat .env; }"'],
['valsub-reply', 'echo ${| cat .env; REPLY=x; }'],
['funsub-in-cmdsub', 'echo "$(echo ${ cat .env; })"'],
['control-cmdsub', 'echo $(cat .env)'],
['control-param-expansion', 'echo ${HOME}'],
];
const [guard, out] = process.argv.slice(2);
fs.mkdirSync(out, { recursive: true });
for (const [name, command] of rows) {
const hook = spawnSync('node', [guard], { input: JSON.stringify({ tool_name: 'Bash', tool_input: { command } }) });
fs.writeFileSync(path.join(out, name + '.sh'), command.replace('cat .env', ': > RAN.flag') + '\n');
console.log(`${name.padEnd(24)} hook-exit=${hook.status}`);
}
EOF
node /tmp/funsub.cjs hooks/gsd-secret-read-guard.js /tmp/funsub-sh
docker run --rm --network none -v /tmp/funsub-sh:/g:ro bash:5.3 bash -c \
'echo "bash $BASH_VERSION"; for f in /g/*.sh; do d=$(mktemp -d); (cd "$d" && bash "$f" >/dev/null 2>&1; echo "$(basename "$f" .sh) bash-runs-read=$([ -f RAN.flag ] && echo true || echo false)"); done'Error output / logs
Observed on next 030e7ca20; the v1.17.0 hook prints the same:
funsub-unquoted hook-exit=0
funsub-in-dquotes hook-exit=0
valsub-reply hook-exit=0
funsub-in-cmdsub hook-exit=0
control-cmdsub hook-exit=2
control-param-expansion hook-exit=0
bash 5.3.20(1)-release
control-cmdsub bash-runs-read=true
control-param-expansion bash-runs-read=false
funsub-in-cmdsub bash-runs-read=true
funsub-in-dquotes bash-runs-read=true
funsub-unquoted bash-runs-read=true
valsub-reply bash-runs-read=true
hook-exit=2 is the guard's deny (Secret read guard: Bash would read '.env'…).
GSD Configuration
Default. No configuration is involved: the guard runs on every Bash call.
GSD State (if relevant)
N/A
Runtime settings.json (if relevant)
N/A
How often does this happen?
Every time (100% reproducible)
Impact
Moderate — Feature is broken but I have a workaround
Workaround (if any)
None in the guard. It applies wherever the Bash tool's shell is bash 5.3 or later.
Additional context
Scope. This does not ask the guard to stop a determined adversary. It asks that the guard read the bash 5.3 command substitution the way it already reads $( ).
Suggested fix. Read ${ followed by a blank, a newline or | as a command substitution: in the tokenizer at top level, inside double quotes, and in the span finder. That is bash 5.3's rule for where one starts. Scan its body as a nested command up to the } that closes it, which bash reads as a reserved word, so it ends a command list. ${VAR}, ${VAR:-x} and every other parameter expansion start with a name or a special character, never a blank or |, so they stay text.
Under bash 5.2 and earlier these lines are a syntax error, so scanning them as commands everywhere only adds denials for lines that would not run anyway. No version check is needed.
Tests to add, in tests/gsd-secret-read-guard.test.cjs: deny each of the four hook-exit=0 rows above; allow echo ${HOME}, echo ${x:-a b} and echo "${#x}". The debugger-spawn check tokenizes with the same reader, so a row there (echo ${ claude --agent gsd-debugger -p x; }) is worth adding with the fix.
Related, not bundled. #5267 / #5309 (case), #5255 / #5285 (ANSI-C quoting) and #5512 / #5513 / #5515 are all about where a $( ) ends or how a quote is read. This one is a construct that runs a command and is never scanned as one.
Privacy Checklist
- I have reviewed all pasted output for PII (usernames, paths, API keys) and redacted where necessary
Activity
trek-e commentedon Oct 18, 2026
Diagnosis
echo ${ cat .env; },echo "${ cat .env; }",echo ${| cat .env; REPLY=x; },echo "$(echo ${ cat .env; })"andecho ${ claude --agent gsd-debugger -p x; }; controlsecho $(cat .env)exits 2,echo ${HOME}exits 0. Not executed: the bash 5.3 run (local bash is 3.2, no 5.3 available); that half rests on the report and bash 5.3 documentation.hooks/gsd-secret-read-guard.js:611-618(tokenize) copies${up to the next}as opaque text and never queues the body for a nested scan; inside double quotes (:575-587) only$(and backticks recurse;findParenCloselikewise treats${as opaque.${…}handling dates to 77e2472 (enhance(#4221): replace installer Read() deny rules with a managed secret-read guard hook #4236); bash 5.3 later added the construct, so a latent gap, not a regression. Not deliberate: no test, ADR,.out-of-scope/entry or changeset documents it. Not fixed onnextor in an open PR (fix(#5255): read ANSI-C quoting ($'…') in the secret-read guard #5285, fix(#5267): keep a case pattern's paren from closing a substitution in the secret-read guard #5309, fix(#5046): block secret operands bundled into short flags #5129 touch other constructs).tokenize(3 in-file callers, incl. the debugger-spawn check). Other hooks use separate tokenizers.casepattern's)close a$( )—echo "$(case x in x) cat .env;; esac)"is allowed #5267/fix(#5267): keep a case pattern's paren from closing a substitution in the secret-read guard #5309, bug(hooks): secret-read-guard has no model of ANSI-C quoting —echo $'x\'y'; cat .envandcat $'.e\x6ev'are allowed #5255/fix(#5255): read ANSI-C quoting ($'…') in the secret-read guard #5285, bug(hooks): secret-read-guard ends a$( )early at a here-string, an escaped backtick, a quoted heredoc tag or a continued heredoc line, and allows thecat .envafter it #5512/bug(hooks): secret-read-guard allowscat .envafter a$( )whose heredoc tag holds$(…)or ends in a CR, or (bash 3.2) whose heredoc body holds a)#5513/bug(hooks): secret-read-guard drops a heredoc opened in a$( )that closes on the same line —echo $(cat <<EOF) xthen a body with a lone quote hides thecat .envafter it #5515 edit the same tokenizer andfindParenClose; expect textual conflicts.echo ${HOME},echo ${x:-a b},echo "${#x}".Agent Brief
Category: bug
Summary: The secret-read guard does not treat bash 5.3 function substitutions (
${ cmd; },${| cmd; }) as command substitutions, so a secret read inside one is allowed.Current behavior:
${is read as a parameter expansion and its body is never scanned, at top level, inside double quotes, and inside an enclosing$( ). A secret-file read in one is allowed while the same read inside$( )is denied.Desired behavior:
${followed by a blank, newline or|is scanned as a nested command up to the}that closes it, in all three contexts, exactly as$( )is. Ordinary parameter expansions stay opaque text.Key interfaces:
Acceptance criteria:
echo ${ cat .env; },echo "${ cat .env; }",echo ${| cat .env; REPLY=x; },echo "$(echo ${ cat .env; })".${ … }is denied.echo ${HOME},echo ${x:-a b},echo "${#x}".Out of scope: the sibling tokenizer bugs (#5267, #5255, #5512, #5513, #5515, #5523, #5309); a bash-version check; stopping a determined adversary.
chore(open-gsd#5518): set changeset fragment pr to 5724
test(open-gsd#5518): run the hook-budget rows uninstrumented under V8…
test(open-gsd#5518): give the long-input rows a hang backstop, not a …
fix(open-gsd#5518): drop a failed arithmetic attempt's pending heredo…
test(open-gsd#5518): property tests for the function-substitution rea…
fix(open-gsd#5518): keep a failed arithmetic attempt's states apart f…
test(open-gsd#5518): pin the two open-gsd#5523 shapes neither reading…
test(open-gsd#5518): pin a read below a substitution's open heredoc a…
test(open-gsd#5518): assert the close finders' cost as a step count, …
test(open-gsd#5518): pin the brief's must-allow rows, each with a twi…
test(open-gsd#5518): record the bash 5.3 over-deny on the pre-5.3 her…
fix(open-gsd#5518): count the whole Bash scan's steps, so every long-…
fix(open-gsd#5518): pair a parameter expansion's `}` as bash does, in…
Merge upstream next into fix/5518-secret-read-guard-funsub
fix(open-gsd#5518): judge a Bash exemption's read-only line under the…