Skip to content

bug(hooks): the debugger-cli-spawn check (#5214) takes a wrapper's flag as the command word — env -i, timeout -k 5 10, sudo -u me, nice -n 5 in front of claude --agent gsd-debugger are allowed #5514

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), 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:

const first = resolveCommand(words);
const cmd = first && unwrapTimeout(first);

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:

const CMD_PREFIXES = new Set(['sudo', 'env', 'command', 'nice', 'nohup', 'time', 'doas']);

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:

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 --launch

Error 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:

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

  1. github-actions commented on Oct 18, 2026

    @github-actions
    Contributor

    Possible duplicate detected. This issue may already be reported:

    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.

  2. added
    possible-duplicateFlagged by the no-LLM dedupe check as a likely duplicate; reporter must justify or it auto-closes
    on Oct 18, 2026
  3. trek-e commented on Oct 18, 2026

    @trek-e
    Collaborator

    This was generated by AI during triage.

    Not a duplicate of #5441. #5441 is about the --agent operand 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.

  4. removed
    possible-duplicateFlagged by the no-LLM dedupe check as a likely duplicate; reporter must justify or it auto-closes
    on Oct 18, 2026
  5. trek-e commented on Oct 18, 2026

    @trek-e
    Collaborator

    This was generated by AI during triage.

    Diagnosis

    Reproduced: Yes, on next at 72730d8. Exit 0 (allowed) for timeout -k 5 10, timeout -s KILL 10, env -i, env -u HOME, nice -n 5, sudo -u me and sudo -E before claude --agent gsd-debugger -p x; bare, timeout 10, env FOO=1 and sudo exit 2. Control timeout -k 5 10 claude --agent gsd-planner exits 0 (correct). Secret-read scan: env -i ls .env exits 2, ls .env exits 0.

    Root cause: hooks/gsd-secret-read-guard.js:777-790 (resolveCommand) skips prefix words but not the prefix's own flags, so env -i claude resolves to base -i. hooks/gsd-secret-read-guard.js:1035-1046 (unwrapTimeout) skips - words then one DURATION word, so -k 5 10 takes 5 as the duration and 10 as the command. findDebuggerCliSpawn (:1079-1101) then continues because the base is not claude.

    Introduced by: 97dc549 (fix #5214), which added the check on top of CMD_PREFIXES/resolveCommand from 77e2472 (#4221). Not deliberate: no header comment, changeset, .out-of-scope/ entry or doc excludes it. Not fixed on next or in an open PR (#5309 and #5285 do not touch it).

    Blast radius: resolveCommand is also called by findSecretRead, reconstructedScript, scanXargsPipe and unwrapTimeout; a flag-aware resolveCommand changes secret-read verdicts (env -i ls .env is denied today). unwrapTimeout is used only by the debugger check.

    Coupled: #5441 ($'...' decoding of the --agent value) is the same check but a separate defect.

    Regression test: in the gsd-debugger CLI spawn (#5214) block of tests/gsd-secret-read-guard.test.cjs, deny each of the seven shapes above; allow timeout -k 5 10 claude --agent gsd-planner -p x; keep existing controls. If resolveCommand itself changes, add rows to the secret-read tests.

    Agent Brief

    Category: bug

    Summary: The gsd-debugger CLI spawn check misses claude --agent gsd-debugger when 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 me and sudo -E in 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:

    • The command-word resolution used by the debugger spawn check.
    • The timeout unwrapping, which must handle value-taking flags (-k, -s) before the duration.
    • The secret-read scan shares command-word resolution; any change there must keep or deliberately update its verdicts.

    Acceptance criteria:

    • Each of the seven shapes is denied as a debugger CLI spawn.
    • timeout -k 5 10 claude --agent gsd-planner -p x and the existing controls keep their verdicts.
    • If shared resolution changes, secret-read tests cover the changed verdicts (e.g. env -i ls .env).

    Out of scope:

  6. added
    bugSomething isn't working
    and removed
    needs-triageNew issue awaiting maintainer review
    on Oct 18, 2026
  7. added 8 commits that reference this issue on Oct 23, 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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions