Skip to content

Read the Guard's Token Fallback With a Bash-Aware Context Scan - #2076

Merged
ptr727 merged 10 commits into
developfrom
feature/auto-1910
Sep 29, 2026
Merged

ptr727 merged 10 commits into
developfrom
feature/auto-1910

Conversation

@ptr727

@ptr727 ptr727 commented Sep 29, 2026

Copy link
Copy Markdown
Owner

The write guard's shell-token fallback now reads a command with a bash-aware context scan. _context_lex replaces the comment strip and the _COMMENT_SCAN_BAIL skip list, so a quote, a substitution, a heredoc, or an arithmetic context spanning lines is read whole rather than cut at a line. _shell_tokens keeps develop's line reading followed by the scan's tokens, per #1960, so the scan only adds to what the guard judges. The wait rule judges both heredoc strips, _closes_as_arithmetic is linear, and _redirects_stdin refuses an operator as a redirect's target and reads a redirect with no target as no bound, per #2023. Every constructed example in #1910 is a selftest case.

One false deny is introduced and accepted per #2045: a quoted operator used as a redirect's target reaches the check bare, tracked in #2075.

#1910, #1958, and #1959 stay open, per #1960.

🤖 Generated with Claude Code

ptr727 and others added 10 commits September 27, 2026 20:19
When shlex cannot follow a command's quoting, the write guard now reads it
with a context-tracking scan rather than a comment strip guarded by a skip
list. The scan carries quote state across lines, honors backslash escapes,
reads $( ), ${ }, $'...', $"..." and backquote spans whole, drops a comment
only outside every quote, and treats # as text inside arithmetic, an
extglob pattern, a [[ =~ ]] operand, and a heredoc body. The union with the
per-line base tokens is gone, so loop text inside a multi-line quote no
longer draws a false deny.

