Repository navigation
fix(hooks): judge unquoted variables as zsh runs them - #149
furedea-agent[bot] wants to merge 9 commits into
Conversation
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>
|
@codex review |
Codex Review Summary: ❌ FailedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
| } | ||
| ) | ||
| # The parser reads these as declarations, not calls; `export` and `readonly` stay scalar unless `=(` shows. | ||
| DECLARATIONS = frozenset({"declare", "local", "typeset"}) |
There was a problem hiding this comment.
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:
exportandreadonlyuse zsh'sbin_typesetand take its flags, including-a,-Aand-T. The comment above assumes they "stay scalar unless=(shows", butexport -T J_ j; J_=1:--web; gh run view $j --logtiesjtoJ_. The scalar assignment fills the arrayj=(1 --web), soghgets two arguments.readonly -T R_ r; R_=…works the same way.zstyle -a ctx st j(afterzstyle ctx st 1 --web),zformat -a j …andzparseopts -a j/-A jare autoloaded zsh/zutil builtins that write arrays. None of them is inEXPANSION_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.
There was a problem hiding this comment.
指摘のとおりです.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 も更新しています.
Review summary1 blocking finding (P1)
The rest looks sound to me: the provider and |
…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>
|
@codex review |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
| 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( |
There was a problem hiding this comment.
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; ….
There was a problem hiding this comment.
指摘のとおりです.c1ae4ce で trap を一覧に追加し,指摘の例をテストに加えました.
Review summaryBlocking findings (P0/P1): 0
Other checks, none of which turned up a problem:
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>
|
@codex review |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
| 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(), | ||
| ".", | ||
| } | ||
| ) |
There was a problem hiding this comment.
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 に足すのが安全です.
There was a problem hiding this comment.
指摘のとおりです.- と command を一覧に追加し,- setopt … と command setopt … を ask にするテストを足しました.
レビュー要約ブロッキングな指摘(P0/P1)はありません. 指摘
確認した点(問題なし)
既知の制約(ユーザーの 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>
|
@codex review |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
| *"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(), | ||
| ".", |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
この指摘の対象(EXPANSION_CHANGING_PROGRAMS)は,b7dac96 で方式ごと外しました(拒否リストをやめ,同じコマンドでの通常の代入か HOME/PWD/TMPDIR/USER だけを 1 語とみなす).. ./env.zsh; gh … $J は,先行する文が代入でないため ask になります(. を含む代入以外の文はすべて同じ扱い).
| # 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( | ||
| { |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
同上です.repeat 1 setopt …; J=…; gh … $J は,先行する文が通常の代入でないため b7dac96 以降は ask になります.拒否リストに頼らない方式なので,repeat を個別に足す必要はありません.
|
レビュー結果(読み取りのみ.この環境では guard を実行できなかったため,コードを読んで判断した)
確認して問題なかった点:Codex などの provider は ブロッキング: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>
|
@codex review |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
| 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) |
There was a problem hiding this comment.
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:
| 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.
There was a problem hiding this comment.
指摘のとおりです.e0b770d で,チルダ始まりの語でも以降の検査(行継続,@/*/位置パラメータ)に進むよう,条件が成り立つときだけ True を返す形に直し,~"$@" と ~/x"${A[@]}" のテストを足しました.
| # Brace expansion can pair braces across quoted parts, so any unquoted brace counts. | ||
| BRACES = frozenset("{}") | ||
| LINE_CONTINUATION = "\\\n" | ||
| UNESCAPED_DOLLAR = re.compile(r"(?<!\\)\$") |
There was a problem hiding this comment.
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. (?<!\\)(?:\\\\)*\$.
There was a problem hiding this comment.
指摘のとおりです.e0b770d で,偶数個の連続するバックスラッシュ(各組が 1 つの文字)の後の $ もエスケープされていないものとして数える正規表現に直し,\\$=J のテストを足しました.
Review summaryI left two inline comments. One is blocking.
What I checked and found no problems in:
I could not run the tests here because 🤖 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>
|
@codex review |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
|
||
| def _operator(command: dict, data: bytes) -> str: | ||
| offset = command["OpPos"]["Offset"] | ||
| return data[offset : offset + 2].decode().rstrip(" \t\n\\") |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
この指摘の対象(zsh_assignments.py の演算子の読み取り)は,9a312de で同ファイルごと削除しました(同じコマンド内の代入の追跡をやめ,TMPDIR/HOME/PWD/USER だけを通す方式に変更).
Review summaryBlocking findings (P0/P1): none. I reviewed how the shell is detected (
P2 (inline): in I couldn't run the test suite because 🤖 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>
|
@codex review |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
|
||
| def _operator(command: dict, data: bytes) -> str: | ||
| offset = command["OpPos"]["Offset"] | ||
| return data[offset : offset + 2].decode().rstrip(" \t\n\\") |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
同上です.9a312de で zsh_assignments.py を削除したため,該当コードはありません.
|
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:
Minor:
The known limitation is documented in ADR-0022: the Claude Code shell snapshot can restore user Tests in |
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>
|
@codex review |
|
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)) |
There was a problem hiding this comment.
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.)
|
Review summary (read-only) Findings: 1 x P2, 0 blocking.
Everything else holds up when read: the 🤖 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>
|
@codex review |
|
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)) |
There was a problem hiding this comment.
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", "")) |
There was a problem hiding this comment.
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 と同じ種類の穴.$' を含むコマンドでは読み方を無効にする(または $'…' をデコードしてから検査する)べき.
概要
実行シェルが zsh と確定できるとき,外に届くコマンド(
ghなど)の引数にあるクォートなしの$TMPDIR/$HOME/$PWD/$USER(${name}を含む)を,クォート付きと同じ「1 語の値(空なら欠落)」として判定する.ADR-0009 の前提「語分割」を実行シェルに依存させる判断は ADR-0022 に記録した.Closes #148
方針の変遷(Issue #148 の計画コメントの再改訂に合わせた)
$nameは 1 語,1 語にならない形を一覧で除く」→ 漏れが review のたびに見つかり収束しなかったpath+=x),副作用のある値などの抜けが見つかり続けた$Jのように同じ行で代入した変数は,"$J"とクォートすれば従来どおり allow.判定(実行シェルが zsh と確定できる場合のみ)
クォートなしの単純な
$name/${name}を 1 語として扱うのは,nameがTMPDIR,HOME,PWD,USERのいずれかで,かつ同じコマンドの中でその名前が展開以外の場所(代入,宣言,read,unset,trap,関数,コマンドの引数としての出現など)に一度も現れないときだけ.それ以外は従来どおり ask.変数だけでできた語は空なら消えるので,その語を除いた形も判定する.実行シェルの確定方法
providerなし(またはclaude)で,CLAUDE_CODE_SHELL,なければSHELLの basename がzsh.hook は Claude Code の環境を引き継ぐ.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