Skip to content

fix(hooks): judge unquoted variables as zsh runs them - #149

Open
furedea-agent[bot] wants to merge 9 commits into
mainfrom
fix/judge-unquoted-variables-as-zsh-runs-them
Open

furedea-agent[bot] wants to merge 9 commits into
mainfrom
fix/judge-unquoted-variables-as-zsh-runs-them

Conversation

@furedea-agent

@furedea-agent furedea-agent Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

概要

実行シェルが zsh と確定できるとき,外に届くコマンド(gh など)の引数にあるクォートなしの $TMPDIR / $HOME / $PWD / $USER(${name} を含む)を,クォート付きと同じ「1 語の値(空なら欠落)」として判定する.ADR-0009 の前提「語分割」を実行シェルに依存させる判断は ADR-0022 に記録した.

Closes #148

方針の変遷(Issue #148 の計画コメントの再改訂に合わせた)

  1. 「zsh では $name は 1 語,1 語にならない形を一覧で除く」→ 漏れが review のたびに見つかり収束しなかった
  2. 「同じコマンドで先に代入した名前も通す」→ 配列への追加(path+=x),副作用のある値などの抜けが見つかり続けた
  3. 現在:決まった環境変数 4 つだけ. zsh の変数の規則が細かく,文字列から「確実に 1 語」と言えるのは環境変数に限られる.$J のように同じ行で代入した変数は,"$J" とクォートすれば従来どおり allow.

判定(実行シェルが zsh と確定できる場合のみ)

クォートなしの単純な $name / ${name} を 1 語として扱うのは,name が TMPDIR,HOME,PWD,USER のいずれかで,かつ同じコマンドの中でその名前が展開以外の場所(代入,宣言,read,unset,trap,関数,コマンドの引数としての出現など)に一度も現れないときだけ.それ以外は従来どおり ask.変数だけでできた語は空なら消えるので,その語を除いた形も判定する.

実行シェルの確定方法

  • Claude Code:確定できる. provider なし(または claude)で,CLAUDE_CODE_SHELL,なければ SHELL の basename が zsh.hook は Claude Code の環境を引き継ぐ.
  • Codex,Devin,Hermes,Pi,dsh:確定できていない. 従来どおり ask.
  • Nix の描画値:このリポジトリの hooks.json は固定文字列で埋め込み先がないため,hook 自身の環境を読む方式にした.

Issue の表との差

--body-file $TMPDIR/body.md は allow.--job $J は,代入の有無にかかわらず ask のまま("$J" なら allow).

変更

  • executing_shell.py:実行シェルの確定,4 つの環境変数の判定,空で消える語を除いた形の列挙
  • shell_words.py,shell_syntax.py,command_objections.py,guard_command.py:zsh の既定での語の広がりの保持.$=name / $~name はどのシェルでも ask(独立した不具合の修正),チルダ始まりの語の検査漏れ,エスケープされたバックスラッシュ後の $ の扱いを修正
  • docs/adr/0022_*.md:新判定で書き直し,採らなかった 2 案(一覧で除く方式,同じコマンド内の代入の追跡)と理由を記載.ADR-0009 と ADR-0015 に参照を追記
  • テスト:tests/hooks/test_zsh_unquoted_variables.py

既知の制約

利用者の zsh 設定が起動時に setopt sh_word_split / glob_subst / ksh_arrays を有効にしていても,guard には見えない(ADR-0022).

🤖 Generated with Claude Code

Where the executing shell is known to be zsh (a Claude Code payload and a zsh
in CLAUDE_CODE_SHELL or SHELL), an unquoted simple $name or ${name} is one word,
or none when empty, so the command is judged as that word and again without it.
Array, subscript, flag, modifier, glob and tilde forms, commands that can change
zsh's expansion, scripts run by another shell, and other providers still ask.
A `$` before `=` or `~` (`$=name`, `$~name`) now asks in every shell.

ADR-0022 records the decision; ADR-0009 and ADR-0015 point to it.

Closes #148

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@furedea

furedea commented Nov 10, 2026

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ❌ Failed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ❌ Failed 2026-11-11T07:42:06.387688Z
We couldn’t complete this review. Comment @codex review to try again.
bd0651f Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Comment thread agents/hooks/lib/executing_shell.py Outdated
}
)
# The parser reads these as declarations, not calls; `export` and `readonly` stay scalar unless `=(` shows.
DECLARATIONS = frozenset({"declare", "local", "typeset"})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Arrays can still be created without the forms this list catches, so an unquoted $name can still expand to several words.

The zsh reading treats $j as one scalar word unless the command contains =(, ]=, a typeset/declare/local DeclClause, or a listed program. Several zsh builtins create or fill an array without any of these:

  • export and readonly use zsh's bin_typeset and take its flags, including -a, -A and -T. The comment above assumes they "stay scalar unless =( shows", but export -T J_ j; J_=1:--web; gh run view $j --log ties j to J_. The scalar assignment fills the array j=(1 --web), so gh gets two arguments. readonly -T R_ r; R_=… works the same way.
  • zstyle -a ctx st j (after zstyle ctx st 1 --web), zformat -a j … and zparseopts -a j/-A j are autoloaded zsh/zutil builtins that write arrays. None of them is in EXPANSION_CHANGING_PROGRAMS.

In each case the guard judges gh … <placeholder> … and allows it, while zsh passes extra options. That is the word split ADR-0009 guards against, and ADR-0022 says commands like these "return the whole command to the shell-independent reading".

I couldn't run these through the guard here, so this is based on the code paths. Suggested fix: add export and readonly to DECLARATIONS (or bail out only when they carry -a, -A or -T). Add zstyle, zformat, zparseopts (and maybe getopts, vared, zle) to EXPANSION_CHANGING_PROGRAMS. Add tests for these to test_a_command_that_may_change_how_zsh_expands_still_asks. A denylist will still be fragile; an allowlist of commands that are known not to touch parameters would be more robust.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

指摘のとおりです.801a174 で対応しました.

  • export/readonly はオプション付き(-T,-a,-A など)のとき,typeset と同じく従来の判定に戻します.
  • zstyle,zformat,zparseopts,getopts,vared,let,print,printf,unset などをプログラム一覧に追加しました.
  • 一覧は網羅できないため,クォートなしで展開する変数の名前が,その展開と単純な name=value 代入以外の場所(他のコマンドの引数など)に現れたら従来の判定に戻す規則を足しました.配列を作る組み込みは名前を引数に取るので,export -T J_ j や zstyle -a ctx st j も塞がります.
  • 配列代入の検査は引用符を除かない全文に対して行います.
  • 指摘の例と,その他の組み込みを test_a_command_that_may_change_how_zsh_expands_still_asks に追加しました.ADR-0022 も更新しています.

@github-actions

Copy link
Copy Markdown

Review summary

1 blocking finding (P1)

  • P1 agents/hooks/lib/executing_shell.py: the check for commands that may change the expansion misses several ways to create or fill an array without =(, ]= or a typeset/declare/local declaration:

    • export / readonly with -T / -a / -A. A tied scalar assignment such as export -T J_ j; J_=1:--web fills the array.
    • zstyle -a, zformat -a, zparseopts -a/-A.

    After any of these, an unquoted $j is still judged as one word and allowed, while zsh passes several arguments. That reopens the word-split injection ADR-0022 says it guards against. Tests for these forms are missing.

The rest looks sound to me: the provider and SHELL gating, using wrapper_depth to skip bash -c/xargs scripts, enumerating words that vanish when empty, and newly asking for $=name/$~name in every shell. I couldn't run the guard in this environment, so the finding comes from reading the code and zsh's builtin option tables.

…array

Review of #149 found zsh builtins that create arrays without `=(`: `export -T`,
`readonly -T`, `zstyle -a`, `zformat -a`, `zparseopts -a`, `printf -v`, and so on.
No list of them is complete, so the zsh reading now also falls back to the
shell-independent one when an unquoted variable's name appears anywhere else in
the command beyond its expansions and plain assignments, and the listed
programs and option-bearing export/readonly are covered too. module_path joins
the array parameters.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@furedea

