From 67191e32a464370453475d4b5b7a363375723446 Mon Sep 17 00:00:00 2001 From: Andreas Daiminger Date: Thu, 10 Sep 2026 10:32:27 +0300 Subject: [PATCH] Catch a countless head/tail in the truncation guard _TRUNC requires an explicit line count, so a bare `head` or `tail` at the end of a pipe never matches. Both keep 10 lines by default, the same cut as the `-10` form the guard already rejects. The rule names the two commands it is about and then misses them whenever the count is left off. The regex now has two alternatives: an explicit count under 20, or no count at all. The countless branch is a lookahead anchored to a real command boundary, so counts of 20 and above, byte counts, follow mode, and words that merely start with those four letters are all unaffected. --- aai_coding/harness.py | 6 +++++- tests/test_harness.py | 13 ++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/aai_coding/harness.py b/aai_coding/harness.py index ef76dc1..522b32a 100644 --- a/aai_coding/harness.py +++ b/aai_coding/harness.py @@ -7,7 +7,11 @@ NO_TRUNCATE = 'Output pipe truncates below 20 lines. Drop the pipe or keep >=20: truncation is decided before the output exists, so keep enough to diagnose surprises.' NO_STDERR_MERGE = 'Do not merge stderr into stdout with 2>&1. Run the command bare: the harness pushes large output to a file by itself, and stderr kept separate makes a crash unmissable.' -_TRUNC = re.compile(r'\|\s*(tail|head)\s+(-n\s*)?-?([1-9]|1[0-9])\b') +# Two ways a head/tail pipe truncates below 20: an explicit count under 20, or no count at all - +# bare `head`/`tail` default to 10, so a countless pipe cuts exactly as hard as the `-10` we reject. +_TRUNC = re.compile(r'\|\s*(?:tail|head)' + r'(?:\s+(?:-n\s*)?-?(?:[1-9]|1[0-9])\b' # explicit: -5, -n 5, -19 + r'|(?=\s*(?:$|[|;&\n])))') # bare: defaults to 10 _MERGE = re.compile(r'2>\s*&\s*1') diff --git a/tests/test_harness.py b/tests/test_harness.py index 3884200..7a3c8b4 100644 --- a/tests/test_harness.py +++ b/tests/test_harness.py @@ -3,7 +3,18 @@ import pytest from shutil import which -from aai_coding.harness import claude_air, claude_drop_sentinel, claude_slop, synthetic_resume +from aai_coding.harness import bash_guard_msg, claude_air, claude_drop_sentinel, claude_slop, synthetic_resume + + +def test_bash_guard_bare_head_tail(): + "A countless head/tail keeps 10 lines, the same cut as the -10 form the guard already rejects" + assert bash_guard_msg('tar tzf x.tgz | head') + assert bash_guard_msg('ls ~ | tail') + assert bash_guard_msg('cat big.log | head | wc -l') + assert bash_guard_msg('ls | head; echo done') + assert bash_guard_msg('ls | header') is None # merely starts with those four letters + assert bash_guard_msg('cat f | head -c 200') is None # bytes, not lines + assert bash_guard_msg('git status | head -30') is None def test_synthetic_resume(tmp_path):