Closes on promotion: #1910

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A heredoc inside a command substitution is skipped line by line, so a
quote in its body no longer spans past the substitution. A heredoc opener
counts only where the line opens one both with its comments dropped and
with a # read as text. A $$ is the process id rather than the start of a
$'...' span, and [[ opens a conditional only unquoted and in command
position.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A case pattern's ) no longer closes a command substitution, and a (( that
closes on a lone ) is read as nested subshells rather than arithmetic. An
empty quoted heredoc delimiter opens a heredoc, and a herestring inside a
substitution does not. A carriage return is a word character, a # glued
to a substitution's ) is text, [[ is in command position only after a
separator or a keyword that was itself in command position, and a { inside
${...} does not nest.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Restore the union with the fallback reading that predates the context scan, so a context the scan misreads never hides a command that reading saw, and a heredoc opens only where that reading also opens it. Build the paren closers once per command, so a long run of `((` pairs is scanned in linear time.

The multi-line-quote false deny stays open, per the answer on #1960.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Strip heredoc bodies once by the scan's opener and once by the line reading's, and deny a loop either leaves standing, since vetoing one opener let a later body line open a heredoc that hid a loop. Build the paren closers once per scan rather than in a shared cache that heredoc body lines evicted, and drop a docstring claim the opener's fallback did not honor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Name in the context scan's docstring that a `#` right after any `)` is kept as text, which bash reads as a comment after a subshell's `)`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	host-setup/agent-safety/claude/gh-write-guard.py
A dangling redirect at the end of the line fallback's reading took the
newline that joins it to the context scan's reading as its target, so the
scan's first statement's input redirect counted as the loop's bound. An
operator is never a target, so the separator now ends the invocation, and a
redirect with no target bounds nothing. Both cases are selftest wait cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings September 29, 2026 15:26
@coderabbitai

coderabbitai Bot commented Sep 29, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Repository: ptr727/ProjectTemplate/.coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: dea201b2-b9aa-4ef6-94c1-fb564219384b


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ptr727 ptr727 added the comments Permits the comment lines the pull request adds or edits, which the prose gate otherwise refuses label Sep 29, 2026
@codecov

codecov Bot commented Sep 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (develop@b56efb2). Learn more about missing BASE report.

Additional details and impacted files
@@            Coverage Diff             @@
##             develop    #2076   +/-   ##
==========================================
  Coverage           ?   55.91%           
==========================================
  Files              ?       16           
  Lines              ?     7359           
  Branches           ?        0           
==========================================
  Hits               ?     4115           
  Misses             ?     3244           
  Partials           ?        0           
Flag Coverage Δ
python-3.13 55.91% <ø> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🔵 Needs a closer look

It is a security-critical, fail-closed write guard whose new hand-rolled bash tokenizer carries intricate cross-context state and knowingly ships an accepted false-deny, so it warrants final human review.

Review effort: Balanced
Findings: None

What changed in this PR

This PR rewrites the shell-token fallback in the Claude Code write guard (gh-write-guard.py). When the primary shlex-based tokenizer cannot follow a command's quoting (for example an apostrophe in a trailing comment, or a quote nested inside "$(...)"), the guard previously stripped comments per whole command or tokenized each line in isolation, which lost quote/context state across lines and produced false denies. The new _context_lex scanner tracks the contexts bash actually nests (quotes, substitutions, $'...', arithmetic (( )), [[ =~ ]] operands, extglob patterns, and heredoc bodies) across line boundaries. Crucially, _shell_tokens keeps the old per-line reading and unions it with the scan's tokens, preserving the safety property that the fallback can only ever over-deny, never allow a command the prior fallback denied.

Changes:

  • Add _context_lex and its helpers (_skip_group, _skip_nested, _skip_quote_body, _skip_backticks, _skip_heredoc_lines, _heredoc_bodies, _arith_depth, _paren_closers/_closes_as_arithmetic), and refactor _shell_tokens to return the union of _line_fallback_tokens and _context_lex.
  • Harden the wait/redirect rules: _redirects_stdin now refuses a shell-operator token as a redirect target and treats a targetless redirect as binding nothing; _check_unbounded_wait and _heredoc_opener/_line_heredoc_opener/_heredoc_opener_in judge both the scan's and the line reading's heredoc stripping so a misread heredoc cannot hide a loop.
  • Add extensive selftest coverage (_CONTEXT_LEX_CASES, new classify cases, and two linear-time performance assertions), keyed to the constructed examples from the referenced issues.
File Description
host-setup/​agent-safety/​claude/​gh-write-guard.py Replaces the guard's line-oriented comment-strip fallback with a bash-aware context scan unioned with the old reading, hardens redirect/heredoc/wait rules, and adds selftest cases.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

ptr727 added a commit that referenced this pull request Sep 29, 2026
…d Effort (#2104)

## Summary

Copilot code review's default effort moved from Lite to Balanced on
2026-09-28. Balanced rounds write the `ccr-overview-v2` body with a
per-file table and almost never a coverage statement. So `pr_review.py`
read every such pull request as `COVERAGE_IS_UNSTATED` (exit 45) and
parked it on a maintainer decision (#2103, #2095, #2089, #2082, #2077).

Measured over the Copilot rounds on the 40 newest pull requests here, 25
of 29 Lite rounds carried the `fleet-review` marker, against 1 of 22
Balanced rounds. Stating the marker literally in the runbook did not
help. A Balanced round on this branch read that instruction, quoted it
in its own summary, and still left the marker out. So the tooling now
accepts a coverage reading that works at any effort level, instead of
depending on one setting.

### The rule

A head that no coverage statement reaches, meaning none is stated on it
and none is carried to it, now reads `coverage=table` and passes when
all of these hold:

- A round covering the head has its own file table naming exactly the
changed files.
- No Copilot round on the pull request, on any commit, states or appears
to state partial coverage. The scan reads each round body in 12 ways:
raw, entity-decoded, and compatibility-folded, each with tags kept and
stripped three ways. A partial found in any reading counts.
- The whole review history is in view.
- The changed-file list is present, well-formed, non-empty, and within
the 100-file window.

A statement that reaches the head always wins, so a stated partial still
blocks. A pull request that ever had a partial round goes to the
maintainer. When no table stands in, the digest prints the reason as `NO
FILE TABLE STANDS IN: <reason>`.

The rule is stated in `GOVERNANCE.md` "PR Review Etiquette", in the
`pr-review-conduct` Merge Gate (source and generated copies), in the
runbook, in `scripts/README.md`, and in the script's own docstring and
exit-45 message.

Measured live with this branch: #2102, #2094, #2088, #2081, #2076,
#2050, #2046, #2039 and #2033 read `coverage=table`. #2098, which
carries the marker, still reads `full`.

## Known limits, all failing closed unless noted

- **Glob rows.** Copilot sometimes collapses generated skill copies into
glob rows such as `.../skills/*/references/*.md`, so on those pull
requests the table does not match and they go to the maintainer. #2053
and #2021 are examples.
- **Marker quoted near digits.** A round that quotes the marker next to
digits reads as a partial. That includes this pull request's own first
round, so this PR goes to the maintainer.
- **Early-closed fences.** A fence that Markdown closes early, through a
list item or an HTML block, masks a real table after it.
- **Prose partials (fail-open).** A partial worded in prose that none of
the patterns anticipate can still pass. The scan narrows this gap but
cannot close it.
- **Malformed marker before a full one (pre-existing, not introduced
here).** On the head, a malformed marker followed by a well-formed full
marker is read as full by the existing coverage reader.
- **Unpinned readings.** Tests do not pin each of the 12 readings
individually, the echoed snippet can carry raw control characters into
the digest line, and two docstrings overstate slightly. None of these
affects a verdict.

## Verification

- `python3 -m unittest discover -s tests`: 1841 tests pass. The new
tests were mutation-checked, and each guard fails its test when removed.
- ruff, mypy, the prose gate, markdownlint, `build_dist.py --check`,
`spec/validate.py`, and the eol gate are clean.
- The local strict review ran across several rounds, and the last pass
found no fail-open. Its differential fuzz over 150k bodies found nothing
the previous commit flagged that this one misses. Its receipt is
recorded.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@ptr727
ptr727 merged commit 6c56130 into develop Sep 29, 2026
11 checks passed
@ptr727
ptr727 deleted the feature/auto-1910 branch September 29, 2026 21:30
ptr727 added a commit that referenced this pull request Sep 29, 2026
…pilot Coverage at Balanced Effort (#2105)

## Summary

Promotes develop to main, carrying 14 pull requests:

- [#2076](#2076) Read the
Guard's Token Fallback With a Bash-Aware Context Scan
- [#2081](#2081) Keep the
Guard's Quote Mask When an Escape Sits Outside the Loop Condition
- [#2094](#2094)
Reconfigure pr_review.py's Console Streams to UTF-8
- [#2088](#2088) Guard
check_settings' Live Read of the Repository Response
- [#2102](#2102) Skip a
Blockquoted List Marker in the Prose Gate's Dash Rule
- [#2104](#2104) Accept a
Head Round's Exact File Table as Copilot Coverage at Balanced Effort
- [#2098](#2098) Quote the
Review Engine's Remaining Bare Shell Placeholders
- [#2050](#2050) Hand
Unstated Coverage to the Maintainer Instead of Prescribing a Re-Request
- [#2046](#2046) Report a
Copilot Review Request That Records Nothing Instead of Waiting It Out
- [#2053](#2053) State the
Python Build Profile by Its Trait, Not One of Its Forms
- [#2068](#2068) Strip the
CR From configure.sh's Environment-Name Encoding
- [#2033](#2033) Bound the
Guard's Stdin Descriptor Before int()
- [#2036](#2036) Guard
ruleset_id() Against Malformed Array Elements
- [#2039](#2039) Strip the
CR From configure.sh's Workflow-Model Read

The lead change is #2104. Copilot code review's default effort moved
from Lite to Balanced on 2026-09-28, and Balanced rounds write a
per-file table but almost never a coverage statement. `pr_review.py` now
accepts a head round's file table as coverage when it names exactly the
changed files and no Copilot round on the pull request has ever stated
or appeared to state partial coverage. Before this, every pull request
reviewed at Balanced effort parked on a maintainer decision. The other
pull requests are guard, configure, prose-gate, and review-engine fixes
that were waiting on that decision or landed earlier.

#2076 leaves #1910, #1958, and #1959 open, per #1960.

Closes #1998
Closes #2091
Closes #1260
Closes #1190
Closes #1652
Closes #2047
Closes #1904
Closes #1947
Closes #1882
Closes #2028
Closes #1987
Closes #1881

🤖 Generated with [Claude Code](https://claude.com/claude-code)
ptr727 added a commit that referenced this pull request Sep 30, 2026
…it-Loop Rule (#2114)

## Summary

Rule 7 of `gh-write-guard.py` stripped no heredoc body on a line holding
`((`, so a comparison in a real heredoc's body could vouch for a wait
loop whose own condition carried no bound. This reads such a line once
as opening nothing and once per `<<` whose tag the rule accepts and
whose non-empty body a later line closes, and denies where any reading
holds an unbounded wait.

- **Merged with develop's two-reading opener (#2076).** The forks run on
the comments-dropped `_context_lex` reading, gated on the `#`-as-text
reading finding an opener, and the `_operator_tokens` line reader forks
the same way. Both reading sets are judged together.
- **Reading limit, per decision #2066.** Past 16 readings the command is
denied unread only when it names both `sleep` and a loop keyword, the
keyword matched in its case as bash reads one. A `<<` whose body is
empty forks nothing, since that reading is the same text as the one
stripping nothing.
- **Unread allow.** A command naming no `sleep` once its line
continuations, quotes, backslashes and `$` signs are removed is allowed
without building readings, which covers `sl$'e'ep`.
- Module docstring item 7 and `host-setup/agent-safety/README.md`
requirement 7 state the same.

## Known residual

Past the limit, a lowercase `while` or `until` anywhere in the text
counts, heredoc bodies and quoted arguments included, so five numbered
heredoc writes whose prose says "retry until green" followed by `sleep
2` are denied. That is the safe direction and follows from the keyword
gate #2066 chose.

## Filed from the local review passes

Pre-existing on develop, each constructed and reproduced there: #2111,
#2112, #2113, plus new shapes commented onto #2062 and #2063.

## Verification

`--selftest` passes, with a revert of each fix failing its new row.
`unittest discover -s tests` (1856 OK), ruff format and check, mypy,
prose lint against `origin/develop`, the eol gate, `spec/validate.py`
and `build_dist.py --check` are clean. Three `local-strict-review`
passes were recorded, the third reporting no regression across about
15,000 generated commands compared against develop.

Closes on promotion: #2059

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Safety checks now account for multiple possible heredoc
interpretations when evaluating commands. Commands with an unbounded
wait are denied if any interpretation contains one; ambiguous commands
that exceed the reading limit are denied when they include both `sleep`
and a loop keyword.
* **Documentation**
* Updated the Requirement 7 description to explain how these cases are
evaluated.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comments Permits the comment lines the pull request adds or edits, which the prose gate otherwise refuses

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants