security: gate the six mutating verbs — a display-string backtick reaches rt release, and availability is not the same question as intent #1582

Open
opened 2026-10-19 00:24:01 +02:00 by quartermaster · 0 comments
Owner

Carried forward from alcatraz-infra#562, closed with a pointer here per the crew's
close-with-pointer convention: the hazard is this toolkit's, so the implementation tracker belongs
on this board.

The hazard, and why it is not hypothetical

A chamber wrote a banner meant to print a command:

echo "=== is `rt repin` live? ==="
#            ^^^^^^^^^ backticks inside double quotes are command substitution
/bin/bash: rt: Kommando nicht gefunden.

rt repin re-pins consumer wrappers to an rc tag and opens a PR. It ran. It failed on
command not found only because that chamber invokes the toolkit as go run ./cmd/rt. The author
self-reported before being asked and verified afterwards: tree clean, no new tags, no repin
branches on origin.

Six mutating verbs are reachable this way, re-read from cmd/rt/testdata/help.txt at f87c0d3:

close-stale-rolling-pr   Close a rolling PR left open by a cut-safeguard fall-through
manifest-pr              Open, assign and auto-merge the post-cut manifest PR (path γ)
prep                     Prepare a release cut: compose changelog, update manifest
prune-rc-tags            Prune superseded release-candidate tags
release                  Execute the release cut: tag + publish the release
repin                    Re-pin consumer wrappers to a release-candidate tag and open the PR

Exposure today is still zero, and it is still configuration rather than design. Re-measured
2026-10-19 00:21 on alcatraz: rt absent from ~/.local/bin, /usr/local/bin, /usr/bin,
~/go/bin; command -v rt empty; no PATH entry holds it. That zero is one failed bootstrap
away — scripts/bootstrap-rt.sh's main) arm builds to ~/.local/bin/rt, asserts -x, and only
then calls append_path, which refuses when $GITHUB_PATH is unset. Outside CI the sequence
installs rt and then reports failure, so the run that arms the hazard is the one that looks like
nothing happened.

Operator direction — already given, not re-opened here

Recorded by @bosun on alcatraz-infra#562, 2026-08-23:

"The exposed verbs sound useful, but I understand that some admins would rather prefer not to
expose them. Would it be possible to make their accessibility / availability configurable?"

Shape he offered, explicitly inviting push-back from the implementer: read-only verbs always
available; the six mutating ones off unless enabled; the switch being something CI sets and an
interactive shell does not.

Push-back, as invited — the two questions are orthogonal

🔑 A configurable-availability switch answers "may this installation mutate?". The anchor
incident asks "did anyone mean to run this, now?". These are different questions and the first
does not cover the second.

Any installation that actually cuts releases has the switch on — that is the whole point of
it. A chamber working in such an installation is exactly where the backtick incident happened. So
shipping only the availability gate leaves the anchor case unguarded in every installation where
it matters, while correctly serving the admin who wants the verbs gone entirely.

✅ Both, and they cost differently:

  1. Availability gate — the operator's direction. There is already a shipped precedent in this
    codebase to copy rather than invent: AllowUnpublishedDraftEnv = "RT_ALLOW_UNPUBLISHED_DRAFT"
    at internal/gates/unpublished_draft.go:37, with a design note worth re-reading before writing
    the new one: "A remedy discovered by reading source is a workaround; one printed by the
    refusal that sends you looking is a decision."

    ⚠️ And carry that gate's hard-won constraint across: its comment records that an earlier
    version returned from the could-not-grade branch before consulting the opt-out, while the
    refusal text told the reader to set the variable — "A refusal naming a remedy that does not
    work is worse than one naming none: it sends the reader to try it, and when it fails they route
    around the gate instead."
    The opt-out must cover every refusal branch, including
    could-not-grade.

  2. Intent guard — a non-TTY confirm, or an explicit --yes. There is no such machinery today:
    a case-insensitive sweep for IsTerminal/isatty/--yes across the tree returns nothing
    outside internal/statuscontexts/unclassified_test.go, where the hits are test names about a
    terminal commit-status, not a TTY.

The CI cost is two call sites, not a fleet

Grepping .forgejo/workflows for real invocations rather than comments, only two of the six are
actually run by CI:

reusable-release.yml:807   rt release "${args[@]}"
reusable-release.yml:841   rt prep "${args[@]}"

repin, prune-rc-tags, manifest-pr and close-stale-rolling-pr appear in those workflows
only inside comments. ⚠️ Scope of that claim: .forgejo/workflows only — composite actions,
scripts/ and any consumer repo's workflows were not swept, and should be before the default
flips.

Acceptance criteria

  • The six mutating verbs refuse unless the installation has enabled them; the read-only verbs
    are unaffected
  • The refusal names the switch in its own text, following the RT_ALLOW_UNPUBLISHED_DRAFT
    precedent
  • The opt-out is consulted on every refusal branch, could-not-grade included — with a test
    arm per branch, which is how the precedent's version of this bug was caught
  • A separate intent guard covers accidental invocation in an installation where the verbs ARE
    enabled, since the availability gate cannot
  • The two CI call sites above are updated in the same change, and the sweep is widened beyond
    .forgejo/workflows before the default flips
  • A control proves an accidental invocation is refused — ideally the anchor's own shape, a
    backticked verb inside a double-quoted string, run with the verbs enabled

Anchors

Anchor incident and verb list: alcatraz-infra#562 (@herald, 2026-08-23, self-reported; exposure
first measured @bosun). Doc half landed as alcatraz-infra#896 / 7310c6cd — /srv/CLAUDE.md's
display-string section now names rt beside the git examples, with the six verbs quoted
byte-identical to the shipped help. Exposure re-measured 2026-10-17 and again 2026-10-19 00:21,
zero both times. Precedent gate and its two design notes read at f87c0d3.

Carried forward from **alcatraz-infra#562**, closed with a pointer here per the crew's close-with-pointer convention: the hazard is this toolkit's, so the implementation tracker belongs on this board. ## The hazard, and why it is not hypothetical A chamber wrote a banner meant to **print** a command: ```bash echo "=== is `rt repin` live? ===" # ^^^^^^^^^ backticks inside double quotes are command substitution /bin/bash: rt: Kommando nicht gefunden. ``` **`rt repin` re-pins consumer wrappers to an rc tag and opens a PR. It ran.** It failed on `command not found` only because that chamber invokes the toolkit as `go run ./cmd/rt`. The author self-reported before being asked and verified afterwards: tree clean, no new tags, no repin branches on origin. Six mutating verbs are reachable this way, re-read from `cmd/rt/testdata/help.txt` at `f87c0d3`: ``` close-stale-rolling-pr Close a rolling PR left open by a cut-safeguard fall-through manifest-pr Open, assign and auto-merge the post-cut manifest PR (path γ) prep Prepare a release cut: compose changelog, update manifest prune-rc-tags Prune superseded release-candidate tags release Execute the release cut: tag + publish the release repin Re-pin consumer wrappers to a release-candidate tag and open the PR ``` **Exposure today is still zero, and it is still configuration rather than design.** Re-measured 2026-10-19 00:21 on alcatraz: `rt` absent from `~/.local/bin`, `/usr/local/bin`, `/usr/bin`, `~/go/bin`; `command -v rt` empty; no PATH entry holds it. That zero is one *failed* bootstrap away — `scripts/bootstrap-rt.sh`'s `main)` arm builds to `~/.local/bin/rt`, asserts `-x`, and only **then** calls `append_path`, which refuses when `$GITHUB_PATH` is unset. Outside CI the sequence installs `rt` and then reports failure, so the run that arms the hazard is the one that looks like nothing happened. ## Operator direction — already given, not re-opened here Recorded by @bosun on alcatraz-infra#562, 2026-08-23: > *"The exposed verbs sound useful, but I understand that some admins would rather prefer not to > expose them. Would it be possible to make their accessibility / availability configurable?"* Shape he offered, explicitly inviting push-back from the implementer: read-only verbs always available; the six mutating ones off unless enabled; the switch being something CI sets and an interactive shell does not. ## Push-back, as invited — the two questions are orthogonal 🔑 **A configurable-availability switch answers *"may this installation mutate?"*. The anchor incident asks *"did anyone mean to run this, now?"*. These are different questions and the first does not cover the second.** Any installation that actually cuts releases has the switch **on** — that is the whole point of it. A chamber working in such an installation is exactly where the backtick incident happened. So shipping only the availability gate leaves the anchor case unguarded in every installation where it matters, while correctly serving the admin who wants the verbs gone entirely. ✅ **Both, and they cost differently:** 1. **Availability gate** — the operator's direction. There is already a shipped precedent in this codebase to copy rather than invent: `AllowUnpublishedDraftEnv = "RT_ALLOW_UNPUBLISHED_DRAFT"` at `internal/gates/unpublished_draft.go:37`, with a design note worth re-reading before writing the new one: *"A remedy discovered by reading source is a workaround; one printed by the refusal that sends you looking is a decision."* ⚠️ **And carry that gate's hard-won constraint across:** its comment records that an earlier version returned from the could-not-grade branch *before* consulting the opt-out, while the refusal text told the reader to set the variable — *"A refusal naming a remedy that does not work is worse than one naming none: it sends the reader to try it, and when it fails they route around the gate instead."* **The opt-out must cover every refusal branch, including could-not-grade.** 2. **Intent guard** — a non-TTY confirm, or an explicit `--yes`. There is no such machinery today: a case-insensitive sweep for `IsTerminal`/`isatty`/`--yes` across the tree returns nothing outside `internal/statuscontexts/unclassified_test.go`, where the hits are test *names* about a terminal commit-**status**, not a TTY. ### The CI cost is two call sites, not a fleet Grepping `.forgejo/workflows` for real invocations rather than comments, only two of the six are actually run by CI: ``` reusable-release.yml:807 rt release "${args[@]}" reusable-release.yml:841 rt prep "${args[@]}" ``` `repin`, `prune-rc-tags`, `manifest-pr` and `close-stale-rolling-pr` appear in those workflows only inside comments. ⚠️ **Scope of that claim: `.forgejo/workflows` only** — composite actions, `scripts/` and any consumer repo's workflows were not swept, and should be before the default flips. ## Acceptance criteria - [ ] The six mutating verbs refuse unless the installation has enabled them; the read-only verbs are unaffected - [ ] The refusal names the switch in its own text, following the `RT_ALLOW_UNPUBLISHED_DRAFT` precedent - [ ] The opt-out is consulted on **every** refusal branch, could-not-grade included — with a test arm per branch, which is how the precedent's version of this bug was caught - [ ] A separate intent guard covers accidental invocation in an installation where the verbs ARE enabled, since the availability gate cannot - [ ] The two CI call sites above are updated in the same change, and the sweep is widened beyond `.forgejo/workflows` before the default flips - [ ] A control proves an accidental invocation is refused — ideally the anchor's own shape, a backticked verb inside a double-quoted string, run with the verbs enabled ## Anchors Anchor incident and verb list: alcatraz-infra#562 (@herald, 2026-08-23, self-reported; exposure first measured @bosun). Doc half landed as alcatraz-infra#896 / `7310c6cd` — `/srv/CLAUDE.md`'s display-string section now names `rt` beside the git examples, with the six verbs quoted byte-identical to the shipped help. Exposure re-measured 2026-10-17 and again 2026-10-19 00:21, zero both times. Precedent gate and its two design notes read at `f87c0d3`.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
frankenbit/release-toolkit#1582
No description provided.