Skip to content

feat(ship): executable pull-request rail, and canary 5 with it - #86

Merged
AetherAI3 merged 1 commit into
mainfrom
feat/ship-rail
Aug 19, 2026
Merged

feat(ship): executable pull-request rail, and canary 5 with it#86
AetherAI3 merged 1 commit into
mainfrom
feat/ship-rail

Conversation

@AetherAI3

Copy link
Copy Markdown
Owner

Why this exists

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 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.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. 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 happen before gh runs at all

About intent, not just syntax:

input why refused
--repo=evil/repo as head gh would swallow it as a flag
base == head a pull request onto itself is never what was meant
main / master as head the work was committed somewhere it should not have been
empty title a pull request needs one

A refused ship invokes gh zero times — asserted, not assumed.

Environment failures are reported as themselves

  • missing gh → says so, and prints the manual command
  • signed out → says signed out, and never attempts creation
  • exit 0 with no URL printed → a failure, not a silent pass

That last one matters: gh returning 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:

  • 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, --auto
  • 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 — precisely the naive implementation this module exists to avoid:

result
mutated 12 pass / 3 fail, including the hostile-title canary
restored 15 / 15
command result
npm run typecheck exit 0
npm test 1109 pass / 0 fail

Scope — deliberately not wired yet

This changes no user-visible behaviour. The module and its canary land first; nothing calls openPullRequest from 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 — renderShipPlan exists 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

canary
1 denied mutation
2 live-child cancel
3 reconnect replay
4 remote freshness
5 fake-gh ship this PR
6 cap across reconnect
7 brain parity ❌ still blocked — LocalBrain has no injectable transport

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

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
@AetherAI3
AetherAI3 merged commit 1aac348 into main Aug 19, 2026
5 checks passed
@AetherAI3
AetherAI3 deleted the feat/ship-rail branch August 19, 2026 15:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant