Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
6caa205
fix(#5255): read bash ANSI-C quoting in the secret-read guard
0xdhx Oct 10, 2026
646ce23
chore(#5255): set changeset fragment pr to 5285
0xdhx Oct 10, 2026
8ea8fc2
fix(#5255): join a `$` run's line continuations as bash does
0xdhx Oct 12, 2026
71b068d
fix(#5255): end a `${…}` past a `}` inside a `$'…'` span
0xdhx Oct 12, 2026
97e927a
fix(#5255): read dollar-quoting under zsh's decoding as well as bash's
0xdhx Oct 12, 2026
81bc335
test(#5255): pin the length and nesting boundaries of the dollar-quot…
0xdhx Oct 12, 2026
2936665
refactor(#5255): return every Bash block as { code, text }
0xdhx Oct 12, 2026
85b33c8
test(#5255): pin a trailing zsh modifier escape with no byte to modify
0xdhx Oct 13, 2026
4dd07b4
fix(#5255): read bytes that are not UTF-8 in bash's $'…' value as wri…
0xdhx Oct 15, 2026
620fd78
test(#5255): confirm the zsh-only rows in zsh 5.9 and pin the escapes…
0xdhx Oct 15, 2026
6710e6c
docs(#5255): shorten the changeset fragment
0xdhx Oct 15, 2026
f7458a7
fix(#5255): pair a `$` run before `{` and `(` left to right
0xdhx Oct 18, 2026
22b6e6c
fix(#5255): open a `$'…'` span after any `$` run under zsh's reading
0xdhx Oct 18, 2026
2f746ca
test(#5255): pin where the `$( )` scanner ends zsh's span and heredoc…
0xdhx Oct 18, 2026
6e5e2e3
fix(#5255): leave zsh's `$${` to its brace rule, and scan a run befor…
0xdhx Oct 18, 2026
fca158a
fix(#5255): pair a `$` run inside double quotes under zsh's reading
0xdhx Oct 18, 2026
ac36c1c
fix(#5255): pair a `$` run in an unquoted heredoc body under the ANSI…
0xdhx Oct 18, 2026
d8f6fd6
fix(#5255): pair a `$` run inside double quotes under bash's reading too
0xdhx Oct 18, 2026
512e9a4
refactor(#5255): name the scanners' reading parameter `reading`
0xdhx Oct 20, 2026
b4bdb9f
fix(#5255): read a command the way dash does, pairing a `$` run with …
0xdhx Oct 20, 2026
a909ce9
fix(#5255): open a `$(` past a line continuation inside "…" and a her…
0xdhx Oct 20, 2026
f24771f
fix(#5255): deny a command the scan cannot finish instead of failing …
0xdhx Oct 22, 2026
a105376
fix(#5255): bound the scan's total work, denying past it as command-t…
0xdhx Oct 22, 2026
c17fca7
docs(#5255): list the shapes the decoded-value check does not reach, …
0xdhx Oct 22, 2026
27f68e3
docs(#5255): name dash's reading and command-too-complex in the chang…
0xdhx Oct 22, 2026
1b0247b
test(#5255): pin the scan budget's boundary at budget - 1, budget and…
0xdhx Oct 23, 2026
23e2448
docs(#5255): say how the scan budget's reach shrinks with each nested…
0xdhx Oct 23, 2026
a8606b2
fix(#5255): read past the quotes and substitutions inside a ${…}, and…
0xdhx Oct 25, 2026
fd699e4
docs(#5255): say how a ${…} is read under the readings, and what is s…
0xdhx Oct 25, 2026
a56608c
docs(#5255): say command-too-complex means a parser overflow or the w…
0xdhx Oct 25, 2026
5544c22
docs(#5255): say zsh takes only `\\` and `\'` as escapes in a span af…
0xdhx Oct 25, 2026
a08b494
test(#5255): read a heredoc tag that holds a substitution under the d…
0xdhx Oct 27, 2026
9dac999
fix(#5255): charge the dollar-quoting readers for every character the…
0xdhx Oct 27, 2026
f187842
docs(#5255): count #5513's heredoc passes in the scan-budget figures
0xdhx Oct 27, 2026
dc405f7
fix(#5255): cut an operand's trailing separators with a loop, not `/[…
0xdhx Oct 27, 2026
f68ef3f
fix(#5255): let an even `$` run before an unclosed span pass, as the …
0xdhx Nov 5, 2026
81635ea
refactor(#5255): stop reading comments in `$( )`, as the maintainer's…
0xdhx Nov 5, 2026
ded44e1
fix(#5255): read a brace, or a `${` that never closes, as a heredoc tag
0xdhx Nov 5, 2026
0e4c988
fix(#5255): read zsh's arithmetic, in which a quote is not a quote
0xdhx Nov 5, 2026
44b8870
fix(#5255): find a pattern operator and a `${` past dash's line conti…
0xdhx Nov 5, 2026
43d988f
refactor(#5255): name `glob-too-complex` only for an overflow in the …
0xdhx Nov 5, 2026
fe14b84
fix(#5255): surface a bug in a reading, and read a tag under #5513's …
0xdhx Nov 5, 2026
73177db
test(#5255): hold the claim that nothing denied before is allowed aga…
0xdhx Nov 5, 2026
025c4c1
docs(#5255): drop the review history from the code and its tests
0xdhx Nov 5, 2026
b3c5d49
docs(#5255): shorten the hook's user-guide entry and the changeset
0xdhx Nov 5, 2026
37bd5ae
test(#5255): make the tag and frozen-scanner tests fail when their fi…
0xdhx Nov 5, 2026
f8bae7d
fix(#5255): keep a heredoc tag's unclosed `${` linear
0xdhx Nov 5, 2026
61cb559
refactor(#5255): stop opening `$(` past a line continuation, which no…
0xdhx Nov 5, 2026
fc67167
fix(#5255): scan a brace in a heredoc tag the earlier way too, so not…
0xdhx Nov 5, 2026
1e1b382
fix(#5255): report a bug in a reading on stderr, and lose no denial t…
0xdhx Nov 5, 2026
4279f47
fix(#5255): rank a secret read over a word with no value at every depth
0xdhx Nov 5, 2026
827cfe1
fix(#5255): treat `'` in zsh's arithmetic as `"`, and scan what a ski…
0xdhx Nov 5, 2026
58b620c
docs(#5255): name the reader the budget comment means, and what a `((…
0xdhx Nov 5, 2026
dadd987
fix(#5255): run the earlier brace passes for a tag only a nested scan…
0xdhx Nov 5, 2026
a880a9a
fix(#5255): hold a nested word with no value where every scanner ente…
0xdhx Nov 5, 2026
c8b2146
fix(#5255): scan a substitution in a `$[ ]` the `${…}` reader or "…" …
0xdhx Nov 5, 2026
a970f23
docs(#5255): say what the arithmetic gaps and a held word actually cover
0xdhx Nov 5, 2026
18f963b
docs(#5255): list the arithmetic gaps by example, with the shells tha…
0xdhx Nov 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-foxes-quote.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
type: Security
pr: 5285
---
**Shell quoting could hide a secret read from the secret-read guard** — `echo $'x\'y'; cat .env` and `cat $'.e\x6ev'` are now blocked. The guard reads `$'…'` under bash, zsh and dash, judges a word by its decoded value, and closes four shapes it allowed: a brace as a heredoc tag, a quote in zsh's arithmetic, `$[ ]` inside `${…}`, and a line continuation inside `${…}` under dash. Nothing it blocked before is allowed. A command it cannot finish scanning is blocked as `command-too-complex`. (#5255)
2 changes: 1 addition & 1 deletion docs/USER-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,7 @@ GSD generates markdown files that become LLM system prompts. This means any user
- `gsd-prompt-guard.js` — Scans Write/Edit calls to `.planning/` for injection patterns (always active, advisory-only)
- `gsd-workflow-guard.js` — Warns on file edits outside GSD workflow context (opt-in via `hooks.workflow_guard`)
- `gsd-write-guard.js` — Hard-blocks a whole-file `Write` that catastrophically shrinks a curated `.planning/` artifact (`ROADMAP.md`, milestone roadmaps, `STATE.md`) below 40% of its on-disk line count; files under 40 lines are exempt. The check is stateless per Write, comparing each payload against the file's *current* on-disk size — a single-shot collapse (the #973 shape) is blocked, but a sequence of individually-tolerated shrinks that erodes the file across several Writes is not detected. For a legitimate milestone reset or large deletion, bypass once with the single-use sentinel — write the target's path into `.planning/.gsd-allow-shrink` (fresh within 15 minutes; consumed by the allowed write) — or, interactively, with `GSD_ALLOW_PLANNING_SHRINK=1` in the runtime's environment. Scope the guarantee accordingly: this stops accidental and single-shot collapse, and is not a defense against a determined agent — the sentinel is a plain file, so anything with shell access can arm one; what it buys is that the bypass becomes a deliberate, path-bound, single-use and auditable action rather than a sentence to reason past (always active, blocking; #2255, fix 3 of #973)
- `gsd-secret-read-guard.js` — Hard-blocks reads of secret files — `.env`, `.env.<suffix>` and `.secrets`, matched case-insensitively (`.ENV`, `.Secrets`) — through Read (`file_path`), Grep (an explicit `path`, or a `glob` that selects them, judged per brace alternative) and Bash (operands, input redirects, `$( )` / backtick / `<( )` bodies, and `git show <ref>:<path>` shapes). A shell interpreter (`bash`/`sh`/`zsh`/`dash`/`ksh`) has its script scanned however it arrives — `-c '…'`, a `<( )` file operand, a heredoc / here-string, or a pipe from a knowable `echo`/`printf` source (`echo cat .env | bash`) — as do `eval`'s joined operands, a `source`/`.` process-substitution operand, and `find … | xargs cat` pipelines (upstream literal names become the sub-command's read operands). A name is exempt when its **final extension** is `example`, `sample`, `template` or `dist`, so `.env.example` and equally `.env.local.example` / `.env.production.sample` stay readable (they are the templates GSD's own phase prompt reads). Stated cost: the exempt set is that unbounded family, not four fixed names — a real secret is not protected if it is named to end in one of those four extensions. Order matters, and only the last segment counts: `.env.example.local` is a secret and stays blocked. Trailing dots and spaces are stripped before the name is judged, because Windows resolves `.env.`, `.env ` and `.secrets.` to the protected file itself. Existence checks (`[ -f .env ]`, `ls .env*`, `test`, `stat`, `rm`, `touch`, `echo`, …) pass, and so do commands that name a secret without printing it: `git check-ignore`, `git ls-files` and `git rm --cached` on its path, and a one-source `cp`/`mv` whose only secret name is the destination (`cp .env.example .env`); an unlisted option (`-X <file>`, `--pathspec-from-file=`, `-t`, `--backup`, …) keeps the whole command checked (#4856). Not covered, by construction: `$VAR` indirection (`bash -c "$CMD"`), shell globs (`cat .e*`), interpreter one-liners, a piped script from a non-`echo`/`printf` source (`cat gen.sh | bash`, `curl … | sh`), reads inside scripts the agent runs, and a Grep `glob: '*'` reaching a `.env` that is not gitignored — none are statically resolvable by a hook. This replaces the `Read(.env)` / `Read(.env.*)` / `Read(.secrets)` permission deny rules the installer used to write: on Claude Code ≥ 2.1.259 any `Read()` deny rule makes every `cd DIR && grep …` compound prompt for approval even in `auto` mode, while a hook denial is not a permission rule and applies in `auto` and `bypassPermissions` alike (always active, blocking; #4221)
- `gsd-secret-read-guard.js` — Hard-blocks reads of secret files — `.env`, `.env.<suffix>` and `.secrets`, matched case-insensitively (`.ENV`, `.Secrets`) — through Read (`file_path`), Grep (an explicit `path`, or a `glob` that selects them, judged per brace alternative) and Bash (operands, input redirects, `$( )` / backtick / `<( )` bodies, and `git show <ref>:<path>` shapes). A shell interpreter (`bash`/`sh`/`zsh`/`dash`/`ksh`) has its script scanned however it arrives — `-c '…'`, a `<( )` file operand, a heredoc / here-string, or a pipe from a knowable `echo`/`printf` source (`echo cat .env | bash`) — as do `eval`'s joined operands, a `source`/`.` process-substitution operand, and `find … | xargs cat` pipelines (upstream literal names become the sub-command's read operands). Dollar-quoting is read the way the shell reads it: `$'…'` is judged by its decoded value, so `cat $'.e\x6ev'` is a read of `.env`, and a `\'` inside does not close the word. bash, zsh and dash read the same text differently, so a command holding `$'…'`, `$"…"`, `$${`, `$$(`, or a quote with `((` or `$[` is checked under all three readings as well as the plain scan and blocked on any; nothing the plain scan blocks is allowed. A `$'…'` word that never closes or has no single value to check is blocked outright, unless it follows an even run of `$`: `echo $$'x\'; ls` is the PID and a plain quote (#5255). A Bash command longer than 1,048,576 characters (UTF-16 code units, not bytes; a character outside the BMP, such as most emoji, counts as two) is blocked unscanned (`command-too-large`), and so is one nested deep enough to overflow the parser or needing more than eight times that much scanning (`command-too-complex`). The readings multiply that work, so a commit message written as `git commit -m "$(cat <<'EOF' …` with a `$'\t'` in it, or a quote beside a `((`, is blocked from about 420,000 characters (#5255). A name is exempt when its **final extension** is `example`, `sample`, `template` or `dist`, so `.env.example` and equally `.env.local.example` / `.env.production.sample` stay readable (they are the templates GSD's own phase prompt reads). Stated cost: the exempt set is that unbounded family, not four fixed names — a real secret is not protected if it is named to end in one of those four extensions. Order matters, and only the last segment counts: `.env.example.local` is a secret and stays blocked. Trailing dots and spaces are stripped before the name is judged, because Windows resolves `.env.`, `.env ` and `.secrets.` to the protected file itself. Existence checks (`[ -f .env ]`, `ls .env*`, `test`, `stat`, `rm`, `touch`, `echo`, …) pass, and so do commands that name a secret without printing it: `git check-ignore`, `git ls-files` and `git rm --cached` on its path, and a one-source `cp`/`mv` whose only secret name is the destination (`cp .env.example .env`); an unlisted option (`-X <file>`, `--pathspec-from-file=`, `-t`, `--backup`, …) keeps the whole command checked (#4856). Not covered, by construction: `$VAR` indirection (`bash -c "$CMD"`) and `${…}` defaults (`cat ${x:-.env}`, and `echo ${x:-$(cat .env)}` in a command holding none of those forms, #5523), shell globs (`cat .e*`), interpreter one-liners, a piped script from a non-`echo`/`printf` source (`cat gen.sh | bash`, `curl … | sh`), reads inside scripts the agent runs, and a Grep `glob: '*'` reaching a `.env` that is not gitignored — none are statically resolvable by a hook. This replaces the `Read(.env)` / `Read(.env.*)` / `Read(.secrets)` permission deny rules the installer used to write: on Claude Code ≥ 2.1.259 any `Read()` deny rule makes every `cd DIR && grep …` compound prompt for approval even in `auto` mode, while a hook denial is not a permission rule and applies in `auto` and `bypassPermissions` alike (always active, blocking; #4221)

Git configuration supplied through `-c key=value` is also checked for the two command-valued forms in #5045: a shell alias (`alias.*` whose value starts with `!`) and `diff.external`. Their literal command strings are scanned by the same bounded shell analysis, so `git -c alias.x='!cat .env' x` and `git -c diff.external='cat .env' diff` are blocked. Benign scripts, secret-free templates, ordinary config values, and `core.pager` remain subject to the existing checks. Scanning is conservative: every matching value supplied before the Git subcommand is inspected, even if a later setting replaces it or that subcommand does not invoke it. Behind a Git option the guard does not recognize, the subcommand can no longer be located, so every later `-c` value on the command is inspected. This does not resolve config files, values obtained through `--config-env` / `GIT_CONFIG_*`, other command-valued keys (diff drivers, filters, credential helpers, SSH or GPG commands), or scripts hidden behind the existing indirection and nesting limits; those remain documented gaps.

Expand Down
Loading
Loading