feat(ship): executable pull-request rail, and canary 5 with it - #86
Merged
Conversation
Canary 5 could not be written because there was nothing to attack. repo.ts's
prCreateHint returns a STRING for the user to type; no code in this repo has
ever invoked `gh pr create`. A fake-gh harness would have asserted against code
that did not exist, which is why that canary sat unrun.
This lands the path, so the canary becomes real.
src/core/ship.ts keeps three rules:
1. argv arrays, never a shell string. A title, branch or body is
attacker-influenced the moment a model writes it, and a command-substitution
payload is only dangerous if a shell ever sees it. Nothing here builds a
command line.
2. the user's own gh session. No env is set, so the child inherits their
configuration exactly as ghAuthStatus already does. No Aether credential
is reachable from this module.
3. nothing merges, nothing pushes to a base. It opens a pull request and
returns its URL. Landing it stays a human action.
Refusals are about intent, not just syntax, and happen BEFORE gh is invoked at
all: a head branch parsing as an option (`--repo=evil/repo`) would be swallowed
by gh as a flag; a pull request from a branch onto itself is never what was
meant; and `main` as the head means the work was committed somewhere it should
not have been. An empty title is refused too.
Environment failures are reported as themselves rather than guessed past. A
missing gh CLI says so and prints the manual command; a signed-out gh says
signed out and never attempts creation; and a zero exit with no URL printed is
a failure, not a silent pass.
Canary 5, 15 tests. The load-bearing ones assert the exact vector handed to gh:
a hostile title stays exactly ONE argv element immediately after --title, and
appears exactly once; a hostile body and branch likewise; no element anywhere
can be merge, push, --force, --admin or --auto; and no credential shape (aek_,
Authorization, GH_TOKEN, GITHUB_TOKEN, x-access-token, --with-token) reaches
the argv.
Mutation-checked: collapsing the argv into a single shell-style string — the
naive implementation this module exists to avoid — fails 3 canaries including
the hostile-title one (12 pass / 3 fail). Restored, 15 / 15.
NOT wired to a command yet, so this changes no user-visible behaviour. The
module and its canary land first; the CLI surface, which must ask before
publishing anything under the user's name, is the next slice.
Gates at this commit:
npm run typecheck exit 0
npm test 1109 pass / 0 fail
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why this exists
Canary 5 could not be written, because there was nothing to attack.
repo.ts'sprCreateHintreturns a string for the user to type. No code in this repo has ever invokedgh pr create. A fake-ghharness would have asserted against code that did not exist — which is exactly why that canary sat unrun through the whole A0–A5 wave.This lands the path, so the canary becomes real.
Contract
src/core/ship.tskeeps three rules:ghsession. No env is set, so the child inherits their configuration exactly asghAuthStatusalready does. No Aether credential is reachable from this module.Refusals happen before
ghruns at allAbout intent, not just syntax:
--repo=evil/repoas headghwould swallow it as a flagmain/masteras headA refused ship invokes
ghzero times — asserted, not assumed.Environment failures are reported as themselves
gh→ says so, and prints the manual commandThat last one matters:
ghreturning success while printing nothing usable is the shape that would otherwise produce a confident "shipped!" with no pull request.Canary 5 — 15 tests
The load-bearing ones assert the exact vector handed to
gh:--title, and appears exactly oncemerge,push,--force,--admin,--autoaek_,Authorization,GH_TOKEN,GITHUB_TOKEN,x-access-token,--with-token) reaches the argvMutation-checked. Collapsing the argv into a single shell-style string — precisely the naive implementation this module exists to avoid:
npm run typechecknpm testScope — deliberately not wired yet
This changes no user-visible behaviour. The module and its canary land first; nothing calls
openPullRequestfrom a command.That is intentional. Opening a pull request publishes work under the user's name, so the CLI surface needs an explicit confirmation showing repository, head, base and title before anything is created —
renderShipPlanexists for that and is tested, but the command that uses it is the next slice.Landing the rail inert mirrors how the skills runtime landed in #72: the primitive and its proof first, the surface after.
Release clearance
LocalBrainhas no injectable transportSix of seven now run on every build. Canary 7 needs a seam on the Python path before it can be written; it is not stubbed green.