Normalize version/value args out of shell approval verb chains#1388
Merged
Aaronontheweb merged 3 commits intoJun 10, 2026
Merged
Conversation
ShellSyntaxTree's greedy verb walk (SPEC §6.1) folds a lowercase-leading version token such as `v0.4.2` into the verb chain (`git tag v0.4.2`), while a digit-leading `0.4.2` stops the walk and lands in Args (verb stays `git tag`). Because ShellApprovalMatcher compared the full `clause.Verb.Joined` for exact equality against persisted grants, the same intent produced two different verbs: `git tag 0.4.2` matched a standing `git tag` grant and ran instantly, while `git tag v0.4.2` missed it and re-prompted. SPEC §6.1.1 warns that `Clause.Verb` is a convenience hint, not a security contract, and that consumers must do their own verb normalization. Extend the issue netclaw-dev#1331 call-specific-value rule (bare integers) to also cover version-shaped tokens, and apply it on both verb-chain paths so they normalize identically: - ExtractCandidatesViaBashParser (the gate) and ReconstructClauseText (the persisted/display pattern) run the verb tokens through TrimTrailingValueTokens, dropping trailing bare-integer and version-shaped tokens while always retaining the command word. - IsVersionShapedToken requires a dot after the optional v/V+digit, so v0.4.2 / 1.0.0-beta.3 / 1.0+build collapse but v2, branch names, and hex SHAs are preserved. - Verb equality matching is unchanged, so no other grant is broadened. `git tag v0.4.2` and `git tag 0.4.2` now both normalize to `git tag`, and a freshly-granted version generalizes across versions instead of pinning to the one approved. The fix targets the POSIX BashParser path; the Windows legacy ShellTokenizer path takes a different route and is unchanged (tests are POSIX-gated accordingly).
Aaronontheweb
commented
Jun 10, 2026
Replace the version-shape predicate (and the bare-integer special case it composed with) with a single morphological rule: a token is a call-specific value iff it is not a flag, not path-shaped, and contains a digit. This deletes IsVersionShapedToken and IsBareIntegerToken in favor of one IsCallSpecificValueToken. The shape taxonomy approach accretes special cases (integers, dotted versions, next SHAs, IPs, dates...) while still mis-drawing boundaries: `git checkout v2` was preserved but `git checkout v2.0` stripped, and alpha-leading SHAs (`git show aa211dc`) never normalized, so a standing `git show` grant could not cover the dominant SHA use-case. Under the digit rule versions, SHAs, range refs, IDs, and digit-bearing ref names all normalize uniformly. Boundaries that keep the rule safe: - Trailing-only trim with a one-token floor: mid-chain tokens (`aws s3 ls`) and command heads (`python3`) are never touched. - Flags are exempt (`-3`, `--max-count=10` carry intent, not values). - Path-shaped tokens are exempt so digit-bearing paths still reach directory scoping and display patterns. - All-alpha operands (branch names, package names) are intentionally not classified: no shape rule distinguishes them from subcommands, and mis-stripping a subcommand silently widens a grant.
Documents the implemented value-token normalization in the "Shell command pattern matching" requirement: the bare-integer stop condition (issue netclaw-dev#1331) generalizes to one morphological rule (non-flag, non-path token containing a digit), and trailing value tokens folded into the verb chain by greedy extraction are trimmed identically on the gate and persisted-pattern paths. The prior "Non-bare numeric tokens are preserved" scenario no longer holds under the generalized rule and is replaced by "Digit-bearing operand terminates the pattern" (docker run --name test123 --port=8080 persists `docker run --name`), now pinned by a test. Scenarios added for version-prefix parity, folded SHA trimming, and the trailing-only boundary. OpenSpec change: shell-approval-value-token-normalization (proposal, design, delta spec, tasks; synced to main spec, archival pending PR merge).
Aaronontheweb
commented
Jun 10, 2026
| [InlineData("git log v0.4.1..dev", "git log")] // range ref is a value | ||
| [InlineData("git push origin main", "git push origin main")] // all-alpha operands are unclassifiable by shape -> preserved | ||
| [InlineData("aws s3 ls", "aws s3 ls")] // mid-chain digit token is not trailing -> untouched | ||
| public void ExtractCandidateVerbs_trims_digit_bearing_tokens_trailing_only(string command, string expected) |
2 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes an approval-gate inconsistency where semantically-identical shell commands were gated differently based on the leading character of a version argument.
ShellSyntaxTree's greedy verb walk (SPEC §6.1) folds a lowercase-leading version like
v0.4.2into the verb chain (git tag v0.4.2), but a digit-leading0.4.2stops the walk and lands in Args (verb staysgit tag). BecauseShellApprovalMatchercompared the full verb chain for exact equality against persisted grants,git tag 0.4.2matched a standinggit taggrant and ran instantly whilegit tag v0.4.2missed it and re-prompted.This extends the issue #1331 call-specific-value rule (bare integers) to version-shaped tokens, applied on both the gate path (
ExtractCandidatesViaBashParser) and the persisted/display path (ReconstructClauseText) so they normalize identically.git tag v0.4.2andgit tag 0.4.2now both normalize togit tag.Verification
Netclaw.Security.Tests+ 311 actor approval/dispatch tests pass.ShellApprovalMatcherTestscases (parity, persist-path, IsApproved scenarios, conservatism boundary).Draft — open review items
A high-effort review surfaced follow-ups to triage before marking ready:
openspec/specs/tool-approval-gates/spec.md("Shell command pattern matching") still lists only flag/path/URL/bare-integer as stop conditions; needs a version-token delta.IsVersionShapedTokenmatches IPv4/dates (8.8.8.8,2024.06.01); for network commands the destination operand gets normalized away.breakdrops trailing flags —git tag 0.4.2 --signpersistsgit tag(order-dependent); considercontinuevsbreak.IsDigitvsIsAsciiDigit).