From aba9455dedceee37b2804bc341fededd4fbee0fc Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Wed, 16 Sep 2026 14:13:57 -0700 Subject: [PATCH] Run list loops under bash, not zsh zsh does not split an unquoted variable into words, so a list loop runs once and reports success. Add the rule to the learned working-style rules with a test that runs both shells and pins the rule text. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: I2b1e4339a193191560c91d8416cd06a4441283dd --- corpus/CLAUDE.learned.md | 1 + tests/test_zsh_loop_rule.py | 56 +++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 tests/test_zsh_loop_rule.py diff --git a/corpus/CLAUDE.learned.md b/corpus/CLAUDE.learned.md index 69069ada..c2753870 100644 --- a/corpus/CLAUDE.learned.md +++ b/corpus/CLAUDE.learned.md @@ -41,3 +41,4 @@ Engine-only install drops this file; reflect Accepted global rules land here. - When a permission classifier or sandbox denies a tool call, treat the first denial as categorical for that target — don't retry the same blocked action through a different tool (Bash → Edit → a different Bash invocation). Switch strategy immediately, usually to a script handoff (next rule). The policy judges the target and the action, not the tool, so every route to the same write meets the same check — Saltzer and Schroeder's complete mediation — and hunting for an unguarded route is working around a security control. - When handing off a blocked action for me to run myself, always write it to a small script file and hand back exactly one `! bash ` / `! python3 ` line — never paste inline multi-line, multi-flag, or `&&`/`&`-backgrounded shell text into chat for me to copy. This applies on the very first handoff and every one after it, not after I complain about copy-paste pain. Pasted shell breaks in transit — wrapped lines, lost quoting, an `&` that detaches a job — while one `!` line runs exactly the file that was written. - Never pass a quoted command string through `sudo -i`, `su -`, or `su -l`. Those start a login shell that reads the command a second time, after the first shell already used up the quotes, so the string splits on spaces and its first word runs alone (`sudo -u demo -H -i bash -lc 'set -u; echo one'` fails with `set: -c: invalid option`; the same line without `-i` prints `one`). Copy the script to the host and run it by path, or drop `-i`. +- The shell behind the Bash tool may be zsh, which does not split an unquoted variable into words: `for x in $LIST` runs once with the whole list as one item, exits 0, and reads as "all done". Run any loop over a list under bash (`bash <<'EOF' ... EOF`), count lookups that failed as unchecked rather than done, and spot-check one item before acting on the loop's summary. This is zsh's `SH_WORD_SPLIT` option being off by default (zsh manual, Options, https://zsh.sourceforge.io/Doc/Release/Options.html#index-SH_005fWORD_005fSPLIT). diff --git a/tests/test_zsh_loop_rule.py b/tests/test_zsh_loop_rule.py new file mode 100644 index 00000000..3f081966 --- /dev/null +++ b/tests/test_zsh_loop_rule.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""The learned rule about zsh loops, and the shell behavior it depends on. + +zsh does not split an unquoted parameter into words, so a loop over `$LIST` +runs once with the whole list as a single item, exits 0, and reads as "all +done". The premise tests run both shells so the rule fails loudly if that +behavior ever stops being true. +""" +import os +import shutil +import subprocess +import unittest + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +LEARNED = os.path.join(REPO_ROOT, "corpus", "CLAUDE.learned.md") +COUNT_LOOP = 'L="a b c"; n=0; for x in $L; do n=$((n+1)); done; echo $n' + + +def loop_count(shell): + result = subprocess.run([shell, "-c", COUNT_LOOP], capture_output=True, text=True, check=True) + return result.stdout.strip() + + +def working_style_section(): + with open(LEARNED, encoding="utf-8") as handle: + text = handle.read() + start = text.index("# Working style") + end = text.find("\n# ", start + 1) + return text[start:] if end == -1 else text[start:end] + + +class TestZshLoopPremise(unittest.TestCase): + @unittest.skipUnless(shutil.which("zsh"), "zsh is not installed on this machine") + def test_zsh_runs_an_unquoted_list_loop_once(self): + self.assertEqual(loop_count("zsh"), "1") + + def test_bash_runs_the_same_loop_once_per_word(self): + self.assertEqual(loop_count("bash"), "3") + + +class TestZshLoopRule(unittest.TestCase): + def test_rule_names_the_shell_and_the_fix(self): + section = working_style_section() + self.assertIn("zsh", section) + self.assertIn("`for x in $LIST`", section) + self.assertIn("bash <<'EOF'", section) + + def test_rule_counts_failed_lookups_as_unchecked(self): + self.assertIn("count lookups that failed as unchecked", working_style_section()) + + def test_rule_cites_the_zsh_option(self): + self.assertIn("SH_WORD_SPLIT", working_style_section()) + + +if __name__ == "__main__": + unittest.main()