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), the first release with the check. Also reproduced on next at 030e7ca20 and on #5309's head be53da14a. Every line cited below is read at that next commit.
Runtime
Claude Code
Operating System
Linux (WSL2)
Node.js Version
v24.21.0
Shell
GNU bash 5.2.21, GNU coreutils timeout, env and nice
Installation Method
Updated from a previous version
What happened?
The debugger-cli-spawn check added for #5214 allows claude --agent gsd-debugger when a wrapper in front of it is given a flag: timeout -k 5 10, timeout -s KILL 10, env -i, env -u HOME, nice -n 5, sudo -u me and sudo -E all get through, while the bare command, timeout 10, env FOO=1 and plain sudo are denied.
findDebuggerCliSpawn finds the command word with resolveCommand, then unwraps timeout, and gives up unless the result is claude:
gsd-core/hooks/gsd-secret-read-guard.js
Lines 1137 to 1138 in 030e7ca
| const first = resolveCommand(words); | |
| const cmd = first && unwrapTimeout(first); |
gsd-core/hooks/gsd-secret-read-guard.js
Line 1159 in 030e7ca
| if (!CLAUDE_BINARY_RE.test(base)) continue; |
resolveCommand skips a known prefix (sudo, env, command, nice, nohup, time, doas) and env's VAR=x words, but not the prefix's own flags. In env -i claude … the command word comes out as -i:
gsd-core/hooks/gsd-secret-read-guard.js
Line 152 in 030e7ca
| const CMD_PREFIXES = new Set(['sudo', 'env', 'command', 'nice', 'nohup', 'time', 'doas']); |
gsd-core/hooks/gsd-secret-read-guard.js
Lines 794 to 806 in 030e7ca
| function resolveCommand(words) { | |
| let idx = 0; | |
| while (idx < words.length && ASSIGNMENT_RE.test(words[idx].text)) idx++; | |
| while (idx < words.length) { | |
| const base = lastSegment(words[idx].text).toLowerCase(); | |
| if (!CMD_PREFIXES.has(base)) break; | |
| idx++; | |
| if (base === 'env') { | |
| while (idx < words.length && ASSIGNMENT_RE.test(words[idx].text)) idx++; | |
| } | |
| } | |
| if (idx >= words.length) return null; | |
| return { base: lastSegment(words[idx].text).toLowerCase(), operands: words.slice(idx + 1) }; |
function resolveCommand(words) {
let idx = 0;
while (idx < words.length && ASSIGNMENT_RE.test(words[idx].text)) idx++;
while (idx < words.length) {
const base = lastSegment(words[idx].text).toLowerCase();
if (!CMD_PREFIXES.has(base)) break;
idx++;
if (base === 'env') {
while (idx < words.length && ASSIGNMENT_RE.test(words[idx].text)) idx++;
}
}unwrapTimeout skips each word that starts with - and then takes the next word as the duration. A flag that takes a separate value leaves that value to be read as the duration, so in timeout -k 5 10 claude … it takes 5 as the duration and 10 as the command:
gsd-core/hooks/gsd-secret-read-guard.js
Lines 1093 to 1105 in 030e7ca
| function unwrapTimeout(cmd) { | |
| let { base, operands } = cmd; | |
| while (base === 'timeout') { | |
| let k = 0; | |
| while (k < operands.length && operands[k].text.startsWith('-')) k++; | |
| k++; // the DURATION operand | |
| const rest = resolveCommand(operands.slice(k)); | |
| if (!rest) return null; | |
| ({ base, operands } = rest); | |
| } | |
| return { base, operands }; | |
| } | |
The secret-read scan in the same file denies the same wrappers around cat .env (env -i cat .env, timeout -k 5 10 cat .env, sudo -u me cat .env all exit 2), because it checks operands whatever the command word resolves to. The debugger check needs the command word itself.
Three of these shapes came up in the second review round of #5309 and were offered as an issue there; the review on #5309 asked for it. #5309 does not change this check's wrapper parsing.
What did you expect?
Each command below is denied as debugger-cli-spawn, as claude --agent gsd-debugger -p x is. Every non-sudo wrapper in the list does start claude: the repro runs each one with a stub claude first on PATH that creates a flag file. sudo -u USER cmd and sudo -E cmd run cmd per sudo(8); they were not launched here because sudo needs a password on this machine.
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 payloads are synthetic: the hook never executes the command. --launch also runs each non-sudo row under bash with the stub claude.
cat > /tmp/dbg-wrap.cjs <<'EOF'
const { spawnSync } = require('node:child_process');
const fs = require('node:fs'), os = require('node:os'), path = require('node:path');
const C = 'claude --agent gsd-debugger -p x';
const rows = [
['bare (control)', C],
['timeout 10 (control)', `timeout 10 ${C}`],
['timeout --kill-after=5 10 (control)', `timeout --kill-after=5 10 ${C}`],
['env FOO=1 (control)', `env FOO=1 ${C}`],
['sudo (control)', `sudo ${C}`],
['timeout -k 5 10', `timeout -k 5 10 ${C}`],
['timeout -s KILL 10', `timeout -s KILL 10 ${C}`],
['env -i PATH="$PATH"', `env -i PATH="$PATH" ${C}`],
['env -u HOME', `env -u HOME ${C}`],
['nice -n 5', `nice -n 5 ${C}`],
['sudo -u me', `sudo -u me ${C}`],
['sudo -E', `sudo -E ${C}`],
['secret-read: env -i cat .env (control)', 'env -i cat .env'],
];
const [guard, launch] = process.argv.slice(2);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dbg-'));
const flag = path.join(dir, 'RAN.flag');
// The flag path is baked in: `env -i` would clear a variable carrying it.
fs.writeFileSync(path.join(dir, 'claude'), `#!/bin/sh\n: > '${flag}'\n`, { mode: 0o755 });
for (const [name, command] of rows) {
const hook = spawnSync('node', [guard], { input: JSON.stringify({ tool_name: 'Bash', tool_input: { command } }) });
let ran = '';
if (launch && !command.startsWith('sudo') && command.includes('claude')) {
fs.rmSync(flag, { force: true });
spawnSync('bash', ['-c', command], { env: { ...process.env, PATH: `${dir}:${process.env.PATH}` } });
ran = ` launches=${fs.existsSync(flag)}`;
}
console.log(`${name.padEnd(40)} hook-exit=${hook.status}${ran}`);
}
EOF
node /tmp/dbg-wrap.cjs hooks/gsd-secret-read-guard.js --launchError output / logs
Observed on next 030e7ca20; the v1.17.0 hook and #5309's head give the same seven hook-exit=0 lines:
bare (control) hook-exit=2 launches=true
timeout 10 (control) hook-exit=2 launches=true
timeout --kill-after=5 10 (control) hook-exit=2 launches=true
env FOO=1 (control) hook-exit=2 launches=true
sudo (control) hook-exit=2
timeout -k 5 10 hook-exit=0 launches=true
timeout -s KILL 10 hook-exit=0 launches=true
env -i PATH="$PATH" hook-exit=0 launches=true
env -u HOME hook-exit=0 launches=true
nice -n 5 hook-exit=0 launches=true
sudo -u me hook-exit=0
sudo -E hook-exit=0
secret-read: env -i cat .env (control) hook-exit=2
hook-exit=2 on the debugger rows is the deny Debugger spawn guard: this Bash command launches the debugger as a CLI process ('--agent gsd-debugger').
GSD Configuration
Default. No configuration is involved: the check 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
Minor — Cosmetic or edge case
Workaround (if any)
None needed day to day: the 1.15.0 run behind #5214 used the plain spelling, which the check catches. timeout -k and env -i are, though, what an agent writes when it wants a bounded or clean run.
Additional context
Scope. This does not ask the check to stop a determined adversary or to model every launcher. It covers the prefixes the hook already knows, plus timeout, when they are given a flag. Wrappers the hook does not know at all (stdbuf, setsid, xargs, watch) are also allowed today; which launchers the check should model is a wider question, not this issue.
Suggested fix. In the debugger check, skip a known prefix's flags before taking the command word, with a table of which flags take a value: sudo / doas (-u, -g, -h, -p, -C, -D, -r, -t, -U), env (-u, -C, -S), nice (-n), timeout (-k, -s), and any other word starting with - as a flag with no value. A simpler fallback that also errs toward deny: after a known prefix, if the resolved word starts with -, take the first later word that matches CLAUDE_BINARY_RE.
Fixing it in resolveCommand instead would also change the secret-read scan, which calls it too:
gsd-core/hooks/gsd-secret-read-guard.js
Line 1020 in 030e7ca
| const cmd = resolveCommand(words); |
Today env -i ls .env is denied there (exit 2), while ls .env and env ls .env are allowed (exit 0) as existence checks. A flag-aware resolveCommand would allow it as well. That is the consistent verdict, but it changes a second check, so it needs its own rows in that check's tests.
Tests to add, in the gsd-debugger CLI spawn (#5214) block of tests/gsd-secret-read-guard.test.cjs: deny each of the seven hook-exit=0 rows; control, allow: timeout -k 5 10 claude --agent gsd-planner -p x.
Related, not bundled. #5441 is the same check reading $'gsd-debugger', a decoding gap rather than a command-word one.
Privacy Checklist
- I have reviewed all pasted output for PII (usernames, paths, API keys) and redacted where necessary
Activity
github-actions commentedon Oct 18, 2026
Possible duplicate detected. This issue may already be reported:
$'…'—claude --agent $'gsd-debugger' -p xis allowed #5441 — bug(hooks): the debugger-cli-spawn check (bug(debug): the #4395 blocking-spawn fix is present but not honored — session-manager still backgrounds its gsd-debugger on 1.15.0, the checkpoint never advances #5214) does not decode$'…'—claude --agent $'gsd-debugger' -p xis allowed (similarity 65%)If this is not a duplicate, react with 👎 on this comment to veto and keep the issue open. If no response is received within 24 hours, this issue may be closed as a duplicate.
trek-e commentedon Oct 18, 2026
Not a duplicate of #5441. #5441 is about the
--agentoperand not being decoded for$'…'; this issue is about wrapper flags (env -i,timeout -k 5 10,sudo -u,nice -n) making the command word resolve wrongly. Different code path and different fix, so fixing #5441 does not close this. Vetoed the bot's duplicate guess.trek-e commentedon Oct 18, 2026
Diagnosis
Reproduced: Yes, on
nextat 72730d8. Exit 0 (allowed) fortimeout -k 5 10,timeout -s KILL 10,env -i,env -u HOME,nice -n 5,sudo -u meandsudo -Ebeforeclaude --agent gsd-debugger -p x; bare,timeout 10,env FOO=1andsudoexit 2. Controltimeout -k 5 10 claude --agent gsd-plannerexits 0 (correct). Secret-read scan:env -i ls .envexits 2,ls .envexits 0.Root cause:
hooks/gsd-secret-read-guard.js:777-790(resolveCommand) skips prefix words but not the prefix's own flags, soenv -i clauderesolves to base-i.hooks/gsd-secret-read-guard.js:1035-1046(unwrapTimeout) skips-words then one DURATION word, so-k 5 10takes5as the duration and10as the command.findDebuggerCliSpawn(:1079-1101) thencontinues because the base is notclaude.Introduced by: 97dc549 (fix #5214), which added the check on top of
CMD_PREFIXES/resolveCommandfrom 77e2472 (#4221). Not deliberate: no header comment, changeset,.out-of-scope/entry or doc excludes it. Not fixed onnextor in an open PR (#5309 and #5285 do not touch it).Blast radius:
resolveCommandis also called byfindSecretRead,reconstructedScript,scanXargsPipeandunwrapTimeout; a flag-awareresolveCommandchanges secret-read verdicts (env -i ls .envis denied today).unwrapTimeoutis used only by the debugger check.Coupled: #5441 (
$'...'decoding of the--agentvalue) is the same check but a separate defect.Regression test: in the
gsd-debugger CLI spawn (#5214)block oftests/gsd-secret-read-guard.test.cjs, deny each of the seven shapes above; allowtimeout -k 5 10 claude --agent gsd-planner -p x; keep existing controls. IfresolveCommanditself changes, add rows to the secret-read tests.Agent Brief
Category: bug
Summary: The gsd-debugger CLI spawn check misses
claude --agent gsd-debuggerwhen a known wrapper before it is given flags.Current behavior:
timeout -k 5 10,timeout -s KILL 10,env -i,env -u HOME,nice -n 5,sudo -u meandsudo -Ein front of the launch are allowed, because the wrapper's flag (or its value) is taken as the command word.Desired behavior: The same denial as the bare command, for every wrapper the check already knows (sudo, doas, env, nice, nohup, time, command, timeout) when given flags, including flags that take a separate value. A non-debugger agent behind the same wrappers stays allowed.
Key interfaces:
-k,-s) before the duration.Acceptance criteria:
timeout -k 5 10 claude --agent gsd-planner -p xand the existing controls keep their verdicts.env -i ls .env).Out of scope:
stdbuf,setsid,xargs,watch).$'...'decoding of the--agentvalue (bug(hooks): the debugger-cli-spawn check (#5214) does not decode$'…'—claude --agent $'gsd-debugger' -p xis allowed #5441).chore(open-gsd#5514): set changeset fragment pr to 5649
fix(open-gsd#5514): read the command inside an env -S string in the B…
fix(open-gsd#5514): read the command inside an env -S string in the B…
fix(open-gsd#5514): read an unset ${NAME} in an env -S string as empty
fix(open-gsd#5514): split env -S strings as BSD/macOS and uutils env do
fix(open-gsd#5514): strip trailing separators in lastSegment with a loop
fix(open-gsd#5514): read the command as before open-gsd#5514 first, a…
test(open-gsd#5514): put the single-quote ${NAME} rows in shell singl…