furedea commented Nov 11, 2026

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Comment thread agents/hooks/lib/executing_shell.py Outdated
CONFIRMED_PROVIDERS = frozenset({"", "claude"})
# Commands that can change what an unquoted variable expands to: an option, an array or association,
# another emulation, or code and definitions the hook does not read.
EXPANSION_CHANGING_PROGRAMS = frozenset(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: trap is missing from EXPANSION_CHANGING_PROGRAMS, and its string argument is never parsed. shell_scripts only expands eval, wrappers, and shells. That means trap 'setopt sh_word_split' DEBUG; J='1 --web'; gh run view 1 --job $J --log is still read as zsh defaults and can be allowed.

In zsh, a trap '…' DEBUG string runs in the caller's environment before each later command (unlike TRAPDEBUG() functions, and FuncDecl already asks). So the option persists, and $J splits into extra arguments the guard never saw. ARRAY_ASSIGNMENT and _is_named_elsewhere do see text inside the trap string, but a setopt or emulate inside it does not trigger _may_change_expansion.

The guard already ignores trap bodies in general, so this is a narrow gap. Still, this PR is meant to list the commands that can change expansion, and trap belongs in that list. Suggest adding trap, plus a test case next to setopt sh_word_split; ….

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

指摘のとおりです.c1ae4ce で trap を一覧に追加し,指摘の例をテストに加えました.

@github-actions

Copy link
Copy Markdown

Review summary

Blocking findings (P0/P1): 0

  • P2 agents/hooks/lib/executing_shell.py:19: trap is not in EXPANSION_CHANGING_PROGRAMS. A trap 'setopt sh_word_split' DEBUG earlier in the command turns word splitting back on, but the command is still read as zsh defaults. Details are in the inline comment.

Other checks, none of which turned up a problem:

  • Provider gating. Codex, Devin and the other providers go through provider_adapter, which always sets provider. Only Claude Code's direct guard_command.py calls have no provider, so the zsh reading cannot reach the other providers.
  • Wrapped and nested commands. These keep the old reading: env/command layers, bash -c scripts, and calls under time or ! all have wrapper_depth.
  • Empty variables. The "word disappears when empty" variants are checked for both the allowed and forbidden guards. Above four such words the guard falls back to the old reading. Downstream checks (automation_*, settings_objection) do not read spreading.
  • Array-making forms. Array assignments, DeclClause with options, and a name that appears elsewhere in the command all fall back to the old reading as intended. FuncDecl (including TRAPDEBUG()) already asks, because it is not in ALLOWED_NODES.
  • Tests. The new tests cover the changed behavior well.

The ADR already notes the remaining gap: options set by the user's zsh startup files, which Claude Code's shell snapshot carries over, are not visible to the guard.

🤖 Generated with Claude Code

A trap string can run setopt before later commands, so trap joins the programs
that return the command to the shell-independent reading. executing_shell and
shell_names are first-party modules for deptry.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@furedea

furedea commented Nov 11, 2026

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Comment thread agents/hooks/lib/executing_shell.py Outdated
Comment on lines +19 to +26
EXPANSION_CHANGING_PROGRAMS = frozenset(
{
*"alias autoload builtin emulate eval exec float functions getopts integer let mapfile noglob nocorrect"
" print printf private read readarray set setopt shopt source trap unalias unset unsetopt vared zformat"
" zmodload zparseopts zstyle".split(),
".",
}
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: EXPANSION_CHANGING_PROGRAMS には builtin,noglob,exec,nocorrect が入っていますが,zsh の前置修飾子 - が抜けています.zsh の - は builtin/noglob と同じ PREFIX で,次の語を builtin として探すので,- setopt sh_word_split は setopt を実行するはずです.

失敗シナリオ(SHELL=zsh,provider なし):

- setopt sh_word_split; gh run view 1 --job $J --log

arguments[0] は - で,このリストにも shell_wrappers の WRAPPERS にも入っていません.setopt の CallExpr も現れないため,_may_change_expansion は False を返し,$J はクォート付きと同じ 1 語として allow されます.実際には語分割が有効になるので,J='1 --web' などで gh に未確認のオプションが届きます.これは ADR-0022 の「展開を変えうるコマンドは丸ごと従来の判定」に反します.

この環境には zsh がないため,実機では確認していません."-" を集合に追加し,- setopt sh_word_split; … が ask になるテストを test_a_command_that_may_change_how_zsh_expands_still_asks に足すのが安全です.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

指摘のとおりです.- と command を一覧に追加し,- setopt … と command setopt … を ask にするテストを足しました.

@github-actions

Copy link
Copy Markdown

レビュー要約

ブロッキングな指摘(P0/P1)はありません.

指摘

  • P2 agents/hooks/lib/executing_shell.py:EXPANSION_CHANGING_PROGRAMS に zsh の前置修飾子 - が抜けています.- setopt sh_word_split; gh run view 1 --job $J --log は zsh の既定どおりに読まれて allow になりますが,実際には語分割が有効になります.zsh が手元になく,実機では確認していません.詳細はインラインコメントを参照してください.

確認した点(問題なし)

  • 実行シェルの判定:Codex ほかは provider_adapter が provider を設定するので,対象外のまま従来どおりの判定になります.
  • ラッパーの内側の層(wrapper_depth > 0)と bash -c などの入れ子スクリプトは,従来の読み方のままです.
  • 空で消える語を除いた形は,forbidden の判定でも評価されます.判定が厳しくなる方向で,すり抜けは増えません.
  • $=name / $~name は,どのシェルでも ask になりました.
  • ZSH_ARRAY_PARAMETERS,配列代入,typeset/export -T,名前が別の箇所に現れる場合の検出は,テストで広く確認されています.

既知の制約(ユーザーの zsh 設定で有効にしたオプション)は ADR-0022 に記載されています.

🤖 Generated with Claude Code

`- setopt ...` and `command setopt ...` run setopt without a setopt call the
guard can see, so both join the programs that return the command to the
shell-independent reading.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@furedea

furedea commented Nov 11, 2026

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Comment thread agents/hooks/lib/executing_shell.py Outdated
*"alias autoload builtin command emulate eval exec float functions getopts integer let mapfile noglob"
" nocorrect print printf private read readarray set setopt shopt source trap unalias unset unsetopt"
" vared zformat zmodload zparseopts zstyle".split(),
".",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] The "." entry never matches. _may_change_expansion checks Path(item.arguments[0]).name, and Path(".").name is "", not ".". So . ./env.zsh; gh run view 1 --job $J --log gets the zsh one-word reading and is allowed, even though the sourced file can run setopt sh_word_split or set J=(1 --web). In practice, source asks but . does not, and nothing else in the hooks handles .. Compare item.arguments[0] directly, or check it as name in ... or arguments[0] == ".". A test for . ./x.zsh; gh ... $J would also help.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

この指摘の対象(EXPANSION_CHANGING_PROGRAMS)は,b7dac96 で方式ごと外しました(拒否リストをやめ,同じコマンドでの通常の代入か HOME/PWD/TMPDIR/USER だけを 1 語とみなす).. ./env.zsh; gh … $J は,先行する文が代入でないため ask になります(. を含む代入以外の文はすべて同じ扱い).

Comment thread agents/hooks/lib/executing_shell.py Outdated
# Commands that can change what an unquoted variable expands to: an option, an array or association,
# another emulation, or code and definitions the hook does not read.
EXPANSION_CHANGING_PROGRAMS = frozenset(
{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] zsh's short repeat N cmd form runs a builtin in the current shell. The parser (shfmt -ln bash) reads it as a plain call whose program is repeat, and repeat is neither in this list nor in WRAPPERS. So repeat 1 setopt sh_word_split; J='1 --web'; gh run view 1 --job $J --log gets past _may_change_expansion. J= is removed as a scalar assignment, and setopt only appears as an argument. Consider adding repeat, or scanning every argument of such precommand-like calls for the listed names.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

同上です.repeat 1 setopt …; J=…; gh … $J は,先行する文が通常の代入でないため b7dac96 以降は ask になります.拒否リストに頼らない方式なので,repeat を個別に足す必要はありません.

@github-actions

Copy link
Copy Markdown

レビュー結果(読み取りのみ.この環境では guard を実行できなかったため,コードを読んで判断した)

  • [P1] executing_shell.py の EXPANSION_CHANGING_PROGRAMS にある "." は一致しない.Path(".").name は空文字になるため,. ./env.zsh; gh run view 1 --job $J --log が zsh の 1 語扱いで allow になる.読み込んだファイル側で setopt sh_word_split や配列の代入ができる.
  • [P2] zsh の repeat N cmd(短い形)は,今のシェルで組み込みコマンドを実行する.しかし repeat は表にも WRAPPERS にもないため,repeat 1 setopt sh_word_split; ... が展開を変えるコマンドの検出を通り抜ける.

確認して問題なかった点:Codex などの provider は provider_adapter が必ず provider を設定するので,Claude 扱いにはならない.関数本体や time の中の setopt は,すべての CallExpr を走査するので検出される.空で消える語が 4 語を超えるときは従来どおり ask になる.

ブロッキング:1 件(P1)

🤖 Generated with Claude Code

…one word

Replace the exclusion lists (array parameters, array-creating builtins, trap and
other option changers) with an allowlist: under zsh an unquoted $name is one word
only after a plain `name=value` earlier in the command with nothing but plain
assignments between, or for HOME, PWD, TMPDIR and USER. The call must be the
statement's first command or part of its pipeline. Everything else asks as before.

ADR-0022 is rewritten for the new rule and records why the exclusion list was dropped.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@furedea

furedea commented Nov 11, 2026

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Comment thread agents/hooks/lib/shell_words.py Outdated
if GLOB.search(unquoted) or BRACES & set(unquoted) or UNESCAPED_DOLLAR.search(unquoted):
return True
if parts and parts[0].get("Type") == "Lit" and parts[0].get("Value", "").startswith("~"):
return any(part.get("Type") == "ParamExp" for part in parts)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — This early return skips the line-continuation and @/*/positional checks below it, and the change affects every shell, not only zsh. any(...) looks only at top-level parts. So for a word that starts with ~ and has a spreading expansion inside double quotes (for example ~"$@", ~/x"${A[@]}", ~"$1"), the function returns False. Before this PR the loop below returned True.

Example: set -- a --admin; gh pr merge 1 ~"$@" runs as ~a --admin. The guard sees one placeholder word and does not flag it as spreading, so any rule that accepts a placeholder there lets an extra option through. A tilde word that ends in \+newline also stops being flagged.

A fix is to return True only when the condition holds, and otherwise fall through to the checks below:

Suggested change
return any(part.get("Type") == "ParamExp" for part in parts)
if parts and parts[0].get("Type") == "Lit" and parts[0].get("Value", "").startswith("~") and any(part.get("Type") == "ParamExp" for part in parts):
return True

A test with a tilde before a quoted "$@" would cover this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

指摘のとおりです.e0b770d で,チルダ始まりの語でも以降の検査(行継続,@/*/位置パラメータ)に進むよう,条件が成り立つときだけ True を返す形に直し,~"$@" と ~/x"${A[@]}" のテストを足しました.

Comment thread agents/hooks/lib/shell_words.py Outdated
# Brace expansion can pair braces across quoted parts, so any unquoted brace counts.
BRACES = frozenset("{}")
LINE_CONTINUATION = "\\\n"
UNESCAPED_DOLLAR = re.compile(r"(?<!\\)\$")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — The lookbehind treats any $ after a backslash as escaped, but the backslash may itself be escaped. In \\$=J (Lit value \\$=J), \\ is a literal backslash and $=J still expands. zsh splits it: J='x --admin'; gh … \\$=J runs as \x, --admin. The regex finds no unescaped $, so the new $=name / $~name fix can be bypassed. Count the run of backslashes instead, e.g. (?<!\\)(?:\\\\)*\$.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

指摘のとおりです.e0b770d で,偶数個の連続するバックスラッシュ(各組が 1 つの文字)の後の $ もエスケープされていないものとして数える正規表現に直し,\\$=J のテストを足しました.

@github-actions

Copy link
Copy Markdown

Review summary

I left two inline comments. One is blocking.

  • P1 shell_words.py:133: the new early return for words starting with ~ skips the line-continuation and $@ / $* / positional / [@] checks. Words like ~"$@" and ~/x"${A[@]}" are therefore no longer flagged as possibly several words, in any shell. Before this PR they were. This is a regression.
  • P2 shell_words.py:30: UNESCAPED_DOLLAR misses a $ after an escaped backslash, so \\\\$=J (a backslash pair before $=J) gets past the new $=name / $~name check.

What I checked and found no problems in:

  • The list of names that count as one word in zsh_assignments: only plain assignments may come before the call, and only a call that is the statement's first command or a pipe member counts.
  • Calls behind wrappers or time / !, and scripts passed to -c, keep the old reading because wrapper_depth is nonzero for them.
  • The check of provider and SHELL: other providers always set provider through provider_adapter.
  • Enumerating each combination of empty words that disappear.

I could not run the tests here because shfmt is not installed, so these findings come from reading the code.

🤖 Generated with Claude Code

…lain appends

A word starting with `~` no longer returns early from the spread check, so
`~"$@"` still asks; a dollar sign after an escaped backslash (`\\$=J`) is not
treated as escaped; and `name+=value` counts only after a plain assignment of the
same name, since appending to an array adds an element.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@furedea

furedea commented Nov 11, 2026

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Comment thread agents/hooks/lib/zsh_assignments.py Outdated

def _operator(command: dict, data: bytes) -> str:
offset = command["OpPos"]["Offset"]
return data[offset : offset + 2].decode().rstrip(" \t\n\\")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: _operator takes two raw bytes after OpPos, then calls .decode() on them. If a non-ASCII character comes right after a one-byte |, the slice ends partway through that character. For example, echo x|日本語 gives b'|\xe6', which raises UnicodeDecodeError. parse() calls one_word_names for every command in every shell and for every provider, before any zsh check. UnicodeDecodeError is a ValueError, so decide() catches it and returns a hard deny (BLOCKED: 'utf-8' codec can't decode…). Before this change that command was not blocked. A related false ask: with J=1; gh run view 1 --job $J --log|head, the slice is "|h", which matches neither operator list, so the call is dropped and the command asks.

Suggested fix: read the operator from shfmt's Op code, or strip the second character unless it is | or &, for example:

text = data[offset : offset + 2].decode(errors="replace")
return text if text in ("&&", "||", "|&") else text[:1]

Please also add tests for cmd|é (no deny) and $J --log|head (allow under zsh).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

この指摘の対象(zsh_assignments.py の演算子の読み取り)は,9a312de で同ファイルごと削除しました(同じコマンド内の代入の追跡をやめ,TMPDIR/HOME/PWD/USER だけを通す方式に変更).

@github-actions

Copy link
Copy Markdown

Review summary

Blocking findings (P0/P1): none.

I reviewed how the shell is detected (provider is unset or claude, plus CLAUDE_CODE_SHELL/SHELL), how assignments are tracked (zsh_assignments.py), how zsh's reading of a word is applied (executing_shell.py), the vanishing variants in command_objections.objection, and the spreads changes. I found no false allow:

  • Every adapter-routed provider gets provider set in provider_adapter.normalized / translated. Only the Claude entries in hooks.json call guard_command.py directly.
  • Unwrapped layers, time/! calls and sh -c scripts all have wrapper_depth > 0, so they keep the old shell-independent reading.
  • Variants are built before canonical(), so git global options are recomputed after a word drops out.
  • literal_words and pinned_program_objection still look at the commands before the zsh reading is applied, so they stay conservative.

P2 (inline): in zsh_assignments._operator, a multi-byte character right after | (for example echo x|日本語) raises UnicodeDecodeError, a ValueError. decide() turns that into a hard deny, and it happens in every shell because parse() always calls one_word_names. Separately, …$J --log|head with no space before | falls back to ask.

I couldn't run the test suite because shfmt isn't installed in the review environment, so these results come from reading the code.

🤖 Generated with Claude Code

`path=x; path+=y` leaves an array of two elements, so an unquoted `$path` is two
words. An append is no longer a plain assignment, whatever came before it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@furedea

furedea commented Nov 11, 2026

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Comment thread agents/hooks/lib/zsh_assignments.py Outdated

def _operator(command: dict, data: bytes) -> str:
offset = command["OpPos"]["Offset"]
return data[offset : offset + 2].decode().rstrip(" \t\n\\")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: _operator reads two bytes and strips only whitespace or a backslash. When nothing separates the pipe from the next command, | is read as something else. For example, J=1; gh run view 1 --job $J --log|head gives "|h", which is in neither PIPE_OPERATORS nor SEQUENTIAL_OPERATORS, so the call is never reached and the command asks. The same thing happens with 2>&1|head. This errs toward asking, so it is not a safety problem, but it does not match the documented "a pipe after the call" allow case. A fix: if the two bytes are not || or |&, check for a single | (or &&). It would also be worth adding a test with no space around the pipe. Decoding two bytes can also split a multibyte character, but the operator bytes are ASCII, so that should not happen.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

同上です.9a312de で zsh_assignments.py を削除したため,該当コードはありません.

@github-actions

Copy link
Copy Markdown

Review summary (read-only, static review: in this environment I could not run the guard or the test suite)

No blocking findings (P0/P1: 0).

What I checked:

  • zsh_assignments.one_word_names: only the first call of a statement or the members of its pipeline are counted, and only after statements that are nothing but plain scalar assignments. +=, [i]=, arrays, export options, background, negation and any non-assignment statement all stop the scan. Calls behind &&/||, inside functions, blocks or control constructs, and in sh -c/eval scripts get no names.
  • Wrapper layers (env, timeout, xargs, …) and nested scripts carry wrapper_depth > 0, so _as_run_by_zsh leaves them with the shell-independent reading. rebased slices zsh_spreading and vanishing consistently, and the only other replace of words (without_option_values) keeps the lengths the same, so the zip(strict=True) calls cannot get mismatched tuples.
  • is_one_word_parameter rejects every modifier (Names is now included). Subscripts and globs after the variable are caught by GLOB. $=, $~, $^ and $+ are caught by UNESCAPED_DOLLAR, and ~$x by the tilde check.
  • Vanishing variants: every combination of droppable words after the program is judged, capped at 4.
  • Only providers whose adapter sets provider other than claude are excluded. Codex, Devin, Hermes, Pi and dsh adapters all set it.

Minor:

  • P3 zsh_assignments._operator: a | with no space after it (...--log|head) is not recognised, so the command asks instead of allowing. Not a safety issue.

The known limitation is documented in ADR-0022: the Claude Code shell snapshot can restore user setopt options such as sh_word_split, and the guard cannot see them.

Tests in tests/hooks/test_zsh_unquoted_variables.py cover the allow, ask and retarget cases well.

Drop the tracking of names assigned earlier in the same command: reviews kept
finding holes in it. An unquoted $name is now one word under zsh only for the
four environment variables, and only while the command mentions the name nowhere
but in expansions. Anything else asks as before; quote it to run unprompted.

ADR-0022 is rewritten and records both rejected approaches.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@furedea

furedea commented Nov 11, 2026

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

def _is_named_beyond_expansions(name: str, command: str) -> bool:
escaped = re.escape(name)
rest = re.sub(rf"\${escaped}\b|\$\{{{escaped}\}}", "", command)
return bool(re.search(rf"(?<![\w$]){escaped}(?!\w)", rest))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — The mention check only matches the name as one unbroken piece of the raw command text, so a quoted or escaped spelling slips past it, even though the docstring says "however it is spelled".

Example: set -A TMP''DIR 1 --repo other/repo; gh run view $TMPDIR --log, or read -A TMP\DIR <<< '...', or typeset -a TMP"DIR". zsh removes the quotes, so the first statement makes TMPDIR an array. The regex never sees the string TMPDIR in that statement, so $TMPDIR is still treated as one word, and the gh call is judged with a single placeholder. At run time it gets several arguments, such as --repo other/repo.

This is a static form, so under ADR-0015 rule 1 it should be caught. Possible fix: run the check on the parsed, quote-removed words of every command (item.words / literal values), plus assignment and declaration names from the AST, instead of the raw source. Alternatively, treat any quote or backslash touching a name that matches the variable once removed as a mention. A test with TMP''DIR would cover this.

(I couldn't run the probe here because Python execution was blocked; this comes from reading the regex (?<![\w$])TMPDIR(?!\w) applied to the raw text.)

@github-actions

Copy link
Copy Markdown

Review summary (read-only)

Findings: 1 x P2, 0 blocking.

  • P2 agents/hooks/lib/executing_shell.py:39: the check for whether the command mentions the name only matches the raw command text, so a quoted spelling (set -A TMP''DIR ..., read -A TMP\\DIR) can make TMPDIR an array without the guard noticing. $TMPDIR is then still judged as one word. Suggested fix: check the parsed, quote-removed words, and add a test for this case.

Everything else holds up when read: the zsh_spreading/vanishing handling, which slices the new flags along with the other per-word fields; the enumeration of variants where a word vanishes when empty; detection of the zsh shell, limited to Claude Code; and the $=/$~/tilde fixes. I could not run the test suite or probe commands in this environment because Python execution was blocked.

🤖 Generated with Claude Code

`set -A TMP''DIR ...` makes TMPDIR an array without the string TMPDIR in the
source. Quotes, backslashes and line continuations are removed before the
mention check, as the shell removes them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@furedea

furedea commented Nov 11, 2026

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

"""
if not _is_zsh(payload):
return commands
names = frozenset(name for name in ENVIRONMENT_NAMES if not _is_named_beyond_expansions(name, command))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: 同じコマンドの中で zsh のオプションを変える形が考慮されていない.

この判定は zsh の既定オプション(SH_WORD_SPLIT / GLOB_SUBST が off)を前提にしているが,setopt shwordsplit,set -o shwordsplit,set -y,emulate sh,setopt globsubst は変数名を一切含まないので,_is_named_beyond_expansions を通ってしまう.agents/hooks のどこにも setopt / emulate の扱いはなく,gh 以外のコマンドは governed_objection で allow になるはず.

例:

setopt shwordsplit; cd "$TMPDIR/a --title x"; gh pr edit 438 --body-file $PWD/body.md

cd は PWD を変えるが,名前は展開の中にしか出てこない.zsh は実際には gh pr edit 438 --body-file /…/a --title x/body.md を実行し,判定されていない引数が増える.IFS=/ と組み合わせれば,環境から来た $TMPDIR も分割される.

ADR-0022 は起動ファイルで変えられたオプションを既知の制約にしているが,コマンドの中に書かれたオプション変更は,テキストから静的にわかる形(ADR-0015 rule 1).最初の実装ではこの一覧があった.オプションを変えうるビルトイン(setopt,set,emulate,unsetopt,-/builtin 経由も含む)がコマンドに現れたら,どの名前もこの読み方を無効にするべき.あわせてテストも追加してほしい.

def _is_named_beyond_expansions(name: str, command: str) -> bool:
"""Whether the command, with its quotes and escapes removed as the shell does, names the variable otherwise."""
escaped = re.escape(name)
spelled = re.sub(r"[\\'\"]", "", command.replace("\\\n", ""))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: クォートとエスケープを「シェルと同じように」外していない.ANSI-C クォートを見逃す.

[\\'\"] を消すだけなので,$'\x54MPDIR' は $x54MPDIR に,$'\124MPDIR' は $124MPDIR になり,TMPDIR が見つからない.しかし zsh はどちらも TMPDIR に展開する.

例:

set -A $'\x54MPDIR' 1 --repo o/r; gh run view $TMPDIR --log

zsh では gh run view 1 --repo o/r --log になる.テキストから静的にわかる綴りで,テストが塞ごうとしている set -A TMP''DIR / TMP\DIR と同じ種類の穴.$' を含むコマンドでは読み方を無効にする(または $'…' をデコードしてから検査する)べき.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(hooks): judge unquoted variables as zsh runs them

1 participant