Skip to content

feat(forge): sign every forge body in Rust, and redirect the gh verbs it covers - #6545

Merged
macanderson merged 6 commits into
mainfrom
feat/git-hygiene-attribution
Sep 21, 2026
Merged

macanderson merged 6 commits into
mainfrom
feat/git-hygiene-attribution

Conversation

@macanderson

@macanderson macanderson commented Sep 21, 2026 •

Copy link
Copy Markdown
Owner

What & why

Stella writes to a forge through bash and gh. Nothing signs what she writes,
so a commit, a pull request body, an issue and a comment all reach GitHub with no
record of what produced them. The prompt cannot fix this: HookDecision is
Allow or Deny, with no rewrite arm, so a hook can refuse a gh call but
cannot append a footer to it. Attribution is deterministic only if Rust appends
it.

This PR adds a forge plane of three tools, signs every body they send, and turns
the gh subcommands they cover into a refusal that names the tool to call
instead.

Refs #1377

The three tools, for eleven verbs

Every tool schema rides the byte-stable prompt prefix on every model call in the
session (invariant 7), so eleven tools is eleven schemas billed per call. The
verbs group by object instead:

Tool Actions
pull_request create, update, close, merge, comment, edit_comment
issue create, update, close, comment, edit_comment
watch_ci one action: watch a branch's checks to a verdict

Read literally, invariant 9 forbids that: a parameter may scope an operation
and may never select one. So the grouping is a decision, and it is recorded as
ADR 0044 rather
than asserted in a PR body. The record states the four conditions that let a
tool group verbs, and the last of them is the new mechanism below. Nothing else
in the tree groups this way. merge refuses without confirm: true, because it
is the one action here that writes to a shared branch and stays there.

Switching off one action

Invariant 9's second reason is that per-tool policy must be able to withhold the
destructive verb without withholding the benign one. Grouping broke that: the
session's gate above the tool stack answers for a whole tool, so
"pull_request": "off" took merge and comment together.

ToolPolicy takes a dotted key now, "<tool>.<action>", answered by
allows_action. Both forge tools ask it per action before anything else runs:

{ "tools": { "pull_request.merge": "off", "issue.close": "off" } }

That agent opens pull requests and comments on them, and never merges one.

An action key can only narrow, because allows_action asks allows first. A
tool switched off by its own name, by its catalog group, or by "*" stays off
whatever an action key says, which keeps deny_all_from's rule true of actions
as well. The tools read the session's own resolved ToolPolicy, handed down the
one assembly point (write_dirs::registry_rooted_at to forge_install::attach
to ToolRegistry::attach_forge), so the gate and the tools cannot come to
disagree about what is switched off. The refusal is
ErrorClass::RefusedByPolicy and names the key.

stella tools prints a tool that is on with an action withheld as on, and names
the withheld actions beside it. Without that line the switch is invisible
everywhere until a session hits the refusal.

Invariant 9's text is amended in place to name this mechanism. The numbering is
an address, so nothing is renumbered.

issue reaches whatever tracker stella.toml names, through
stella_protocol::issue::IssueProvider. A Linear workspace gets Linear. That is
the half gh issue create could never do.

What is blocked, and what is not

bash and gh stay open. A gh subcommand is refused only when a tool here
covers it: pr create/edit/ready/close/merge/comment/checks and
issue create/edit/close/comment. gh pr view, gh pr list, gh pr diff,
gh issue list, gh issue view and gh run watch are all deliberately absent
from the table, and so is every other gh subcommand, and so is all of git.
The refusal text names the tool, the action, and says the rest of gh is open.

With neither adapter attached, forge_redirect returns None and the gh call
runs: the gate is the slot, so a workspace with no forge configured loses
nothing.

The attribution text

Five keys under [self_driving.attribution] in stella.toml, each defaulting
to the string this repository asked for: commit, pull_request, issue,
issue_comment, pull_request_comment. stella_autonomy::sign is idempotent
against its own output, so an update that echoes a signed body back gets one
footer rather than two.

Git hygiene in the prompt

