Skip to content

bug(hooks): secret-read-guard never scans a bash 5.3 function substitution — echo ${ cat .env; } and echo ${| cat .env; } are allowed, and bash 5.3 runs the read #5518

Open

Description

@0xdhx

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:

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:

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:

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

  1. trek-e commented on Oct 18, 2026

    @trek-e
    Collaborator

    This was generated by AI during triage.

    Diagnosis

    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:

    • The hook's stdin/exit contract (exit 2 = deny) is unchanged.
    • The same command reader feeds the debugger-spawn check, so that check also sees commands inside function substitutions.

    Acceptance criteria:

    • Denied: echo ${ cat .env; }, echo "${ cat .env; }", echo ${| cat .env; REPLY=x; }, echo "$(echo ${ cat .env; })".
    • Debugger spawn inside ${ … } is denied.
    • Allowed: echo ${HOME}, echo ${x:-a b}, echo "${#x}".
    • Regression tests for each row are added in the guard's existing test file and fail before the fix.

    Out of scope: the sibling tokenizer bugs (#5267, #5255, #5512, #5513, #5515, #5523, #5309); a bash-version check; stopping a determined adversary.

  2. added
    bugSomething isn't working
    and removed
    needs-triageNew issue awaiting maintainer review
    on Oct 18, 2026
  3. added 15 commits that reference this issue on Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingconfirmed-bugVerified reproducible bug

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions