Skip to content

fix(cron): run command crons on hosts whose /bin/sh is bash - #7414

Open
SebastianYuSun wants to merge 1 commit into
kirodotdev:mainfrom
SebastianYuSun:fix/cron-command-shell-brace-off
Open

fix(cron): run command crons on hosts whose /bin/sh is bash#7414
SebastianYuSun wants to merge 1 commit into
kirodotdev:mainfrom
SebastianYuSun:fix/cron-command-shell-brace-off

Conversation

@SebastianYuSun

@SebastianYuSun SebastianYuSun commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Problem / Motivation

Every command cron is refused on a Linux host whose /bin/sh is bash — the
default on AL2023, RHEL and Fedora — with ❌ No POSIX shell available to run this command cron, and the job auto-pauses after five consecutive failures.

There is no host-side workaround: _resolve_command_shell() consults only
/bin/sh and /usr/bin/sh, so installing dash elsewhere does not help.

Why it matters

The command cron surface is unusable on the most common Linux configuration.
The failure is silent-ish in the worst way: the job is accepted at storage time,
fails at every tick, and then disables itself, so a user sees a scheduled job
that simply stopped happening.

What changed (motivation → approach → change)

Symptom. _resolve_command_shell() returns None on this host even though
/bin/sh exists and is trusted.

Root cause. _shell_is_posix_strict() accepts a candidate only if it
preserves the literal x.{a,a}, and it probes exactly one invocation form,
sh -c. bash expands to x.a x.a, so both trusted candidates fail. Measured
against main on this host:

readlink -f /bin/sh                   -> /usr/bin/bash
/bin/sh -c 'echo x.{a,a}'             -> x.a x.a      (probe fails)
/bin/sh +B -c 'echo x.{a,a}'          -> x.{a,a}      (probe's required literal)
_resolve_command_shell()              -> None
run_command_sandboxed("echo hello")   -> error, exit -1, "No POSIX shell available"

Why not just accept bash. The property the probe defends is real: the
storage-time vet gate tokenizes the command once, so a runtime re-expansion
widens what a deny-list can see. And bash brace expansion is a composition form the
vet gate never refuses — $(...), $'...', backticks, a non-plain ${...},
positional parameters, loops and unresolved variable references are all refused
there, but _CRON_BRACE_EXPANSION_RE matches ${...} parameter expansion only,
and a bare {a,a} carries no $. Measured:

_vet_shell_command("echo x.{a,a}")         -> None  (allowed)
_vet_shell_command("set -B; echo x.{a,a}") -> None  (allowed)

So merely switching expansion off at the shell would leave a stored command able
to switch it back on.

Approach — two halves, the first is what makes the second safe.

  1. mcp_cron: refuse bash brace-expansion syntax at storage time, next to the
    composition forms already refused there, with the same "ship a script job"
    remedy. The refusal reads QUOTE STATE rather than matching a pattern, because
    bash needs the braces and the separator unquoted but not the alternatives:
    {w,"w w"}, {w,'w w'}, {w,$'w w'} and {w,w\ w} all expand, so a
    whitespace-free character class exempts exactly the live shapes. Three further
    properties are load-bearing, each measured rather than reasoned:

    • whitespace disqualifies a group only when bare, and only space, tab and
      newline count — bash still expands across form feed, vertical tab, CR and NBSP,
      all of which str.isspace() calls whitespace. Since the disqualifier is what
      makes the scan ALLOW, a generous whitespace class fails open. Bare space is
      what keeps find -exec {} ;, echo {print} and awk '{print x, y}' allowed;
    • nesting is counted{{x}h,h} expands (echo p{{x}s,s}qp{x}sq psq),
      and stopping at the first } reads the outer group as separator-free;
    • two levels are scanned: the command as written, and the command with quote
      delimiters removed, which is what a nested shell receives. sh -c "cat ~/.a{w,w}s/creds" is refused because the inner shell re-parses the braces bare,
      and bash -c "cat ~/.ss{h","h}/KEY" is refused because concatenating two quoted
      runs puts the separator outside both while the braces sit inside — no
      single-level rule can see that, and the inner shell expands the reconstructed
      word. Stubbing this scan out shows it is the only refusal in
      _vet_shell_command covering either.

    The price is one deliberate over-refusal: a whitespace-free single-quoted
    group such as awk '{a,b}', since single quotes survive one level of
    double-quoted nesting. The double-quoted spellings are refused for cause.
    Targeting the braces rather than the switch
    is deliberate: a set -B / shopt -s braceexpand denylist leaks (eval "set -B" reaches the same state), while a command with no braces left has
    nothing to expand.

  2. cron_script: accept a trusted shell invoked with brace expansion off (+B).
    The plain form is probed FIRST, so dash / ash / a real POSIX sh resolve
    exactly as they do today and their argv is byte-for-byte unchanged. The form
    that PASSED is recorded and re-used by the executor through one shared argv
    builder — previously the probe and the executor each spelled [shell, "-c", ...] separately, so accepting a form in one without the other would have left
    the probe reporting strict while the command ran expanded. The record is
    written only after a form passes, so a concurrent cron never observes a form
    that is still being tested, and a fully refused shell leaves no record behind.

  3. mcp_cron: re-run the composition scan at fire time for command jobs,
    not only at storage time. This is what makes half 1 reach the installed base:
    vet_job_at_fire_time exists because "a policy tightened AFTER scheduling
    would never be re-evaluated", and it already re-scanned a script body
    while a command body got only the governance ceiling. Measured, the ceiling
    ALLOWS set -B; cat ~/.a{w,w}s/credentials while the storage-time scan
    refuses it — so without this, a command stored before the refusal existed
    still runs after it, on the very executor half 2 newly admits. Deny semantics
    at that seam: the run fails, the job is kept, and the refusal is audited
    under its own cron_command_body scope, mirroring cron_script_body.

Windows still refuses command crons by design; that path is untouched. Its
rationale in docs/guides/windows-install.md no longer cites brace expansion,
since that is now refused at storage time and on every fire — the refusal rests
on "a shell whose language is wider than the one the vet was written against",
which is the reason the code gives. docs/system-specs/modules/governance.md
is updated in this commit too: its fire-time contract enumerated the capability
gate + the commands ceiling, and there is now a third check.

Installed-base impact, stated plainly

The fire-time re-vet is what closes the legacy-store hole, and it also means the
newly-refused shapes apply retroactively, not only to newly-authored jobs.
Concretely: a stored grep -E '[0-9]{1,3}' cron that has been running fine
under dash will now fail on every fire until it is rewritten — audited, with
the remedy in the message (grep "[0-9]\{1,3\}" for a BRE tool), but permanent
until someone edits the job. That is deliberate and I think it is the right
trade, since the alternative is a gate that never reaches the jobs most likely
to predate it, but it is a behaviour change for hosts that were working, not
merely an import-path cost, and it probably belongs in the release notes.

Tests

7 of the added assertions fail on unfixed main and pass with the change. The four
quoted / escaped-whitespace payloads are additionally measured as ALLOW from
_vet_shell_command under a whitespace-free rule, end to end, before the scan
replaced it — they were live, not hypothetical.

test/test_mcp_cron_security.py — brace-expansion payloads added to
MALICIOUS_COMMANDS, each hiding a path whose literal text the credential scan
cannot see: cat ~/.a{w,w}s/credentials, cp ~/.ss{h,h}/id_rsa /tmp/key, the
sequence form cat ~/.s{s..s}h/id_rsa, the nested cp ~/.a{w,{w}}s/credentials,
the set -B re-enable route, the four quoted / escaped-whitespace spellings, and
the nested-shell form sh -c "cat ~/.a{w,w}s/credentials". Brace-bearing but
non-expanding commands are added alongside so the refusal cannot widen into
find -exec {} ; / awk '{print x, y}'.

_BRACE_SHAPES_MEASURED_AGAINST_BASH carries the whole decision table, every row
run rather than reasoned: the same word is echoed under bash -c and bash +B -c
and the outputs compared — a difference is an expansion, identical output is quote
removal only — and the nested level is measured the same way through
bash -c 'bash -c "..."'. 29 shapes, 0 holes, 1 over-refusal. The assertion is
one-directional by design: a shape some shell expands MUST be refused, since letting
one through means the composed word reaches the executor unseen, while refusing a
shape every shell leaves literal is only a false positive. A third column pins the
exact over-refusals on top, so a later change cannot trade one for a hole by
loosening a shape nobody was watching. test_brace_scan_keeps_a_nested_shell_covered
pins the nested-shell coupling separately, including the quote-concatenation
spelling, so a change that "finishes" the quote-awareness fails on the test naming
the reason not to.

Line continuations, and four pre-existing bypasses they open. A backslash-newline
is deleted before the shell parses, so it splits whatever token a static check
matches on and the shell rejoins it afterwards. Every refusal in _vet_shell_command
was bypassable this way. Measured split-vs-control, all controls refused:

trigger split by the continuation before control, unsplit
the literal path itself — cat ~/.ss\<nl>h/KEY, no composition form at all allowed refused
$\<nl>( — command substitution allowed refused
$\<nl>{A} — non-plain ${…} allowed refused
$\<nl>'\x73\x73' — ANSI-C quoting allowed refused
{s.\<nl>.s} — this PR's brace scan, sequence form allowed refused

The first four are pre-existing on main and none is bash-specific: POSIX requires
the removal, and sh resolves the split path exactly as bash does — so they are
reachable on main on any host. The first needs no composition syntax at all.

The fix is one normalization ahead of every scan, not five per-rule patches, so each
rule sees the string the executor will; it is scan-only, and quote-aware because inside
single quotes a backslash is literal and the shell never joins those halves. An escaped
backslash is likewise not a continuation — echo a\\<nl>b is two commands, verified.
7 of the added assertions fail without the normalization. Happy to split the four
pre-existing ones into a separate PR against main if you'd rather they move
independently of this one.

Not fixed here

Brace expansion is not the only unrefused composition form once bash is the
executor, and the review lane was right to challenge the premise — the wording above
is corrected accordingly. Extglob composes a path out of text no static scan sees,
and +B does not disable it (+B is brace expansion only). Measured against a real
fixture, with .q@(|x)r standing in for the credential directory:

route result
`sh +B -c 'shopt -s extglob; echo .q@( x)r/f'`
`sh +B -c 'shopt -s extglob; eval "echo .q@( x)r/f"'`
`BASHOPTS=extglob bash +B -c 'echo .q@( x)r/f'`
`bash +B -c 'shopt -s extglob; export BASHOPTS; sh -c "echo .q@( x)r/f"'`

So refusing eval, or shopt, would each be insufficient: the switch can be set
entirely outside the command text, which is the same reason the brace refusal targets
the braces rather than set -B. The fail-closed fix targets the pattern syntax, and
that has a cost this PR should not spend on the maintainers' behalf — a quote-blind
refusal of @( +( !( ?( *( also takes awk '!(NR%2)' and awk '{print 2*(x+1)}'.
A precise alternative is to teach the existing _glob_could_reach_credentials about
extglob so only words that can actually reach a credential path are refused, which is
more work and its own review.

Correction to my earlier framing here, since the design lane caught it and it
matters for who owns this.
I first wrote that the route becomes reachable because
of this PR. That is wrong: it pre-exists on any host that merely has bash, because
the cron command can invoke bash explicitly and what /bin/sh is then does not matter
— verified, env -i /bin/sh -c 'bash -c "shopt -s extglob; eval …"' composes the path,
and so does the same with bash --posix outside. So the extglob fix should not be
gated on this PR, and it is more urgent than "only if this merges" implied, not less.
What this PR does change is narrower: it makes bash the cron executor by default on the
most common distros while the vet gate models only POSIX-sh composition forms.

test/test_cron_script_more_coverage.py

  • test_a_brace_expanding_trusted_shell_is_accepted_with_expansion_off pins both
    the acceptance and the probe ORDER (plain form first, then +B), and that
    _command_argv then returns the +B form.
  • test_a_posix_strict_shell_keeps_its_exact_argv pins the no-regression half:
    a strict shell still gets [shell, "-c", command].
  • test_the_spawn_uses_the_form_the_probe_proved asserts the executor's real
    spawn argv carries +B — the gate on the two-literals defect.
  • test_a_shell_that_expands_in_every_form_records_no_form pins that a refused
    shell leaves no brace-off record for a later caller.

Measured: 354 passed, 1 skipped across
test_mcp_cron_security.py, test_cron_script.py,
test_cron_script_more_coverage.py. With the two source files reverted to main
and the tests kept, 7 failed.

Manual verification

Executed on an AL2023 host with /bin/sh -> bash and no dash installed: before
the change _resolve_command_shell() returns None and
run_command_sandboxed("echo hello") refuses; after it, /bin/sh resolves with
the brace-off form recorded and the same call runs the command. The shell
measurements in the table above are unstubbed subprocess runs; the probe
measurements stub wrap_argv to the identity only because this host has no
user-namespace sandbox backend, which would otherwise fail-close for a reason
unrelated to the behaviour under test.

Screenshots / video

N/A — no user-visible UI change. The diff touches the cron command shell
resolver, one storage-time vet predicate and their tests; there is no panel,
component, layout or theme surface involved.

Related Issues

Fixes #7412

Pattern harvest

Rule candidate: review-prompt
Pattern: a probe and the executor it is meant to certify build their argv from
two separate literals, so the probe can keep passing while the executed form
diverges. Any "prove this invocation is safe, then run it" pair should share one
builder.

Checklist

  • At most two commits (one is the norm), with a Conventional Commits title (feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)
  • Existing tests pass and new tests added for new functionality
  • Self-review completed; code follows project style guidelines
  • Documentation updated (if applicable)
  • No secrets, credentials, or internal references in the diff

Contribution License Agreement

@SebastianYuSun
SebastianYuSun requested a review from a team as a code owner September 1, 2026 01:08
@github-actions github-actions Bot added readiness: checking Automated validation is still running fork Pull request from a fork (external contributor) labels Sep 1, 2026
@SebastianYuSun
SebastianYuSun force-pushed the fix/cron-command-shell-brace-off branch from 16f3684 to d94950d Compare September 1, 2026 01:16
@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running and removed readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention labels Sep 1, 2026
@SebastianYuSun

Copy link
Copy Markdown
Contributor Author

Coverage Gate is red because of one cancelled shard, and the cancellation is a
job timeout rather than a test failure. Posting the measurements so a re-run of
that single job is all that is needed.

Coverage Gate is an aggregator — its log says exactly this and nothing else:

coverage-combine=skipped  frontend-test=success  frontend-coverage-merge=skipped
backend-test=cancelled  only_backend=true  only_frontend=false
::error::backend-test=cancelled -- failing closed.

backend-test is timeout-minutes: 30 with fail-fast: false, so a lone
cancelled shard whose siblings all passed can only be the 30-minute wall.

On this head, Backend Tests (3.12, 1) was cancelled at 30.25 min while its
three siblings passed at 27.87 / 27.33 / 28.32 min. The lane is not near the cap
because of this PR — measured on main with no PR diff at all, run 33456335135
took 28.35 min on (3.12, 3) and 27.37 min on (3.12, 2). That is about
1.6 minutes of headroom on a 30-minute limit, so whichever shard draws a slower
runner crosses it.

The same boundary was crossed an hour earlier on an unrelated PR, on a different
shard: (3.12, 2) cancelled at 30.27 min there.

This diff cannot account for it. It adds 7 tests across
test_cron_script_more_coverage.py and test_mcp_cron_security.py; those two
files plus test_cron_script.py run in 6.5 s in total locally. The one
behaviour change that touches a subprocess — the shell probe now tries a second
invocation form — does not fire on the runners: /bin/sh is dash on
ubuntu-latest, so the plain form passes first and exactly one probe process is
spawned, as before. The second form is reachable only on a host whose /bin/sh
is bash.

The branch is up to date with base (behind 0), so a rebase would change no
result and would only reset the fork workflow approval. I have deliberately not
touched the diff or force-pushed.

Could a maintainer re-run the cancelled Backend Tests (3.12, 1) job? Happy to
open a separate issue about the shard-timeout headroom itself — the matrix comment
already notes that adding a group means extending the list and bumping
SHARD_COUNT, which looks like the intended remedy.

@bolichen97

Copy link
Copy Markdown
Collaborator

Audit note — #7608 is being closed in favour of this PR

You are the surviving implementation; #7608 is being closed.

What the two shared

Both PRs add the same predicate to the same function at the same insertion point: a module-level bash-brace regex declared right after _CRON_BRACE_EXPANSION_RE (origin/main:mcp_cron.py:152) plus an if <re>.search(command): return "Error: cron command blocked: ... brace expansion ..." branch dropped into _vet_shell_command in the gap between the ${ branch (ends origin/main:620) and _CRON_POSITIONAL_PARAM_RE (origin/main:621), with the identical 'ship a script job' remedy. I imported kiro_crew.mcp_cron and confirmed origin/main returns None for every brace payload, so both are adding the same missing refusal from the same starting state; only one of the two branches can exist there. That is 100% of #7608 (one file, +27/-13, no test file at all -- its body's Tests bullets are unpinned) and it is half 1 of #7414, whose other half (cron_script.py +91/-25: the two-form +B probe, _probe_one_form, the shared _command_argv, _BRACE_OFF_SHELLS) plus 116 lines of tests is what actually fixes issue #7412 and has no counterpart in #7608. #7608's only non-duplicated content is black reformatting of untouched lines in a file baselined at .github/black-baseline.txt:304 that it does not prune -- churn, not wanted work. the first adjudication's rationale is however wrong on one point I disproved: #7414's regex is NOT strictly broader. Running both against the real _vet_shell_command, #7414 blocks the alphabetic-range class (cat ~/.s{s..s}h/id_rsa) that #7608 allows, and #7608 blocks the nested-comma class (cp ~/.a{w,{w}}s/credentials, real bash -> .aws .a{w}s) that #7414 allows, because #7414 excludes { from its inner character class. Neither is a superset, so the redundancy survives but the survivor must not merge as-is: #7414 is the very change that makes a brace-expanding bash the cron executor, so the class it misses goes from inert on main to live under it.

What #7608 had that this PR does not

Please pick these up (or say they are not wanted) so they do not disappear with that branch:

src/kiro_crew/mcp_cron.py -- #7608's character class. Before closing #7608, widen #7414's _CRON_BASH_BRACE_EXPAND_RE from \{[^{}\s]*(?:,|\.\.)[^{}\s]*\} to \{[^}\s]*(?:,|\.\.)[^}\s]*\} (drop { from both inner classes, keeping #7414's (?:,|\.\.) alternation) so it refuses the nested-comma form as well as the alphabetic range. Verified: the widened pattern blocks cp ~/.a{w,{w}}s/credentials /tmp/x, set -B; cat ~/.ss{h,{h}x}/id_rsa, cat ~/.s{s..s}h/id_rsa and cat ~/.a{w,w}s/credentials while still allowing find /tmp -name '*.log' -exec rm {} ;, echo {print}, awk '{print x, y}', awk '{print $1,$2}' and echo {a b,c}. Also add the two nested payloads to MALICIOUS_COMMANDS in test/test_mcp_cron_security.py, since #7414's existing four payloads do not cover that shape. This is a merge blocker for #7414, not optional: #7414's cron_script._shell_is_posix_strict two-form probe is what makes a brace-expanding bash the cron executor, so the class its regex misses becomes reachable exactly under the form #7414 adds. Nothing else in #7608 needs carrying: its error string is a weaker paraphrase of #7414's, it adds no test, and its remaining lines are black churn on baselined, untouched code.

This PR still needs work: CONTINUE_DEV

Neither side has merged, so nothing is superseded. git grep BASH_BRACE origin/main is empty and origin/main's mcp_cron.py still carries only the ${-form _CRON_BRACE_EXPANSION_RE; importing the module confirms all five brace payloads return None today. The one change origin/main made to _vet_shell_command since the earlier rounds is the enabled_rule_ids threading around line 658, well below both PRs' insertion gap at 613-621, so neither insertion point moved and no landed cron commit in the landed-commit index for main touches brace vetting. #7412 is an open ISSUE (the issue/PR reference check), i.e. the shared motivation, not a covering change.


From a repository-wide duplicate/overlap audit of every pull request open against main (2026-09-02, 330 PRs, one reviewer per PR). Each PR was read as its full merge-base diff plus its description and every comment and review, then compared against each candidate PR's own diff and against origin/main at 1a765b88ceb7. This PR is not being closed — the note is informational. If the reading is wrong, please correct the reasoning rather than just the conclusion.

@SebastianYuSun
SebastianYuSun force-pushed the fix/cron-command-shell-brace-off branch from d94950d to e4506d4 Compare September 2, 2026 20:28
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 2, 2026
@SebastianYuSun

Copy link
Copy Markdown
Contributor Author

Rebased onto a030091b4; head is now e4506d47. Two things changed since the last review.

The Coverage Gate red is gone at the source. It was never this diff -- backend-test was capped at 30 minutes while the coverage-bearing 3.12 shards ran 27 to 30, so whichever shard drew a slower runner was cancelled and Coverage Gate failed closed on the missing artifact. 7827b0392 raised that cap to 40 with a comment describing the same failure mode, and #7780 collapsed the matrix to 3.12. Both are on main, so this rebase picks them up.

A nested-brace bypass in this PR's own regex is now closed. The inner character classes were [^{}\s]*, which excluded { -- so an outer brace pair containing an inner { read straight past the screen while bash still expands it:

$ echo .a{w,{w}}s
.aws .a{w}s
$ echo .ss{h,{h}x}
.ssh .ss{h}x

The first word of each expansion is the credential directory, so cp ~/.a{w,{w}}s/credentials /tmp/x would have passed the gate. The classes are now [^}\s]*, which keeps the whitespace-free requirement that lets find -exec {} ; and awk '{print x, y}' through while refusing the nested comma form. Two cases were added to MALICIOUS_COMMANDS to pin it.

This matters specifically because of the other half of this PR: the +B probe is what admits a brace-expanding bash as the cron executor in the first place, so the storage-time screen has to be airtight for shapes that only that executor would expand.

Local gates on the pushed head: black gate (repo wrapper, 4 files in scope), isort, flake8, mypy over src/kiro_crew/ all clean; scrub-lint stage 1 clean; 356 passed / 1 skipped across the three affected test files.

@SebastianYuSun

Copy link
Copy Markdown
Contributor Author

Backend Tests (Windows) (2) is red on e4506d47, and it is not this diff. Recording the attribution so the red is not left unexplained.

The failure is a wall-clock ratio assertion, not a behaviour failure:

test/test_denied_commands_security.py::TestNestedPayloadExtractionIsLinear::test_the_scan_is_linear_not_quadratic
E   AssertionError: 0.0438s -> 0.1351s looks super-linear
E   assert 0.13510329999996884 < (0.04379360000007182 * 3)

Ratio 3.08 against a bound of 3.0, i.e. it missed by 2.7 percent of the threshold on a shared Windows runner. Shards 1, 3 and 4 all passed.

It exercises security._nested_shell_payloads, which walks a token list. This commit touches four files -- cron_script.py, mcp_cron.py, test_cron_script_more_coverage.py, test_mcp_cron_security.py -- and none of them is security.py. I checked the one plausible way it could have been mine: the amend in this PR widened _CRON_BASH_BRACE_EXPAND_RE's inner classes from [^{}\s]* to [^}\s]*, and a widened class can cause backtracking, so a test named "the scan is linear not quadratic" was worth ruling out rather than assuming. It is a different code path -- that regex is in mcp_cron._vet_shell_command and is never reached from _nested_shell_payloads, which does no regex matching at all.

The linearity property itself came from #7122, later touched by #7013 and #7356, none of them mine.

I have not claimed a measured flake rate, because a Windows runner's wall clock is not reproducible here -- this is "not reproduced, platform mismatch" with the ownership paths above excluded, rather than a flake verdict. A re-run of that one shard is the cheapest way to settle it.

@SebastianYuSun

Copy link
Copy Markdown
Contributor Author

Blocker closed, and thank you for finding it — the nested-comma class was a real hole in this PR's regex, and your framing of why it matters here is exactly right: this PR is what makes a brace-expanding bash the cron executor, so a class that is inert on main goes live under it. It is now fixed on head e4506d47.

Your prescribed change, applied verbatim. The pattern is now the widened form, character for character:

pattern: \{[^}\s]*(?:,|\.\.)[^}\s]*\}
matches the required widened form: True

Both inner classes dropped {, the (?:,|\.\.) alternation is kept.

Your verified table, re-measured against the real _vet_shell_command on this head. brace_re is whether _CRON_BASH_BRACE_EXPAND_RE itself matched:

=== must block ===
  BLOCK  brace_re=True   cp ~/.a{w,{w}}s/credentials /tmp/x
  BLOCK  brace_re=True   set -B; cat ~/.ss{h,{h}x}/id_rsa
  BLOCK  brace_re=True   cat ~/.s{s..s}h/id_rsa
  BLOCK  brace_re=True   cat ~/.a{w,w}s/credentials

=== must allow ===
  ALLOW  brace_re=False  find /tmp -name '*.log' -exec rm {} ;
  ALLOW  brace_re=False  echo {print}
  ALLOW  brace_re=False  awk '{print x, y}' /tmp/f
  BLOCK  brace_re=False  awk '{print $1,$2}' /tmp/f
  ALLOW  brace_re=False  echo {a b,c}

One correction to the note, in your favour rather than against it. awk '{print $1,$2}' is not allowed by the gate — it is refused, but by _CRON_POSITIONAL_PARAM_RE, not by the brace rule. That refusal is pre-existing on main: the pattern there is r"\$[0-9@*#]|\$\{[0-9@*#]" and it matches the $1. So the property you were actually testing holds — the widening introduces no new false positive, brace_re=False on that command — but the command itself was already unavailable on this surface before either PR. Worth stating precisely so nobody later reads it as a regression this PR caused.

Tests. Both nested payloads are in MALICIOUS_COMMANDS (test/test_mcp_cron_security.py:208-209) with a comment recording the measured bash expansion (echo .a{w,{w}}s -> .aws .a{w}s, i.e. the first word IS the credential directory) and why the shape is reachable only under the +B executor this PR adds.

Nothing else from #7608 picked up, agreed on your reasoning: the weaker error string, and black churn on lines baselined at .github/black-baseline.txt:304.

Also confirmed on your CONTINUE_DEV point: git grep BASH_BRACE origin/main is still empty and main's _vet_shell_command still returns None for every brace payload, so nothing here is superseded. This head is additionally rebased onto a030091b4, which picks up 7827b0392 (backend-test 30 -> 40 min) and #7780 (matrix collapsed to 3.12) — that is what cleared the Coverage Gate red this PR was carrying, and it was never this diff.

@SebastianYuSun

Copy link
Copy Markdown
Contributor Author

Correcting my earlier triage of this red with measurements, because the honest verdict is weaker than what I wrote before.

What I claimed last time: that a Windows runner's wall clock is not reproducible here. That was an assumption, not a measurement -- the test is pure Python timing and runs on Linux fine. So I measured it.

Measured, 20 runs under CPU contention on Linux: STABLE 0/20. That is not a flake verdict. It is absence of evidence, so I am not claiming this is a flake.

Ownership, which is what I can settle. The failing assertion is
test/test_denied_commands_security.py:4496, exercising security._nested_shell_payloads. This PR's commit touches four files -- cron_script.py, mcp_cron.py, test_cron_script_more_coverage.py, test_mcp_cron_security.py -- none of them security.py or that test file. git log -S on both the assertion text and the code under test names #7122, #7149, #7013, #7356 and #7335; none is mine, so this is not the "my own already-merged work" case either.

staleness on both implicated paths returns REBASE_WONT_HELP (20 behind, upstream touched neither), so I am deliberately not rebasing again -- on a fork PR that would reset the workflow-approval gate and change nothing about this failure.

The part that may actually be worth your attention. 283625e16 -- "test: assert linearity structurally, not by wall-clock ratio (#7321) (#7335)" -- is already on main, so the repo has decided this assertion shape is wrong. Two wall-clock ratio assertions survived that cleanup:

test/test_denied_commands_security.py:4496   assert large < small * 3, ... "looks super-linear"
test/test_denied_commands_security.py:4517   assert large < small * 3, ... "looks super-linear"

Line 4496 is the one that failed, at ratio 3.08 against a bound of 3.0 -- 2.7% over, while shards 1, 3 and 4 passed. The test's docstring says the 3x bound is "generous ... so scheduler noise on a shared runner cannot red it"; on this runner it did. Widening the bound does not fix the shape, because it has to stay below the quadratic 4x signal while clearing worst-case noise, and those are not cleanly separated on a shared Windows runner. The assertion on the next line (assert large < 1.0) is the one that actually pins the ~13 s quadratic regression the class was written for, and it passed.

I have not touched any of it -- wrong file, not this diff's code, and finishing #7335's conversion belongs in its own change rather than riding along here. I have it written up and am happy to file it separately if you want, ideally checking with the #7335 author first since they chose the replacement shape.

For this PR: a re-run of Backend Tests (Windows) (2) is the cheapest way to settle it. The rest of the head is green so far -- 42 success, 5 pending, that one red.

@chenmingwei23

Copy link
Copy Markdown
Contributor

Two measurements from running this branch against main, offered as information rather than as review asks. Both are against head e4506d473.

1. One over-refusal the storage-time predicate introduces

jq '{a,b}' /tmp/in.json flips from ALLOWED on main to REFUSED on this head.

Mechanism: _CRON_BASH_BRACE_EXPAND_RE is quote-blind, and bash does not brace-expand inside single quotes, so that particular command could never have expanded at run time.

Mitigating context, which is why this is a note rather than a request: it is the fail-closed direction, and it matches how the vet's other rules already behave. awk '{print $1}' is refused on base for the same quote-blind reason, so quote-blindness is the established convention on this surface rather than something introduced here. Recording it as a known cost for you to weigh.

2. The failing Windows lane does not look like it is yours

The Backend Tests (Windows) (2) shard is the only failing check run, and it dies at test/test_denied_commands_security.py:4496 on a wall-clock ratio assertion:

E       AssertionError: 0.0438s -> 0.1351s looks super-linear
E       assert 0.13510329999996884 < (0.04379360000007182 * 3)

That file is not in this diff. It reads as runner-timing flake, so a re-run should clear it, and PR Readiness is likely just reflecting the same red.

@SebastianYuSun

Copy link
Copy Markdown
Contributor Author

Both measurements reproduce. Thank you for framing them as costs rather than asks -- that turned out to be the right call on the first one, and I want to record why with the measurement rather than just agreeing.

1. The over-refusal is real, and slightly broader than stated

Reproduced on e4506d473 vs origin/main, with the double-quoted and unquoted variants added:

command main this head does bash expand it?
jq '{a,b}' /tmp/in.json ALLOWED REFUSED no
jq "{a,b}" /tmp/in.json ALLOWED REFUSED no
jq {a,b} /tmp/in.json ALLOWED REFUSED yes
echo '{a,b}' ALLOWED REFUSED no

So it is not only single quotes -- double quotes suppress expansion too, and three of those four are genuine over-refusals this PR introduces.

Your precedent also checks out as stated: awk '{print $1}' is refused on main by _CRON_POSITIONAL_PARAM_RE, quote-blind, so quote-blindness is the surface's existing convention rather than something introduced here.

2. Why I am keeping it quote-blind rather than fixing the over-refusal

The obvious refinement -- skip braces that sit inside quotes -- is unsafe, because bash performs brace expansion before quote removal. Braces that straddle or contain a quote still expand, while a left-to-right quote tracker would read the region as inert:

echo x{a,b}y      -> 'xay xby'   expanded=True
echo 'x{a,b}y'    -> 'x{a,b}y'   expanded=False   <- only the fully-enclosing case
echo x{a,'b'}y    -> 'xay xby'   expanded=True
echo x{a,b}'y'    -> 'xay xby'   expanded=True
echo 'x'{a,b}'y'  -> 'xay xby'   expanded=True    <- tracker sees "all quoted"
echo x{'a','b'}y  -> 'xay xby'   expanded=True

Four of six expand with a quote inside or adjacent to the braces. The concrete consequence, using a neutral stand-in directory name so nothing sensitive goes in this comment:

$ bash -c "echo '.q'{x,x}'r/data'"
.qxr/data .qxr/data

The literal text carries no .qxr/data substring -- a static scan sees .q'{x,x}'r/data -- yet bash produces the assembled path twice. Substitute a real credential directory for .qxr and the shape is a path-assembly bypass. A quote-aware scanner that skipped '.q' and 'r/data' as quoted regions would admit exactly that. The current quote-blind rule refuses it; I verified the equivalent straddling shapes plus x{a,'b'}y and '{a,b}' are all REFUSED on this head.

Doing it precisely needs a tokenizer that models expansion ORDER, not quote matching, which is a bigger change than this PR should carry and wants its own review. So: accepted cost, kept fail-closed, and written up separately rather than left implicit. If you would rather I add a narrower mitigation, say so and I will -- I did not want to ship a half-measure into a security predicate on my own judgement.

3. On the Windows lane -- agreed on ownership, one correction on "flake"

Same conclusion as yours on whose code it is. I had traced it independently: test_denied_commands_security.py:4496 exercises security._nested_shell_payloads; this commit's four files do not include security.py or that test file, and git log -S on both the assertion text and the code under test names #7122, #7149, #7013, #7356 and #7335 -- none mine. staleness returns REBASE_WONT_HELP.

The one place I would not go as far as "runner-timing flake": I measured it, 20 runs under CPU contention on Linux, and got STABLE 0/20. That is absence of evidence rather than a confirmed flake, so I am recording it as "not reproduced" and leaving the flake question open.

One thing that may be more useful than a re-run: 283625e16 -- "test: assert linearity structurally, not by wall-clock ratio (#7321) (#7335)" -- is already on main, but two wall-clock ratio assertions survived that cleanup, at lines 4496 and 4517. 4496 is the one failing here, at ratio 3.08 against a 3.0 bound. The sibling assert large < 1.0 on the next line is what actually pins the ~13 s quadratic regression, and it passed. Finishing #7335's conversion looks like the durable fix; happy to file it separately, though it may belong with whoever chose the replacement shape.

@github-actions github-actions Bot added the readiness: action required A blocking check or review needs attention label Sep 8, 2026
@SebastianYuSun
SebastianYuSun force-pushed the fix/cron-command-shell-brace-off branch from aa2120e to 6202d65 Compare September 8, 2026 14:26
@SebastianYuSun

Copy link
Copy Markdown
Contributor Author

Both taken. 6202d65a7 — and the net effect is 60 lines smaller, which is the right sign for a review that says "this rides along as a second spelling".

The duplicate is deleted in favour of the export. You're right, and the sharpest part of the criticism is that I did it in the same PR whose _quote_states docstring argues against private copies of exactly this module's rules — I adopted _iter_shell_chars on your last round's finding and then wrote a second copy sixty lines away. Verified each fact before acting: helper at shell_normalizer.py:1304, exported at security/_exports.py:308, two real consumers (security/__init__.py:1169, :1615). Its docstring even carries the same measured context table I had derived independently — fold unquoted and double-quoted, preserve '…' and $'…' — which is a good sign the shared one is right and a bad sign for having rewritten it.

Behaviour across the swap: all six continuation payloads still refused, the bash differential still 29 shapes / 0 holes / 1 over-refusal, 205 tests in that module.

On the CRLF divergence: it's real, and it's the exported helper that departs from bash — I adopted it anyway, deliberately. Your clears-when offered "name a measured behavior the exported folder gets wrong" as the alternative route, and I have one:

$ cat crlf.sh          # line reads:  echo a\<CR><LF>b
$ bash crlf.sh | od -c
0000000   a  \r  \n   ...  crlf.sh: line 2: b: command not found

bash escapes the CR and lets the LF terminate, so \<CR><LF> is not a continuation — two commands. My copy folded \<LF> only and so matched bash; _continuation_width folds CRLF too.

I still deleted mine, because for a refusal scan over-folding is fail-closed: it can only join text the shell keeps apart, which makes this gate see more adjacency and refuse more, never less. A correct private copy plus a guaranteed drift risk is worse than one divergence in one place — and the divergence is now recorded in the call-site comment and the commit message rather than lost by adopting. If you'd rather the shared helper matched bash exactly on CRLF, that's a one-line change in _continuation_width and its own PR; I didn't touch it because two of its consumers are the main gate and narrowing what it folds is a loosening there, which is your call, not mine.

Separately, and the reason I re-tested rather than pointing at the SHA: round 11 added _vet_shell_command to vet_job_at_fire_time, so the O(n²) scan you blocked on now runs on every fire of every command job — an unbounded version would have moved from the import path onto the scheduler's hot path. Measured through the real function:

through vet_job_at_fire_time cost verdict
df -h 71 ms allow
4,900-char realistic command 260 ms allow
hostile 200,000-char legacy command 333 ms deny

The 71 ms is the whole vet, not just this scan, and it's once per tick per job. The bound itself is intact: 8k / 200k / 2M cost 188 / 347 / 1880 ms.

Noting for the record that Opus 4.8 has since cleared this head's predecessor (✅ no blocking findings on 96beb5c67), so as far as I can tell the DoS finding is closed on both our sides.

Two offers still open and unanswered, both structural rather than code: splitting the continuation fix into its own PR against main (four of its five bypasses pre-exist there on every host), and filing the extglob deferral as a tracked issue. Say go on either and I'll do it immediately.

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 8, 2026
@SebastianYuSun
SebastianYuSun force-pushed the fix/cron-command-shell-brace-off branch from 6202d65 to 83f9b1c Compare September 8, 2026 14:47
@SebastianYuSun

Copy link
Copy Markdown
Contributor Author

Both of the items that are mine are done. The third is the one I keep declining to do unilaterally, and I'll say plainly what that now costs you.

Owning specs, updated in this commit — you were right that AGENTS.md makes it mandatory (AGENTS.md:33: the owning spec moves "in the same commit when you change what it documents"). I verified both locations before touching them:

  • docs/system-specs/modules/governance.md — the fire-time contract enumerated a command job as "capability gate + the commands ceiling", which now understates enforcement by one check. It lists the third (cron_command_body) and states why the ceiling and the composition scan are distinct decisions that both have to be re-run: the ceiling authorizes who may run the command, the scan judges what it composes, and only the second moves when mcp_cron's refusals change.
  • docs/guides/windows-install.md:286 — it gave brace expansion as the reason Windows is refused, which this change retires. It now cites what cron_script.py:1956 actually cites: a shell whose language is wider than the one the vet was written against. I noted the retired rationale explicitly rather than silently swapping it, since the old example is the thing a reader might remember.

The installed-base regression: you're right, and my description was wrong to frame it as an import-path cost. That was a framing error of mine, not a code defect. Added an "Installed-base impact, stated plainly" section saying what actually happens — a stored grep -E '[0-9]{1,3}' cron that has been running fine under dash now fails on every fire until someone rewrites it, audited and with the remedy in the message, but permanent. I've also said there that it probably belongs in the release notes, which is the part that needs your call rather than mine.

Worth being explicit about why it can't be had one way only: the retroactivity is the property that closes the legacy-store hole you found last round. A fire-time re-vet that skipped pre-existing jobs would be exactly the gap. So the honest description is "this reaches jobs that predate it, both when that helps and when it hurts" — which is what the section now says.

Extglob: still not filed, and this is now the third clears-when it blocks. I've twice told you in this thread that I'd wait for your word before opening a public issue describing an unfixed credential-read route on main. A review lane's clears-when isn't the person I said I'd wait for, and breaking that to tick a box would make anything else I commit to here worth less. So it stays undone — but you should know the cost is now concrete rather than theoretical: two lanes can't clear, and if this PR squashes, the route survives only as prose in a merged description. One word and it's filed within the minute, with the measured route table.

Same standing offer on splitting the continuation normalization into its own PR against main.

Verification for this round, since I changed _vet_shell_command's internals last round and the fire-time path calls it: all four legacy payloads still refused at fire time — including the continuation one, which exercises the newly-adopted shared folder — each audited under cron_command_body, with df -h still firing. 205 tests in that module, differential still 29 shapes / 0 holes / 1 over-refusal. Rebased onto current main; that rebase again was not indicated by evidence (1 commit behind, no implicated path touched) and was taken only because the push resets approval anyway.

@SebastianYuSun
SebastianYuSun force-pushed the fix/cron-command-shell-brace-off branch from 83f9b1c to 27f1118 Compare September 8, 2026 15:08
@SebastianYuSun

Copy link
Copy Markdown
Contributor Author

One of the two spec files you name was stale and is now fixed; the other claim doesn't hold, and I'd rather say so than quietly "fix" a file that was already correct. 27f1118a0.

learn-cron-dashboard.md — confirmed and updated. Line 265 presented the fire-time body re-scan as script-only ("The script body is scanned as well — at authoring time and again at every fire, via _vet_script_file"), which is precisely the asymmetry this PR removes. It now states that a command body is re-scanned on the same terms under its own cron_command_body scope, and that the "denied on every tick until it is edited" property therefore applies to a stored command a newly-added refusal rejects — the installed-base consequence, in the doc rather than only in the PR description.

security.md — the claim as stated is not true of the file. You describe it as citing _vet_shell_command as the primary .ssh control. It does not mention _vet_shell_command at all:

$ grep -rln "_vet_shell_command" docs/
docs/system-specs/modules/governance.md

governance.md is the only spec that references it, and I updated that one last round (the fire-time enumeration gained the third check). What security.md says about cron is the opposite subject — its line 179 bullet, "Cron script bodies are not shell subjects", argues that a script body must NOT be routed through the shell gate, and this PR doesn't touch that. So there's nothing there to bring into line. If you meant a different passage, point me at it and I'll fix it; I'm not going to edit a correct file to clear a checkbox.

That makes three owning docs updated in this commit: governance.md (fire-time enumeration), windows-install.md (retired brace rationale), learn-cron-dashboard.md (the script/command asymmetry).

On your first Watch item — I agree it's yours to accept, and I've stopped framing it as anything else. The description now carries an "Installed-base impact, stated plainly" section saying that a stored grep -E '[0-9]{1,3}' job which ran fine under dash now fails on every fire until rewritten, that this is a different population from the one being fixed, and that it likely belongs in the release notes. Your framing is sharper than mine was and I've adopted it: the retroactivity is the same property that closes the legacy-store hole, so it can't be had one way only — but that's an argument for accepting the trade knowingly, not for me deciding it.

Both Suggestions: I want to do both and have offered both twice. They're the same standing offer, unanswered:

  1. Land the line-continuation fold as its own PR against main — four pre-existing bypasses, reachable on any host, and you're right that bundling couples an urgent security fix's revert story to an availability change's.
  2. File the extglob gap as its own issue now.

I've held off on both for one reason, and it's worth stating so you can overrule it in a sentence: each means publishing a new public description of an unfixed credential-read path on main, and how loudly to do that seemed like yours to decide rather than mine. Three of your lanes' clears-when conditions now depend on those two actions, so the cost of my caution is concrete. One word on either and it's done within the minute — the continuation PR is a mechanical carve-out of a commit that already exists, and the issue text is written.

@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 8, 2026
@SebastianYuSun

Copy link
Copy Markdown
Contributor Author

The spec item cleared — thanks for re-checking it. Both remaining Watch items are yours, and I have a measured answer on the Suggestion rather than a change.

Your governance observation is correct. Verified rather than assumed:

  • mcp_cron.py:1257vet_job_at_fire_time calls _vet_command_governance(job.command) (pre-existing, there to emit a distinct commands audit scope)
  • mcp_cron.py:1043_vet_shell_command calls it again internally

My round-11 addition is what made it execute twice per fire. I'm not folding it, and here's the measurement behind that:

_vet_command_governance('df -h') 0.02 ms
_vet_shell_command('df -h') total 0.45 ms
redundant cost per fire 0.02 ms

The only fold that keeps the pre-existing commands audit scope is to widen _vet_shell_command with something like governance_checked: bool = False so the fire-time caller can suppress the inner pass. That adds review surface to a security function and creates a new way for a future caller to skip a governance check — to save 0.02 ms per fire. Removing the outer call instead would delete behaviour that exists on purpose: its own comment says the command-body authorization is a distinct decision that should appear in the SEL trail in its own right. Tidying up my addition isn't a reason to drop that record.

One correction to the framing, in your favour: the redundancy is not free, but the cost isn't CPU. Tracing the paths:

  • normal pass — neither call audits, no duplicate
  • deny — the outer call returns first and the inner never runs, no duplicate
  • governance degraded (PlatformCompositionError, best-effort returns None) — audit_governance_degraded fires in both, so a degraded host emits two degrade audits per fire instead of one

So the real price is doubled degrade-audit volume on a host whose governance is already broken. If that noise matters more to you than the audit-scope distinction, the fold is the right call and I'll do it — that's a judgement about your SEL trail, so I'd rather you make it than have me quietly pick.

On the two Watch items — both are explicitly yours and I've stopped arguing either way:

  1. The retroactive refusal. The description's "Installed-base impact, stated plainly" section names the class, says it is a behaviour change on healthy hosts rather than an import cost, and says it likely belongs in the release notes. Your wording is sharper than mine and I've adopted it.
  2. Extglob. Your clears-when now asks for a tracked issue linked from this PR before merge, which is the most specific form of that ask yet.

On #2 and on splitting the continuation fold, the position is unchanged and I want to be precise about why, because it is now the only thing I am declining to do: both actions publish a new public description of an unfixed credential-read route on main, and I judged that "how loudly" was yours to decide rather than mine. Three of your lanes' clears-when now depend on those two actions, so my caution is costing you real progress. Say go and both land within the minute — the issue text is written, and the continuation PR is a mechanical carve-out of a commit that already exists.

Current head is 27f1118a0, which also updates learn-cron-dashboard.md — its line 265 presented the fire-time body re-scan as script-only, which is exactly the asymmetry this PR removes. That's the third owning doc, after governance.md and windows-install.md. I also checked the security.md half of last round's spec claim and it doesn't hold — grep -rln "_vet_shell_command" docs/ returns only governance.md — so I left that file alone rather than editing a correct spec to clear a checkbox.

Command crons were refused outright on any Linux host where /bin/sh is bash
(the default on AL2023, RHEL and Fedora): the POSIX-strict probe accepted only
a shell that preserves `x.{a,a}` when invoked `sh -c`, bash expands it, and
the resolver never looks past /bin/sh and /usr/bin/sh, so installing dash was
not a workaround either. Every command cron failed and auto-paused after five
consecutive failures.

The property the probe defends is real -- the storage-time vet gate tokenizes
the command once, so a runtime re-expansion widens what a deny-list can see --
but bash brace expansion was the one composition form the vet gate never
refused, which left the shell deciding whether the gate held.

- mcp_cron: refuse bash brace-expansion syntax (`{a,b}`, `{1..9}`) at storage
  time, alongside the existing refusals for \$(...), \$'...', backticks,
  non-plain \${...}, positional parameters and loops. The refusal reads QUOTE
  STATE rather than matching a pattern, because bash requires the braces and
  the separator unquoted but not the alternatives: `{w,"w w"}`, `{w,'w w'}`,
  `{w,$'w w'}` and `{w,w\ w}` all expand, so a whitespace-free character class
  would exempt exactly the live shapes. Three properties are load-bearing and
  each was measured, not reasoned:
    * whitespace disqualifies a group only when BARE, and only space, tab and
      newline count -- bash still expands across form feed, vertical tab, CR
      and NBSP, all of which `str.isspace()` calls whitespace. Since the
      disqualifier is what makes the scan ALLOW, a generous whitespace class
      fails OPEN. Bare space is what keeps `awk '{print x, y}'` allowed;
    * nesting is counted, because `{{x}h,h}` expands and stopping at the first
      `}` reads the outer group as separator-free;
    * two levels are scanned -- the command as written, and the command with
      quote delimiters removed, which is what a nested shell receives.
      `bash -c "cat ~/.ss{h","h}/KEY"` puts the separator outside both quoted
      runs while the braces sit inside, so no single-level rule can see it,
      and the inner shell expands the reconstructed word onto the real path.
  The cost is stated in the error text and the docstring: a quoted regex
  interval (`grep -E '[0-9]{1,3}'`) is refused, with the escaped double-quoted
  spelling `grep "[0-9]\{1,3\}"` offered as an accepted BRE equivalent.
- mcp_cron: scan what the shell PARSES, by folding backslash-newline before any
  check runs, using the security module's own exported
  `shell_normalizer._fold_line_continuations` rather than a second spelling of
  it. A continuation is removed before parsing, so it splits whatever token a
  static check matches on and the shell rejoins it afterwards. Every refusal in
  `_vet_shell_command` was bypassable this way, and the un-split spelling of
  each payload was refused as expected -- the credential-path pattern
  (`cat ~/.ss\<newline>h/KEY`, which carries no composition form at all), the
  command-substitution refusal (`$\<newline>(`), the non-plain `${...}`
  refusal, the ANSI-C refusal, and the brace scan's sequence form
  (`{s.\<newline>.s}`, since a comma is one character and cannot be split but
  `..` is two). 7 of the added assertions fail without the folding. POSIX
  requires the removal, so this is not bash-specific -- `sh` resolves the split
  path too. One divergence measured while adopting the shared helper: it also
  folds `\<CR><LF>`, which bash does NOT (bash escapes the CR and lets the LF
  end the command, so `echo a\<CR><LF>b` runs as two commands). For a REFUSAL
  scan that over-folding is fail-closed -- it can only join text the shell keeps
  apart and make this gate see more adjacency -- so adopting it is safe here and
  keeps the divergence in one place instead of two.
- mcp_cron: read quote state through `security.shell_normalizer._iter_shell_chars`
  rather than a private copy of the rules. That generator is THE quote/escape
  machine here and its docstring records the escape a copy gets wrong: inside
  `$'...'` a backslash escapes, so `$'a\'b'` does not close at the escaped
  quote. Measured on `x $'a\'b' {p,q} y`, a hand-rolled copy closed early,
  reopened on the next quote and then disagreed for the rest of the string,
  labelling an UNQUOTED `{p,q}` as single-quoted -- 10 of 17 positions. That
  mislabelling did not open a hole in the brace rule, since the group is
  mislabelled uniformly and the rule compares a group against its own opening
  state, but the continuation normaliser above decides literalness from these
  same states and runs BEFORE the `$'` refusal, so the desync was reachable.
  `_quote_states` is now a thin adapter and the only remaining difference from
  the generator is the quote characters themselves, where this convention
  deliberately reports the state a quote is changing FROM.
- mcp_cron: bound the WORK the brace scan may do, and refuse when the bound is
  hit. The walk is quadratic on a hostile shape -- a long run of `{` with no
  closing brace at the same state makes the inner scan run to end-of-string for
  every one of them. Measured: 145 ms at 1k, 572 ms at 2k, 2.3 s at 4k, 9.2 s at
  8k, i.e. ~4x per doubling, so a few hundred KB hangs the process. A length cap
  alone does not fix it: `cron_add` is capped at 5000 by
  `validation.FieldSpec("command", max_len=5000)`, but `portability.py` re-vets an
  IMPORTED job with the raw dict value where that cap does not apply, and 5000
  still costs seconds once per job. Bounding steps bounds every shape, which is
  the same reason `_CRON_MAX_GLOB_WORD` bounds the word handed to fnmatch rather
  than the command. Exhaustion REFUSES with its own message: no verdict was
  reached, so the command is not clean, and short-circuiting to clean would turn
  a denial of service into a bypass. 2M characters now costs 1.8 s.
- mcp_cron: re-run the composition scan at FIRE time for `command` jobs, not
  only at storage time. This is what makes the first half reach the installed
  base: `vet_job_at_fire_time` exists because "a policy tightened AFTER
  scheduling would never be re-evaluated", and it already re-scanned a `script`
  BODY while a `command` body got only the governance ceiling. That asymmetry
  is load-bearing once the resolver accepts a brace-expanding bash -- measured,
  the ceiling ALLOWS `set -B; cat ~/.a{w,w}s/creds` while the storage-time scan
  refuses it, so a command stored before this refusal existed would still run
  after it, on the very executor this change newly admits. Deny semantics at
  that seam are the right ones: the run fails, the job is KEPT, and the refusal
  is audited under its own `cron_command_body` scope, mirroring
  `cron_script_body` -- which also makes a newly-refused shape such as a quoted
  regex interval surface as a legible audited failure instead of silence.
- docs: update the three owning documents in the same commit, which AGENTS.md
  requires. `docs/system-specs/modules/governance.md` enumerated the fire-time
  contract for a `command` job as the capability gate + the `commands` ceiling;
  there is now a third check, and the entry also states why the ceiling and the
  composition scan are distinct decisions that both have to be re-run.
  `docs/guides/windows-install.md` gave brace expansion as the reason Windows
  refuses command crons; that reason is retired by this change, so it now cites
  what the code actually cites -- a shell whose language is wider than the one
  the vet was written against.
  `docs/system-specs/modules/learn-cron-dashboard.md` presented the fire-time
  body re-scan as script-only, which is the asymmetry this change removes, so it
  now states that a `command` body is re-scanned on the same terms and that the
  "denied on every tick until it is edited" property applies to a stored command
  a newly-added refusal rejects.

- cron_script: accept a trusted shell invoked with brace expansion off (`+B`).
  The probe tries the plain form FIRST, so dash/ash/a real POSIX sh keep their
  exact current argv, and the form that PASSED is recorded and re-used by the
  executor through one shared argv builder -- previously the probe and the
  executor spelled argv separately, so the probe could stop proving what runs.

The first half is what makes the second safe: with brace syntax refused at
storage time, a command cannot re-enable expansion and have anything left to
expand. Windows still refuses command crons by design, unchanged.

Every brace row in the tests was run against real bash, comparing `bash -c`
against `bash +B -c` on the same word, and for the nested level comparing
`bash -c 'bash -c "..."'` the same way. The assertion is one-directional: a
shape some shell expands must be refused, a shape every shell leaves literal
must not regress. The one deliberate over-refusal is a SINGLE-quoted group
such as `awk '{a,b}'`, since single quotes survive one level of double-quoted
nesting; the double-quoted spellings are refused for cause, because they
reconstruct into a live expansion.

Not fixed here, and reported on the PR rather than silently widened: brace
expansion is not the only unrefused composition form once bash is the
executor. An extglob pattern such as `.s@(|s)h`, enabled via
`shopt -s extglob; export BASHOPTS` and a nested shell, composes the private
key directory with no `eval` at all, and a pre-set `BASHOPTS` in the
executor's environment needs neither. That is a different composition form with its own
over-refusal cost (`awk '!(NR%2)'`, `awk '{print 2*(x+1)}'`), so it is the
maintainers' scope call, not a change to ride along here.
@bolichen97
bolichen97 force-pushed the fix/cron-command-shell-brace-off branch from 27f1118 to 509481e Compare September 8, 2026 15:30
@SebastianYuSun

Copy link
Copy Markdown
Contributor Author

Your Watch item is right, and I measured the half you flagged as unconfirmable. The argv floor does not catch it. The protected-branch push guard is bypassable today.

_AMBIGUOUS_EXPANSION_RE (shell_normalizer.py:2067) is verbatim:

re.compile(r"\$\(|\$\{|`|\{[^{}]*(?:,|\.\.)[^{}]*\}")

[^{}]* excludes {, so a group containing a nested group cannot match — while bash expands it:

$ bash -c 'echo {main,{x}}'
main {x}
word regex matches
{main,x} yes
{ma,in} yes
{main,{x}} no
{main,{x},y} no

Through the real gate (security.is_denied), which is the part you said you couldn't confirm from the base tree:

command verdict
git push origin main DENY — git-publish-push-protected-b…
git push origin {main,x} DENY — ambiguous expansion
git push origin {main,{x}} ALLOW
git push origin {main,{x},y} ALLOW
git push --force origin {main,{x}} ALLOW
git push origin {ma,in} DENY

So it is not caught downstream, and the reason is structural: argv_floor.py:2499 consumes the same matcher, so the floor inherits the miss rather than covering it. Your clears-when therefore has only one branch left open — the sibling matcher needs the nesting treatment; it cannot clear by showing the gate denies, because it doesn't.

You're also right that this is the same root cause. This PR's scan started as \{[^}\s]*(?:,|\.\.)[^}\s]*\} and had the identical miss; it was fixed by counting brace depth instead of excluding { from a class, after measuring echo p{{x}s,s}qp{x}sq psq. This is that defect's surviving sibling.

I have not changed it here, and I don't think I should. It's a different module, a different feature, and pre-existing — putting a git-publish security change inside a cron availability PR is exactly the coupling the design lane asked me to avoid for the line-continuation fix. Two candidate fixes, for whoever picks it up:

  1. One character[^{}]*[^}]* in both classes, which is precisely the widening this PR applied to its own regex before replacing it with a scanner. It makes {main,{x}} match and widens denials (fail-closed) in the two consumers, which is the safe direction for a guard but is still a behaviour change you own.
  2. Depth-counting scan mirroring mcp_cron._scan_one_level — more faithful, more code, and a second scanner unless it's factored into shell_normalizer as one shared helper, which is the direction that module's own docstrings argue for.

Flagging severity plainly because it's outside cron: this defeats a protected-branch push guard, --force included. Of everything this review sequence has turned up, it's the one with a consequence beyond the cron surface, and it is live on main right now — independent of whether #7414 merges.

That makes three pre-existing items now queued behind this PR rather than in it: the four line-continuation bypasses, the extglob composition route, and this. All three are exploitable on main today and none of them needs #7414 to land. I've said several times I'd rather carve them out than bundle them; this one strengthens that — say the word and I'll open them, this one first given what it gates.

@bolichen97

Copy link
Copy Markdown
Collaborator

Rebased onto main 9c2b4b67 by a maintainer as part of the 2026-09-08 open-PR audit (the branch was ~700 commits behind and carried a merge conflict label).

Clean rebase: no conflicts. I verified the one moved call site still lands correctly, run_command_sandboxed calls _command_argv(shell, command) rather than the old argv = [shell, "-c", command] that main relocated in #7670.

Gates run locally on the changed files only: isort and flake8 clean, black reports the two touched test files as unformatted but both are already listed in .github/black-baseline.txt, so that is pre-existing and not from the rebase. pytest test/test_mcp_cron_security.py test/test_cron_script_more_coverage.py = 307 passed; test_cron_script.py, test_cron_script_identity.py, test_cron_script_source_drift.py, test_mcp_cron.py = 166 passed, 1 skipped.

Please review the rebase result. A maintainer push makes the maintainer the last pusher, so under the repo's last-push rule a second approver is now needed. Reply here if anything looks wrong.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running and removed readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention labels Sep 8, 2026
@SebastianYuSun

Copy link
Copy Markdown
Contributor Author

Reviewed, and the rebase result is correct. I checked both of your claims against the tree rather than reading the diffstat, since the conflict region is the one place where a clean-looking rebase could still be wrong.

The moved call site. Confirmed, and it is the claim worth checking, because the invariant it protects is silent when it breaks. grep -n '"-c"' src/kiro_crew/cron_script.py returns exactly two hits on 509481e81, both inside _argv_for_form (:2001, :2002), and the executor's only argv construction is argv = _command_argv(shell, command) at :2192, sitting inside the try block #7670 relocated, after the shell is None guard and before wrap_argv. No second builder survived the merge.

That is the whole property _command_argv exists for. Its docstring states it: before it existed, the probe and the executor each wrote their own [shell, "-c", ...], so a resolver that accepted a new invocation form would have left the probe proving a form the executor never used. The probe would still pass while the command ran with brace expansion on. A resolution that kept main's relocated literal alongside my builder would have reproduced exactly that, and it would not have failed a single test, because both builders return the same argv for every shell in _BRACE_OFF_SHELLS with a False value. So this was the right thing for you to call out, and it holds.

git log 9c2b4b678 -S'_command_argv' -- src/kiro_crew/cron_script.py is empty, which is also why the conflict landed there: the builder is entirely this branch's, and #7670 moved the lines it replaced.

Gates. Re-measured on your head in a separate worktree rather than taking the numbers:

  • test_mcp_cron_security.py + test_cron_script_more_coverage.py = 307 passed
  • test_cron_script.py + test_cron_script_identity.py + test_cron_script_source_drift.py + test_mcp_cron.py = 166 passed, 1 skipped

Both match your counts exactly. scripts/check_black_formatting.py passes, which is the changed-files-versus-baseline gate CI actually runs, and both touched test files are baseline entries (.github/black-baseline.txt:553, :796), so your reading of the two black complaints as pre-existing is right. check_comment_history.py also passes.

Since you scoped your lint run to the changed files, I also ran the four CI commands verbatim on the whole tree on your head, which is the only file set that is a superset of CI's: isort --check-only src/kiro_crew test conftest.py xdist_budget.py, flake8 --jobs=1 over the same, and mypy src/kiro_crew/ all exit 0. A changed-files scope has bitten this branch before, so it seemed worth closing.

I have not pushed anything over your rebase. Nothing in the diff needed changing, and a force-push would have reset the workflow-approval gate your push just re-armed, for no content. Current state on 509481e81 is 14 suites at action_required awaiting an approval. Understood on the last-push rule needing a second approver.

One thing I got wrong, and it is a process error rather than a code one. SECURITY.md asks that a potential security issue not be filed publicly. Earlier in this thread I posted a measurement table for a bypass in a different module, _AMBIGUOUS_EXPANSION_RE in security/shell_normalizer.py, including the payload shapes that get through the protected-branch push guard. That belongs in the private channel, and it is now filed there through this repo's private vulnerability reporting, as GHSA-r338-xwx2-grj4. It carries the measurement, the reachability through security.is_denied, and two candidate fixes, and it says up front that the finding is already public here so you can weight the timeline accordingly.

The distinction I should have drawn and did not: the findings inside this PR's own scope are ones you asked me to make public, down to adding payloads to MALICIOUS_COMMANDS, and there is a fix shipping alongside them. That one is a different guard, has no fix in flight, and is live on main, so it does not get the same treatment just because I found it while answering a review item on this PR.

Two questions, both yours to answer:

  1. Do you want that comment redacted? I can replace the payload table with a pointer to the private report. I have not touched it, because removing it also removes the evidence one of your review lanes cited a clears-when against, and I would rather you choose than quietly rewrite thread history.
  2. The extglob composition route is in the same category as far as SECURITY.md goes, and one lane's clears-when asks for a tracked issue linked here before merge. Those two things now pull in opposite directions, which is why I am asking rather than picking. Tell me whether a public tracking issue is acceptable for it, given the route is already described in this branch's commit message and in the test file, or whether you would rather it go private and the clears-when point at a private report instead. I have mentioned it inside the report above so it is at least on your queue either way, and I will file it as its own report on request.

@SebastianYuSun

Copy link
Copy Markdown
Contributor Author

Three lanes re-reviewed the rebase result and one is red, so taking them in order of what actually blocks.

GPT 5.6 (🔴 blocking) — F1 re-tested against 509481e81, not dismissed on its stamp. That comment is stamped 72b2461e2, which is eight heads back, and its adjudication reasons about a one-level scan. A stale stamp is not a reason to skip a finding, so I ran F1's exact payload through _vet_shell_command on the current head:

command verdict
F1's cross-quote concatenation payload REFUSED — bash brace expansion
the same payload un-split (control) REFUSED — credential path
the same spelling against a different credential directory REFUSED — bash brace expansion

And the mechanism is precisely the one F1's own adjudication predicted would be missing:

brace scan on F1          : True
  level 1 (as written)    : False   <- what the adjudication measured
  level 2 (quotes stripped): True   <- added in round 9, closes it

Level 1 still returns False for exactly the reason the adjudication gives: the separator lands outside both quoted runs while the braces sit inside, so a same-state rule cannot see it. What changed after 72b2461e2 is that the scan runs a second level over the command with quote delimiters removed, which is what the nested shell receives, and the reconstructed word is refused there. F1's own Fix: line asks for exactly that: "Run brace detection on normalized nested-shell payloads after quote/escape removal."

No regression on the shapes that must stay allowed, checked in the same run: awk '{print x, y}' f, grep "[0-9]\{1,3\}" f, df -h and tar -cf a.tar b c all pass.

So F1 is closed on the current head by the change GPT prescribed. The red check is that lane not having re-run its body since 72b2461e2; its check-run for 509481e81 exists but the comment is unedited. Nothing for me to fix, and I would rather state that with the measurement above than assert it from the SHA.

First principles (🟡 CONCERNS) and design (🟡 CONCERNS) converge on one item, and it is now filed: #9488.

Both lanes reduced to the same clears-when. First principles: "the release shipping this names the newly-refused shapes and the BRE-escape remedy in its notes, or a linked issue tracks that." Design: "the behaviour change is recorded where the release PR will pick it up ... not only in this PR's description."

That is a documentation and release-process item with no bypass in it, so SECURITY.md does not apply and it belongs in the open. #9488 carries the before/after table for the four affected shapes plus the two that keep working, why the escaped BRE spelling is the remedy rather than a quoted-interval carve-out, and an explicit note that script and message crons are unaffected. It also records what is NOT being proposed, so nobody reads it as a request to weaken the fire-time re-scan.

Design's second Watch — the extglob gap — is the one I am holding, and now for a concrete reason rather than caution. Its clears-when asks for a tracked issue referencing this PR's measurements. SECURITY.md asks that a potential security issue not be filed publicly. Those pull in opposite directions for that particular item, because unlike #9488 it is a live composition route on main. I have mentioned it inside the private report I filed for the sibling finding, so it is on your queue either way, and I will file it however you say: its own private report, or a public tracking issue if you judge that acceptable given the route is already in this branch's commit message and test file. That question is also in my previous comment; answering it in either direction clears the lane.

Design's Suggestion — split the four line-continuation fixes into their own PR. I agree with the reasoning and it is the third time it has come up, so I want to be plain about the one thing that makes me hesitate rather than keep deferring. Splitting them out means this PR's brace scan temporarily loses the folding, and every refusal in _vet_shell_command is bypassable by a backslash-newline until the split PR lands, including the brace refusal this PR adds. So the split is right for reviewability and revert-safety, and the ordering matters: the continuation PR has to land first, or land simultaneously, rather than after. If you want it, say which order you prefer and I will carve it out with the tests that fail without it (7 of them) moving with it.

Verification state on 509481e81: M3 gates re-run on your head with the four CI commands verbatim over the whole tree, all exit 0. 43 checks green, 12 still running, and the only failure is the stale GPT lane above.

@SebastianYuSun

Copy link
Copy Markdown
Contributor Author

Correcting my previous comment on one point, because I had the GPT lane's state wrong and the difference matters for what you do about it.

I said the red was that lane "not having re-run its body". It did run on 509481e81, and it failed. What it did not do is produce a review. The check-run's own title says so:

GPT 5.6 Review — review incomplete

That is different vocabulary from what the same lane emitted on the heads it actually reviewed, and the contrast is clean across four heads:

head GPT run window check-run title
898d19868 09:40:29 → 09:49:47 (9m18s) changes requested (blocking)
72b2461e2 10:27:59 → 10:44:04 (16m05s) changes requested (blocking)
27f1118a0 15:10:19 → 15:25:29 (15m10s) review incomplete
509481e81 15:49:42 → 15:58:52 (9m10s) review incomplete

So the blocking comment you see on this PR is a 72b2461e2 verdict, eight heads old, still displayed because the lane has not written a body since. The finding in it is closed on the current head, which I measured in my previous comment.

A lane-side outage is ruled out. Other fork PRs completed real GPT reviews inside my failing run's own window:

PR completed title
#4762 15:57:15 changes requested (blocking)
#7791 15:57:10 no blocking findings
#9453 15:31:18 changes requested (blocking)
#8014 15:01:00 no blocking findings

#4762 and #7791 both finished between my run's start and end. The lane was working while mine was not, so this is specific to this PR rather than to the pipeline.

What changed at 27f1118a0, measured rather than guessed. Two things crossed at once, and I cannot separate them from outside the job:

72b2461e2 (last real review) 27f1118a0 (first incomplete)
diff bytes 31,270 73,419
commit message bytes 2,620 9,685
files 4 7
longest added line 93 chars 2,053 chars

The 22x jump in longest line is a much sharper discontinuity than the 2.4x jump in size. Both arrived with the same three files: the owning-spec doc updates AGENTS.md requires in the same commit. The 2,053-character line is a single prose paragraph I wrote unwrapped in docs/system-specs/modules/governance.md, and there is a 716-character one in docs/guides/windows-install.md which is a markdown table row.

I could not narrow it further from here. gh api .../actions/jobs/<id>/logs returns 404 for a fork contributor on a pull_request_target secret-context job, so the run's own log is not reachable from my side. That is a limit on my evidence, not a claim that no log exists: it is visible to you.

What I am not doing. I am not reshaping the diff to get a review bot to finish. The doc updates are required in this commit, and amending means force-pushing over the rebase you just landed, which resets the approval gate you re-armed. Both of those are costs you own, so the order should be yours to pick.

Suggested order, cheapest first:

  1. Re-run the lane. Free, and it is the one experiment nobody has run. My measurement shows a correlation across two heads, not a hard limit, so a marginal condition would clear on a retry. If it comes back incomplete a third time, the correlation firms up considerably.
  2. If it repeats, I rewrap the two long lines and cut the commit message down, which is zero semantic change: the message duplicates detail the PR body already carries at 15,525 characters. Say the word and it is a few minutes, force-pushed only with your go-ahead.
  3. Or merge on the other lanes if you are satisfied by them: Opus is success with no blocking findings on this exact head, and first-principles and design are both neutral CONCERNS, which are advisory by their own text. Only PR Readiness counts the incomplete GPT lane as a blocker, and it is counting a lane that produced no verdict rather than one that objected.

Worth noting for the pipeline itself, separate from this PR: a lane that cannot complete currently presents as failure with a stale blocking comment still on display, which reads as a live objection. The title says review incomplete, but nothing in the comment or the readiness rollup carries that distinction, so a reader who trusts the comment sees a blocker that no longer exists. Happy to file that as its own issue if it is useful.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 8, 2026
@SebastianYuSun

Copy link
Copy Markdown
Contributor Author

CI is terminal on 509481e81 now and there are two reds plus the incomplete review lane from my last comment. Neither red is a coverage failure, and no test failed: 56 checks green, 0 pending, all backend and Windows and macOS shards passed.

Root cause, from the job log rather than the rollup. Coverage Combine produced a valid coverage.xml, uploaded all 1,116,978 bytes of it, computed its digest, and then died at the finalize call:

Uploaded bytes 1116978
Finished uploading artifact content to blob storage!
SHA256 digest of uploaded artifact is 871096269fc2...
Finalizing artifact upload
##[error]Failed to FinalizeArtifact: Received non-retryable error:
Failed request: (403) Forbidden: Error from intermediary with HTTP status code 403 "Forbidden"

That is actions/upload-artifact@043fb46d (v7.0.1) failing against GitHub's artifact blob storage, after the coverage data was already computed and transferred.

Coverage Gate is then purely downstream of it. Its env at failure:

CC: failure   BE: success   FE: success   FCM: skipped
ONLY_BACKEND: true   LEAF_TESTS: false   BACKEND_MIN: 90   PER_FILE_MIN: 80
##[error]coverage-combine=failure -- failing closed.

It exited at the dependency check and never evaluated BACKEND_MIN or PER_FILE_MIN against anything. The surface detection was also correct: only_backend=true and frontend-coverage-merge=skipped is exactly the expected shape for this diff.

The 403 is intermittent across the repo rather than a clean outage, which is why I did not stop at "infra, not mine":

PR Coverage Combine completed
#4762 success 16:32:16
#7414 failure (403) 16:38:33
#9435 cancelled 16:38:29
#9453 failure 15:52:12

#4762 succeeded six minutes before mine failed, so I cannot claim the service was simply down.

That left a real unknown, and it is the part worth reading. Because Coverage Gate never ran its comparison, and because this PR's two heads have only ever produced cancelled (concurrency cancellation from your push) and then this 403, no CI run has ever confirmed this diff clears the coverage floor. So "just re-run it" was a hopeful recommendation rather than a safe one until I measured it locally.

Measured against scripts/check_per_file_coverage.py's actual rules:

  • Neither touched module is in .github/coverage-baselines/backend.txt (92 entries, checked), so rule 1 applies to both: they must meet the 80% floor rather than a recorded rate.

  • Every added statement is covered. Intersecting the diff's added line ranges with coverage's uncovered set:

    file added lines statements among them uncovered
    src/kiro_crew/mcp_cron.py 326 80 0
    src/kiro_crew/cron_script.py 93 20 0

    Adding statements that are all covered can only raise a file's rate, so this diff cannot push either module under the floor. (Absolute per-file rates from my run read 73% and 74%, but that is a 12-file subset of the suite and understates them; the added-line figure is the one that answers the question, because it is measured on exactly the tests written for those lines.)

  • The ratchet cannot fire either. Rule 3 turns the gate red when a baselined file is incidentally lifted clear of the floor. Nothing my tests touch is baselined: no security/ module, no shell_normalizer.py, no argv_floor.py, and the largest baselined entries are all under apps/builtins/mochi, dashboard/, and mcp_gateway/, which this diff and its tests never reach.

So once the artifact upload succeeds, the coverage lane should pass on this tree. A re-run of Coverage Combine is the remedy, and a fork contributor cannot trigger one.

On rebasing, which my own tooling recommended and I am declining with a reason. A staleness check flagged REBASE_INDICATED: this branch is 7 commits behind and upstream touched .github/workflows/ci.yml. Following that at file granularity would be wrong here. The implicated commit is b47f1b997 build(deps): bump astral-sh/setup-uv from 9.0.0 to 10.0.1, whose entire diff is that one action pin repeated across 9 workflow files, 17 insertions and 17 deletions. coverage-combine does use setup-uv, which is why the file matched, but the step that failed is actions/upload-artifact further down the same job, and it is untouched. setup-uv installs uv; it cannot affect artifact finalization.

So a rebase cannot fix this, and it would cost you the approval gate you re-armed plus the rebase you just landed. Not doing it.

Nothing to fix in the diff. Asks, both requiring write access:

  1. Re-run Coverage Combine (and Coverage Gate, which will follow it).
  2. Re-run GPT 5.6 Review, per my previous comment: it returned review incomplete on this head, and its displayed blocking comment is a 72b2461e2 verdict whose finding I have measured as closed on the current tree.

@github-actions github-actions Bot added the merge conflict Branch has merge conflicts with its base — author must resolve before merge label Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

drive-to-green PR claimed by drive-to-green pipeline fork Pull request from a fork (external contributor) merge conflict Branch has merge conflicts with its base — author must resolve before merge readiness: action required A blocking check or review needs attention

Projects

None yet

Development

Successfully merging this pull request may close these issues.

command crons are refused on Linux hosts where /bin/sh is bash (POSIX-strict probe has no accepted form for a brace-expanding trusted shell)

3 participants