git_hygiene!() in SYSTEM_PROMPT and PIPELINE_SYSTEM_PROMPT: branch from
the fetched tip of the remote default branch before writing, and ask the driver
what to do with a dirty tree rather than deciding for them.

The witness

  • This PR includes witness tests (fail on main, pass here)

cargo test -p stella-tools forge is 31 tests, all new, all failing on main
because the module does not exist there. They cover each action's argument
errors, the merge confirmation gate, the redirect table in both directions (a
covered subcommand refuses, an uncovered one runs), the empty-slot passthrough,
and sign's idempotence.

Two of them hold the per-action gate, one per tool, because one gate serves both
tools and each can lose its call site on its own:
a_switched_off_merge_refuses_while_comment_still_runs and
a_switched_off_close_refuses_while_comment_still_runs. Each asserts the error
class, the key named in the message, that the provider recorded nothing, and
that a second action still runs.

Both were proven by control rather than asserted: deleting the permits call
from pr.rs fails the first, and deleting it from issue.rs fails the second.
Doing it turned up something the test would otherwise have papered over. Without
the gate, merge does not reach the forge anyway: it falls through to the
confirm: true check and is refused as InvalidInput. So the "nothing reached
the provider" assertion passes either way, and only the class assertion tells
the two refusals apart. That is recorded in ADR 0044's consequences.

cargo test -p stella-tool-facts policy is 18 tests, covering allows_action's
precedence and the narrowing rule.

The gate

  • cargo fmt --all — clean
  • make prose — OK on all three ratchets (counts, reading grade, header density)
  • make guards-fast — green
  • cargo test -p stella-tools forge — 27 passed, 0 failed
  • RUSTDOCFLAGS="-D warnings" cargo doc -p stella-tools --no-deps --document-private-items — clean
  • cargo clippy --workspace / cargo test --workspace — not run here. CLAUDE.md
    reserves the workspace build for CI on this machine, so this PR's CI run is
    its evidence.
  • Docs updated: docs/tools/pull_request.md, issue.md, watch_ci.md, and
    the five keys in website/content/docs/stella-toml.mdx

Fix over file

  • Extra fixes in this PR:
    1. The generated tool-page template. RISK_NOTE and two of the fallback
      texts carried a filler adverb and an honesty declaration, so every page the
      template generates shipped both. scripts/prose-baseline.txt records that
      as 40 entries: one filler-adverb and one honesty-declaration for each of
      the 19 .toml pages and for docs/tools/README.md. Fixing the template
      clears all 40 at once — python3 scripts/check-prose.py --report now shows
      no hit of either pattern anywhere under docs/tools/, only the
      issue-reference hits that come from the per-tool declarations rather than
      the template. Those 40 baseline entries are therefore stale-high. Reclaiming
      that slack is make prose-retighten, which AGENTS.md says is a PR of its
      own, so it is deliberately not done here.
    2. website/content/docs/stella-toml.mdx spelled the wordmark capitalised
      on seven lines, which make brand-case fails on, and repeated one
      95-character default string verbatim in five defaultValue attributes. The
      five defaults move into one fenced block, which reads once and which the
      guard skips by design, since a config excerpt is a quotation rather than
      brand copy.
  • Filed, with the reason a fix could not ride this PR: nothing new filed;
    one handoff posted as a comment on Git Tools & Agent Git Workflow Bindings #1377 (below).

What was deferred, and why

The [run] create_worktrees key is not here. A no-worktree path is 250 to 400
lines across roughly 14 call sites and carries two hazards that make a partial
fix worse than the defect:

  • graph_seed::seed_from_parent(root, path) with equal paths asks SQLite to copy
    a database onto itself. Its own test
    the_worktree_gets_its_own_file_not_the_repositorys asserts the opposite
    invariant.
  • tree_change reads git status --porcelain and attributes all dirt to the
    turn. In the repository root an operator's uncommitted edits would make a no-op
    turn report Changed, and drive.rs would open a pull request on it.

It also needs a new exclusivity invariant (root mode is one session per clone,
so parallel.rs waves can never use it) and a LoopConfig threading decision.
That is larger than this session, which is AGENTS.md's third deferral case. The
full map is posted as a comment on #1377.

Ground-rule check

  • No I/O added to stella-core. The forge tools live in stella-tools, and
    the adapters a crate above in stella-cli, reached through a host-filled
    slot.
  • No new outbound network calls beyond the forge the operator already
    configured and the gh binary they already installed.
  • No new dependencies.

Anything reviewers should know?

Why the adapters are reached through a slot. PullRequestProvider and
IssueProvider are implemented in stella-cli, one crate above stella-tools,
so the tool cannot own them. ForgeSlots is the same shape as
crate::subagent::DispatcherSlot and crate::registry::question: the host fills
it at registry construction. A tool with an empty slot is still listed, so the
schema list never depends on settings the prompt cache cannot see change.

This PR closes nothing. #1377 is the wider git-tools issue and this advances
it without finishing it, so the trailer is Refs, not Closes. It wants the
closes-nothing label rather than no-issue: the change is substantial.

watch_ci is read_only but not speculation-safe. Each call spends shared
rate-limit quota, so the engine must not fire it speculatively.

Commit 622a53262's message carries a false claim. Read this instead. It
says check-brand-case.sh "cannot see a JSX attribute value." That is wrong,
and I did not check it before writing it. Probing the guard with a fixture
containing four shapes — a single-line defaultValue, a title attribute,
plain prose, and a multi-line attribute — flags all four:

website/content/docs/a.mdx:3: <Field name="commit" defaultValue="Generated by Stella. Inspect it." />
website/content/docs/a.mdx:5: <Card title="Stella runs this" />
website/content/docs/a.mdx:7: plain prose naming Stella here
website/content/docs/a.mdx:11:   defaultValue="Generated by Stella."

The guard strips fenced blocks, inline code spans, and URLs, and reads
everything else, JSX attributes included. So the real shape is the opposite of
what that commit message says: the guard saw the five defaultValue strings and
correctly refused them, and moving them into a fenced block is me taking the
remedy the guard offers, not routing around a blind spot.

What is true is narrower, and is a design limitation rather than a defect: there
is no way to exempt an attribute value that the component renders as a code
span, short of moving it into a fence. The fence is the better document anyway,
so nothing is filed for it — an open design question with no defect behind it is
not an issue (AGENTS.md § "Fix over file"). The commit message cannot be
rewritten without a force-push, which this repository forbids, so this note is
the correction.


Review round: 68e5de9e9

A peer review of 52119dc35 found six defects. All six are fixed here. Each
is a separate fix in one commit, named so a reviewer can read them apart.

The gh redirect matched a literal token, and six spellings walked past
it.
command gh, env gh, sudo gh, GH_TOKEN=x gh, /usr/bin/gh, and a
gh opened inside $(...) or backticks all reached the forge carrying
whatever footer the model typed. mentions_in_line now asks three questions:
names_gh reads the last path segment, space_substitutions turns a
substitution opener into its own token, and is_command_position walks back
over wrappers and leading assignments to a separator. Two tests cover 22
spellings, split by direction: the wrapped and pathed forms must be refused,
and the same words inside an argument (echo command gh pr create,
git commit -m "gh pr create") must stay open.

Proven by control, not asserted. With WRAPPERS emptied and names_gh
reduced to word == "gh", cargo test -p stella-tools --lib forge fails on
must be refused: command gh pr create --fill. Restored, 38 pass.

One lock had two policies. The redirect read the forge slots with
.read().ok()?, so a poisoned lock read as "nothing is attached" and opened
every covered gh command. ForgeSlots::has_forge and has_tracker state the
poison policy once, beside the lock, and the redirect asks them.

An unknown action was answered by whatever failed next. On a workspace with
no gh, action: "frobnicate" reported "no forge is configured", which sends
the reader after a missing binary instead of a misspelled word. known_action
runs before the slot lookup. The trailing match arm stays and catches the
opposite drift: an ACTIONS entry nobody wrote an arm for. Witness:
an_unknown_action_is_not_reported_as_a_missing_forge.

A failed description write could leave a pull request marked ready.
pull_request_provider::update ran gh pr ready before gh pr edit, so a
gh that failed on the text left reviewers the old description under a mark
saying it was ready to read. The text is written first. Neither order is
atomic; only one of them can publish the wrong thing.

Five fixtures named a footer this crate does not ship.
"created by stella*" in self_driving_cmd's tests became
stella_autonomy::SIGNATURE, so they cannot drift from it again.

sign()'s idempotence is tail-anchored, and now says so. A footer quoted
mid-body gets a second one at the end. Reaching into the body to find and move
it would mean editing what the model wrote, which is the one thing this plane
does not do.

Deferred, and why

Commit signing is the one surface the model can still paraphrase, and it is
filed as #6546 rather than fixed here. Both fixes are maintainer decisions
under AGENTS.md § "Fix over file" case 1: a prepare-commit-msg hook is
deterministic and covers humans too, but this repository already sets
core.hooksPath=.githooks and a second hook home is a decision; a commit tool
is deterministic and agent-scoped, but it costs a schema on the byte-stable
prefix (invariant 7) and takes git commit away from bash. The issue carries
both, the file paths, and what done looks like. It moves reliability.

Evidence

cargo test -p stella-tools --lib forge — 38 pass.
cargo test -p stella-cli --bin stella self_driving_cmd — 267 pass.
cargo fmt --check, make prose, make guards-fast — clean.
cargo clippy -p stella-tools -p stella-autonomy -p stella-cli --all-targets -D warnings — clean.
cargo doc -p stella-tools -p stella-autonomy --document-private-items with
RUSTDOCFLAGS="-D warnings" — clean, after it caught a private intra-doc link
this round added. The workspace build and suite are CI's, per CLAUDE.md.

Reviewer sign-off

The reviewer that raised these findings re-read the tree at 68e5de9e9 and
reports nothing outstanding. It checked its own ranked list against the source
rather than against this description, which caught a numbering mismatch: the
six fixes above map to its suggestions, and its ranked findings 3 to 6 are the
--raw-field flag, the stella.example.toml commit claim, the
forge_install scope doc, and the title prefix on update. All four are in
the tree.

One of its findings is worth restating, because the fix reads like a
refactor. split_whitespace erased newlines, so a two-line script
tokenised into one stream and is_command_word compared gh against the
last word of the line above it. git push -u origin HEAD followed by
gh pr create --fill walked straight through the redirect, and a newline is
the most ordinary separator a model writes. mentions scans per line now, so
a line boundary is a command boundary and index == 0 is correct rather than
accidental. Witness: a_command_on_the_next_line_is_still_checked, with
naming_the_command_on_a_later_line_is_still_not_running_it as the control
that per-line scanning does not turn echo gh pr create into a run.

…covers

Eleven forge verbs land as three tools: `pull_request` (create, update,
close, merge, comment, edit_comment), `issue` (create, update, close,
comment, edit_comment) and `watch_ci` (one branch, its checks, a verdict).
Grouped by the thing they act on rather than split one per verb, because a
tool schema rides the cached prompt prefix on every call in the session
whether or not that session ever opens a pull request.

They are tools rather than prompt instructions so the attribution footer is
appended in Rust, after the model has stopped having a say. A prompt can ask
for a footer and a model can forget it or reword it; a hook cannot repair
that, because `HookDecision` is Allow or Deny with no arm that rewrites.

`bash` now refuses the eleven `gh` subcommands these tools cover, and names
the tool and action to use instead. The rest of `gh` stays open, `git` is
untouched, and the refusal is gated on the slot: a workspace with no forge
attached has no tool to point at, so nothing is refused there.

The adapters live in `stella-cli`, one crate up, so the tools hold
`ForgeSlots` the host fills after assembly -- the shape `DispatcherSlot` and
the `question` registration already use. A tool whose slot is empty is still
listed and says what would set one up, so the schema list never depends on
settings the prompt cache cannot see change.

`merge` refuses without `confirm: true`. It is the one verb nothing walks
back, and the guard is about the verb rather than the deployment, so a
workspace approval gate stacks with it rather than replacing it.
…iene

Attaches the forge. `forge_install::attach` reads
`[self_driving.attribution]` and probes for `gh`, then fills the registry's
slots: the tracker and pull request adapters when `gh` is present, the
attribution always. One call site, in `write_dirs::registry_rooted_at`, so
every door that builds a registry gets the same wiring.

`[self_driving.attribution]` grows from one key to five -- `commit`,
`pull_request`, `issue`, `issue_comment` and `pull_request_comment` -- each
defaulting to the shipped signature. The section is source-tracked so it
travels with the repository, and an installed plugin may rewrite it, so a
downstream distribution signs in its own name without forking. Setting a key
to "" signs that one surface with nothing.

The system prompt gains a git hygiene paragraph, in both the full and
pipeline variants. Fetch first, branch from the fetched tip rather than from
whatever the local checkout points at, never commit to main or master, never
force-push, never push a branch you did not create. A dirty tree stops the
session and asks the driver: those edits are someone's unfinished work, so
no stashing, no discarding, and no committing them into a branch of your own
to get them out of the way. With nobody to ask, work only in ways that leave
those files alone and say the tree was dirty in the answer.

A short forge paragraph rides the same prefix, pointing at `pull_request`
and `issue` over `gh`, saying the tools sign what they write so the model
must not add a footer of its own, and saying that pushing, branching and
committing are still git through bash.

Whether a task gets its own worktree is not settled here. That is #1377's
subject, and the handoff for it is on that issue.

Refs #1377
`issue.toml`, `pull_request.toml` and `watch_ci.toml` join the generated set
so `make tool-docs` matches the declarations again.

Unrelated fix in the same change, because the regeneration touches every
page anyway: the template itself carried prose the repository bans. A filler
adverb and an honesty declaration sat in the header banner and in the
unmeasured-usage and no-example texts, so all twenty existing pages carried
them too. Rewritten at the source, which is the only copy that can stay
correct. The risk note now states the risk rather than announcing that it is
about to.
Adds the `[self_driving.attribution]` cards for the four new surfaces, and
says what the section governs: every session, not only the self-driving
loop, because the `pull_request` and `issue` tools sign what they write
whoever is driving.

Two unrelated fixes ride along, both in the section this change was editing.
The wordmark was capitalised in seven lines, which `make brand-case` fails
on. The five default strings were spelled out five times in `defaultValue`
attributes, one 95-character sentence repeated verbatim; they move into a
fenced block, which reads once and which the guard skips by design, since a
config excerpt is a quotation rather than brand copy.

The guard cannot see a JSX attribute value even when the component renders
it inside a code span. Widening it to look there is a real gap, but it has
no self-test today, so a change to it would ship untested. Left alone.
@macanderson macanderson added the closes-nothing Substantial change that closes no issue by design (SCR-003) label Sep 21, 2026
@macanderson
macanderson marked this pull request as ready for review September 21, 2026 20:40
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry @macanderson, your pull request is larger than the review limit of 150,000 diff characters

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for security reviews. Please try again later.

@macanderson

Copy link
Copy Markdown
Owner Author

Sourcery declined this PR for size

Sourcery posted one line and no assessment table:

Sorry @macanderson, your pull request is larger than the review limit of 150,000 diff characters

So there are no ❌ rows to settle here, and the absence of them is a refusal
rather than a clean bill. Recording it so the next reviewer does not read the
silence as approval.

The measurement

git diff origin/main...HEAD is 251,026 characters over 68 files, 3733
insertions and 476 deletions. Grouped:

Area Characters
docs/tools/ regenerated from the template repair (19 pre-existing pages + README) 76,701
docs/tools/ pages for the three new tools 16,511
the forge code itself 77,819
website/ 7,985
everything else 72,010

Why splitting does not reach the limit

Lifting out the template repair and its 19 regenerated pages — the one part of
this branch that is genuinely a separate concern — leaves 174,325 characters.
That is still over 150,000, so the split buys a cleaner history and no review.

The seam that would save enough does not exist. The three new tool pages are
generated from the tool declarations, and docs/tools still matches the declarations is a gate step, so those pages and the code that declares them
have to land together. Partitioning them is the atomicity hazard AGENTS.md
names under rebase-replay.yml: each half would fail on its own.

What was done instead

A full review pass was run against the diff rather than leaving the change
unreviewed. Its findings are in the following comment.

The checks

All 30 settled before this PR left draft: 27 pass, 3 skipping (the CodeQL
status roll-up, the site deploy, and cla). fmt + clippy + test is among the
passes, which is this PR's workspace clippy and test evidence — CLAUDE.md
reserves the workspace build for CI on this machine.

`pull_request` and `issue` each carry several verbs under one `action`.
The session's gate above the tool stack answers for a whole tool, so
`"pull_request": "off"` took `merge` and `comment` together. An operator
who wanted the agent to comment but never merge had no way to say it.
That is `AGENTS.md invariant 9`'s second reason, and it was broken.

`ToolPolicy` now takes a dotted key, `"<tool>.<action>"`, answered by
`allows_action`. Both forge tools ask it per action before they do
anything else. It can only narrow: `allows_action` asks `allows` first,
so a tool switched off by name, by group or by the wildcard stays off
whatever an action key says. The refusal is `ErrorClass::RefusedByPolicy`
and names the key, so a model reads why it stopped instead of retrying.

The tools read the session's own resolved `ToolPolicy`, handed down the
one assembly point, rather than loading settings a second time.

`stella tools` names the withheld actions beside a tool that is on.
Without it the switch is invisible until a session hits the refusal.

ADR 0044 records the four conditions that let a tool group verbs, since
`invariant 9` read literally forbids what shipped. Its text is amended
in place to name the mechanism; the numbering is an address, so nothing
is renumbered.

Two unrelated fixes ride along: the settings page's group list was
missing `forge`, and it documented no per-action key.

Witnesses, one per tool, because one gate serves both and each can lose
its call site on its own:
`a_switched_off_merge_refuses_while_comment_still_runs` and
`a_switched_off_close_refuses_while_comment_still_runs`. Both were
proven by control. The class assertion is what makes the first bite:
without the gate, `merge` falls through to the `confirm: true` check and
is refused as `InvalidInput` without reaching the forge, so the
"nothing reached the provider" assertion passes either way.

Refs #6545
The redirect matched the literal token `gh`, so six spellings walked past
it: `command gh`, `env gh`, `sudo gh`, `GH_TOKEN=x gh`, `/usr/bin/gh`, and
a `gh` opened inside `$(...)` or backticks. Each one reached the forge with
whatever footer the model typed, which is the choice this plane exists to
take away.

`mentions_in_line` now asks three questions instead of one. `names_gh`
reads the last path segment, so `/usr/bin/gh` and `./gh` are `gh`.
`space_substitutions` spaces out `(`, `)` and the backtick, so a
substitution opener becomes its own token rather than being glued to the
word after it. `is_command_position` walks back over wrappers and leading
assignments and stops at a separator, so `env -i gh` is a command and
`echo env gh` is not.

Proven by control: with `WRAPPERS` emptied and `names_gh` reduced to
`word == "gh"`, the suite fails on `command gh pr create --fill`.

Five more findings from the same review, each one its own fix:

- The redirect read the forge slots with `.read().ok()?`, which turns a
  poisoned lock into "nothing is attached" and lets every covered `gh`
  command through. `ForgeSlots::has_forge` and `has_tracker` state the
  poison policy once, next to the lock.
- An unknown action was answered by whatever failed next. On a workspace
  with no `gh`, `action: "frobnicate"` read as "no forge is configured".
  `known_action` runs before the slot lookup. The match arm at the end
  stays, for the opposite drift: an `ACTIONS` entry with no arm.
- `gh pr ready` ran before `gh pr edit`, so a failed text write left the
  old description marked ready to read. The text is written first, and a
  draft flip that fails after it says so.
- The five `"created by stella*"` fixtures named a footer this crate does
  not ship. They take `stella_autonomy::SIGNATURE`, so they cannot drift
  from it again.
- `sign()`'s idempotence guard is tail-anchored, and the comment now says
  that is the whole promise: a footer quoted mid-body gets a second one at
  the end, because moving it would mean editing what the model wrote.

Refs #6546
@macanderson
macanderson merged commit 331543e into main Sep 21, 2026
34 of 37 checks passed
@macanderson
macanderson deleted the feat/git-hygiene-attribution branch September 21, 2026 23:41

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 68e5de9. Configure here.

verb: ("pr", "checks"),
tool: "watch_ci",
action: "the pull request's branch",
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Redirect names invalid tool actions

Medium Severity

The redirect exists so a refused gh call retries successfully, but three action strings are not valid tool inputs. gh pr ready is sent to update with draft, which is not an action enum value and reads as draft: true — the opposite of marking ready. gh pr merge is sent to merge with confirm: true, and gh pr checks is sent to watch_ci with an action that tool does not take.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 68e5de9. Configure here.

// what the record needs; a line that reads like a footer inside the
// text is the model's prose.
return trimmed.to_owned();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Empty signed bodies get a second footer

Low Severity

The new idempotence check only recognizes a footer that starts with the two-newline separator. An empty body is signed as ---\n{signature} without that leading blank line, so signing that result again does not match and appends a second footer. The function claims to be idempotent against its own output.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 68e5de9. Configure here.

}
prefix = rest;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Wrapper flag values skip the redirect

Medium Severity

is_command_position walks back over wrapper names and their flags, but a flag value is treated as a non-command word and stops the walk. Spellings such as sudo -u user gh pr create, nice -n 10 gh pr merge, and env -u FOO gh issue comment therefore do not redirect and can still write an unsigned body.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 68e5de9. Configure here.

cursor Bot pushed a commit that referenced this pull request Sep 22, 2026
The prose gate rejects "used to". The comments now say what the refusal
names: ready is update with draft set to false, merge's confirm is its
own field, and watch_ci takes branch.

Refs #6545

Co-authored-by: Mac Anderson <mac@oxagen.sh>
cursor Bot pushed a commit that referenced this pull request Sep 23, 2026
env -S and --split-string run the split string, so treating them as
values let gh pr create through. A single-dash cluster is read letter
by letter, so sudo -Eu root and env -iu FOO still name the command.

Refs #6545

Co-authored-by: Mac Anderson <mac@oxagen.sh>
macanderson added a commit that referenced this pull request Sep 23, 2026
## What & why

Bugbot reviewed #6545 once and left two medium findings in
`forge_redirect`. Both are fixed here.

`gh pr ready` was refused with action `update with draft`, which is not
an enum value on `pull_request` and reads as `draft: true`. Ready is
action `update` with `draft: false`. `gh pr merge` now names action
`merge` and `confirm: true` as its own field. `gh pr checks` points at
`watch_ci` with `branch` set to the pull request's head. That tool has
no `action`.

A wrapper flag that takes a value (`sudo -u user`, `nice -n 10`, `env -u
FOO`) was treated as a command word, so the `gh` after it was not
redirected. The parse now consumes that value for the wrapper flags that
take one. `sudo -n echo gh` stays open, because `-n` on sudo takes no
value and `echo` is the command.

The low finding, an empty body signed twice, is residue in #6553.

Refs #6545
Refs #6553

## The witness

- [x] This PR includes a witness test (fails on `main`, passes here),
**or**
- [ ] No witness needed (pure refactor / docs / CI) — because:

`every_redirect_names_a_real_tool_input` reads the action between
backticks and requires it to be in the tool schema's enum. On main the
ready refusal puts `update with draft` in those backticks, which is not
an enum value. `a_wrapper_flag_value_does_not_hide_the_command` requires
`sudo -u user gh pr create`, `nice -n 10 gh pr merge`, and `env -u FOO
gh issue comment` to redirect. On main `is_command_position` stops on
the flag's value and returns `None`.

`cargo test -p stella-tools --lib forge::redirect` — 12 passed.

## The gate

- [x] `cargo fmt --check` (formatted with `cargo fmt -p stella-tools`)
- [x] `cargo clippy -p stella-tools --lib -- -D warnings` passed
- [x] `cargo test -p stella-tools --lib forge::redirect` passed
- [x] Docs updated where behavior/flags changed (the refusal text is the
documentation the model reads)
- [ ] CLA signed (the bot prompts on your first PR — nothing to do per
commit)
- [x] `Refs #6545` appears above and as a commit trailer. This PR does
not close that issue; it is already merged.

## Fix over file

- [x] Extra fixes in this PR: none beyond the two medium review findings
— **or** none
- [x] Filed, with the reason a fix could not ride this PR: the
empty-body footer is #6553. It is a low finding on merged code, outside
the two review findings this PR fixes. The docs deploy failure on
`331543e` is the same `ssm:SendCommand` `AccessDeniedException` for
`gha-deploy-stella` that failed the previous main push (run
35527940671). The role policy is outside this repository, so this PR
does not change `.github/workflows/docs.yml`.

## Ground-rule check

- [x] No I/O added to `stella-core`; no new deps without justification
below
- [x] No new outbound network calls (Stella never phones home)

## Anything reviewers should know?

Bugbot and Sourcery each reviewed #6545 once, so the medium findings are
fixed here rather than filed. `command -v gh` is now left open: `-v`
takes the name and does not run it.

`docs guards` and `main is not known-broken` fail on main's account
(#6548). #6550 registers SCR-006 in the document manifest and #6552
rewrites its prose. This branch merges main again once they land.


<div><a
href="https://cursor.com/agents/bc-74228cf7-e29e-4613-a179-fc7ac74615cd?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/automations/c630848f-b60f-11f1-bb68-864e54d14197"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/view-automation-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/view-automation-light.png"><img
alt="View Automation" width="141" height="28"
src="https://cursor.com/assets/images/view-automation-dark.png"></picture></a>&nbsp;</div>

## Summary by Sourcery

Ensure forge redirects both detect wrapped `gh` invocations reliably and
provide schema-accurate retry instructions.

Bug Fixes:
- Correct forge redirect guidance so refused GitHub CLI commands
reference valid tool inputs, including draft, confirmation, and branch
parameters.
- Prevent wrapper flags that consume values from hiding executable `gh`
commands from redirect enforcement.

Enhancements:
- Refine command-position parsing for shell assignments, wrappers, flag
clusters, and wrapper-specific options while preserving non-executing
cases such as `command -v gh`.
- Distinguish signing write tools from read-only CI monitoring in
redirect messages.

Documentation:
- Update refusal guidance to match the schemas and inputs agents should
use when retrying redirected commands.

Tests:
- Add regression coverage for wrapper flag values, clustered options,
non-executing command forms, and schema-valid redirect guidance.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes bash-layer enforcement of which `gh` invocations are blocked
and the guidance agents get on retry; incorrect parsing could either
block benign commands or miss wrapped `gh` writes.
> 
> **Overview**
> Fixes **forge `gh` redirects** so refusal text matches real tool
schemas and wrapped shell spellings cannot skip signing.
> 
> **Refusal copy** now goes through a `refusal()` helper: each redirect
uses a `call` snippet (e.g. action `update` plus `draft: false` for `gh
pr ready`, `confirm: true` for merge, `branch` for `gh pr checks` →
`watch_ci`) and only mentions signing for write tools.
> 
> **Command-position detection** is rewritten to parse forward from the
last separator, consuming wrapper flags and their values per wrapper
(`sudo -u`, `nice -n`, `env -u`, clusters like `-Eu`, GNU `env -S`).
That closes the hole where `sudo -u user gh …` was treated like `echo gh
…` and allowed unsigned forge writes; `command -v gh` and `sudo -n echo
gh` stay allowed.
> 
> **Tests** add witness coverage that every redirect names a
schema-valid action/input and that value-taking wrapper flags still
trigger redirects.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
5c5fc38. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Mac Anderson <mac@oxagen.sh>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

closes-nothing Substantial change that closes no issue by design (SCR-003)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant