Attach-at-relay: the credential egress proxy¶
Where this fits: this is the deep-dive on the credential plane of the unattended cloud runner — start there for the workflow and setup. Siblings: the
/gh-tokenbroker (the simpler, trusted-worker alternative to this proxy) and the pull-API runner (an alternative way to select work; independent of how auth is done).
The unattended cloud runner runs disposable, prompt-injectable workers that must clone/fetch/push across several repos — yet a worker must never hold a GitHub credential. Attach-at-relay turns the relay into a scoped, credential-attaching egress proxy: the worker points its git remote at the relay carrying only a low-value placeholder + per-run token; the relay attaches the real, repo-scoped GitHub App token at egress and enforces what the worker may do. The credential never lands on the worker.
Modules: src/mship/core/relay/egress/* (proxy role), src/mship/core/relay/grants.py
(typed ceiling), src/mship/core/relay/run_token.py (per-run token). CLI:
mship relay grant, mship relay issue-run-token, mship relay egress-server.
1. Trust model¶
Three trust tiers, least-trusted first:
- Worker — least trusted. Disposable and prompt-injectable. It holds only a
placeholder (a git
insteadOfURL rewrite — not a secret) and a low-value per-run token. It never holds a GitHub credential. If the worker is fully compromised, the attacker gets the per-run token — and that token, presented directly togithub.com, is rejected (it is not a GitHub bearer); presented to the relay it unlocks only a push of the run branch to the run's repos. - Relay / front door — trusted transport (in v1). Terminates the worker's TLS and forwards to the small trusted core. In the co-located deployment the front door and the core run on the same host, so the relay is trusted.
- Secrets-egress host — the small trusted core. Does the exchange: verify the per-run token → resolve the enrollment ceiling → enforce the request → mint the repo-scoped App token → attach it host-locked → forward to GitHub.
Containment rests on four independent limits, so no single slip is catastrophic:
1. the credential is never on the worker (attached only at egress);
2. the git-smart-HTTP receive-pack enforcer restricts pushes to the run branch
for a run-scoped repo (clone/fetch pass; other branches, other repos, and
branch deletes are refused);
3. the minted App token is repo-scoped and short-TTL (built-in blast-radius cap);
4. the Attachment is host-locked — a route misconfig cannot send the credential
to any host outside github.com / api.github.com.
2. The co-located deployment (v1)¶
All three roles run on the single relay the operator already runs. This is the deliberate v1 simplification: some host must see the bearer credential in plaintext to attach it to an outbound request — that is unavoidable for any bearer-token scheme. The design does not pretend otherwise; it minimizes and isolates that plaintext exposure to the small egress-proxy core and keeps it off the worker entirely.
3. Deployment trust: trusted relay today, untrusted relay later¶
The end-state splits the tiers onto separate hosts: worker / blind relay / separate secrets-egress host. Because the egress-proxy is a distinct module whose worker session logically terminates at it — authenticated end-to-end by the per-run token the module verifies itself, not by "the relay says so" — relocating that module onto its own host behind a now-blind relay is a deployment / wiring change, not a channel rewrite. The worker config, the token, the seams, and the enforcer are all unchanged by the move.
The co-located shape (this doc) vs the untrusted-relay shape is a FORK, not a ladder (internally: Shape 2 / Shape 3). They defend different adversaries:
- Co-located (this): worker is least-trusted; the relay operator is trusted.
- Untrusted-relay: the relay operator is least-trusted (a blind courier), which needs a second, separately-operated secrets-egress component.
You pick the fork by which party you distrust — you do not "graduate" from one to the other. Attach-at-relay retires seal-to-worker / HPKE: there is no longer any need to encrypt a credential to the worker, because the worker never receives one.
4. The four seams (and how they admit GitLab / static secrets with ZERO worker change)¶
The proxy core is route → provider → enforce → attach → forward. Four seams make
it extensible without touching the worker or the core:
CredentialProvider(egress/provider.py) —resolve(identity, grant, request) -> Credential. v1 shipsGitHubAppProvider(wrapscore/gh_app.py). Add aGitLabProviderorStaticSecretProviderimplementing the same method.Attachment(egress/credential.py) — how a credential rides on the wire plus a host-lock. GitHub App =Authorization: token <value>locked to github.com/api.github.com. A different provider supplies a different header/lock, e.g.Authorization: Bearer <value>locked toapi.openai.com.RouteTable(egress/routes.py) — destination host →{provider, enforcer}as data. Adding a host is a new entry (+ one/prefix/inrequest.py, onetls_askallowance, one Caddy block) — nogithub.comspecial-case in code.- Typed
Grants (grants.py) — a new provider scope on the enrollment ceiling.
Security asymmetry (why the generalization strengthens attach-at-relay): a GitHub App token has built-in TTL + repo scope, so even a leaked minted token self- limits. A static third-party API key (OpenAI, etc.) has no built-in TTL or scope — so the off-box egress boundary is its only backstop. Generalizing the four seams to such secrets makes the attach-at-egress boundary more load-bearing, not less.
5. Operator setup¶
RELAY_STORE=/path/to/docker/relay/pending-store
RELAY_PUBKEYS=/path/to/docker/relay/pubkeys
# 1. Approve the worker's enrollment (existing enroll flow).
mship relay approve <enrollment-id> \
--store-dir "$RELAY_STORE" --pubkeys-dir "$RELAY_PUBKEYS"
# 2. Set the CEILING — the repos this enrollment may EVER touch.
mship relay grant <enrollment-id> --store-dir "$RELAY_STORE" \
--provider github-app --repos owner/a,owner/b
# 3. Issue a PER-RUN token (repos ⊆ ceiling, one push branch). Printed ONCE.
mship relay issue-run-token <enrollment-id> --repos owner/a --push-branch feat/<slug>
# 4. Run the egress proxy with the App creds in its env (refuse-on-unreadable key).
MSHIP_GH_APP_ID=<app-id> MSHIP_GH_APP_KEY=/path/to/app.pem \
mship relay egress-server --grant-store-dir ./grants-store --run-token-dir ./run-tokens-store
Caddy fronts egress.<RELAY_DOMAIN> → 127.0.0.1:47280 (on-demand TLS; the
egress label is allow-listed in tls_ask). With no App creds the egress-server
fails closed — every request returns 503, it never forwards unauthenticated.
6. Worker config (the placeholder — holds NO usable GitHub credential)¶
# URL rewrite (the "placeholder" — NOT a secret): git resolves
# https://github.com/<owner>/<repo>.git -> https://egress.<RELAY_DOMAIN>/gh/<owner>/<repo>.git
git config --global url."https://egress.<RELAY_DOMAIN>/gh/".insteadOf "https://github.com/"
git config --global url."https://egress.<RELAY_DOMAIN>/api/".insteadOf "https://api.github.com/"
# The per-run token on the worker->relay leg (LOW value: NOT a GitHub credential).
git config --global http."https://egress.<RELAY_DOMAIN>/".extraHeader "Mship-Run-Token: <token_id>.<secret>"
git then requests …/gh/owner/repo.git/info/refs?service=git-receive-pack and
POST …/gh/owner/repo.git/git-receive-pack; the relay strips Mship-Run-Token +
the inbound Host, attaches the minted Authorization: token <ghs…>, and forwards
to github.com.
State it plainly: the worker holds no usable GitHub credential. The per-run token presented directly to GitHub is rejected; presented to the relay it unlocks only a run-branch push to the run's repos, with a repo-scoped short-TTL App token the worker never sees. Exfiltrating the placeholder + token yields no GitHub access and no other-branch / other-repo write.
The api.github.com leg is live + enforced — but
mship finishdoes not route its PR-open through it yet. The/api/route is deployed behindGitHubApiEnforcer, a DEFAULT-DENY REST enforcer (below) whose only permitted write is opening a PR. Howevermship finishhas no--relay-url/--run-tokenpath today, so a run-token-only worker pushes withmship finish --push-onlyand the PR is opened in an attended step. The canonical statement of what works today is the runbook's Opening the PR — this section documents the enforcer that makes the future relay-routed PR-open safe, so the API path can never sidestep the git push-to-run-branch enforcement.
7. Egress-proxy module boundary¶
The egress-proxy role is the subpackage src/mship/core/relay/egress/:
pktline.py—read_pkt_lines,parse_receive_pack_commands,RefUpdate(pure git-wire).request.py—parse_egress_request(path-prefix host map + repo/service extraction).enforce.py—GitSmartHttpEnforcer(run-branch-only push),GitHubApiEnforcer(default-deny PR-only REST leg) +classify_api_request(its pure classifier).credential.py—Attachment(host-locked),Credential,github_token_attachment.provider.py—CredentialProvider,GitHubAppProvider.routes.py—Route,RouteTable,build_default_routes.proxy.py—build_egress_app(verify → route → enforce → provider → attach → forward; fail-closed when no provider).
The worker's session logically terminates at build_egress_app, authenticated
by the per-run token that module verifies itself (not delegated to the relay). This
self-contained, end-to-end-verifiable boundary is exactly what makes the Shape-3
relocation a deployment change rather than a rewrite.
8. The api.github.com leg — GitHubApiEnforcer (default-deny, PR-only)¶
Opening a PR is a REST call (POST /repos/{o}/{r}/pulls), and the plan of record
is for the worker to open its own PR through this leg once mship finish can
route its PR-open via the relay (today it cannot — see the runbook's
Opening the PR).
The api.github.com route is therefore already routed on the same github-app
provider as the git leg, behind GitHubApiEnforcer. Containment is two independent layers: (a) the
repo-scoped App installation token already bounds every call to the run's repos;
(b) the enforcer is DEFAULT-DENY over the REST surface — it permits an enumerated
allowlist and refuses everything else (403). New/unknown GitHub endpoints are denied
by construction: the safe failure mode is a blocked worker call, never an unexpected
mutation.
PERMIT (and nothing else):
- POST /repos/{o}/{r}/pulls — open a PR (the only permitted write; the worker
sets title + body in this call, so it needs nothing further)
- GET /repos/{o}/{r}/... — reads scoped to the run's repos
- GET /rate_limit, GET /user — safe global reads
Every repo-scoped permit ALSO requires the path's owner/repo to be within the
run's scope.repos (an out-of-scope repo is refused — same containment as the git
leg).
Explicitly DENIED (named + tested, though default-deny already covers them):
- Any write to an EXISTING numbered PR/issue — PATCH /pulls/{n}, POST /issues/{n}/comments,
POST /pulls/{n}/reviews, POST /pulls/{n}/requested_reviewers. The run does not
own every PR in its repos, and the enforcer can't prove a given number is the run's
own PR, so a (prompt-injectable) worker must not be able to close/rewrite/review an
UNRELATED PR. (Managing the run's own PR could return later behind per-run
PR-ownership tracking — recording the number from the open response.)
- PUT /repos/{o}/{r}/pulls/{n}/merge — merging a PR. Nothing auto-merges; the
fan-out is review-gated end to end (#393), so merge is denied even though the
token technically could.
- POST /repos/{o}/{r}/merges
- POST|PATCH|DELETE /repos/{o}/{r}/git/refs/* — ref mutation
- PUT|DELETE /repos/{o}/{r}/contents/* — content mutation
These are exactly the REST paths that could sidestep the git push-to-run-branch
enforcement, so they are called out even though default-deny refuses them anyway.
GraphQL (/graphql) is not routed (REST-only in v1; it would need its own enforcer).
No new deploy surface. The api leg rides the existing egress subdomain: the
/api/ path prefix is already mapped in request.py, the worker config already sets
url."…/api/".insteadOf "https://api.github.com/" (Section 6), the Attachment already
host-locks the token to [github.com, api.github.com], and one Caddy block + one
tls_ask entry already front the whole egress.<RELAY_DOMAIN> host. Adding the leg
was a route-table entry + enforcer, not a new route/TLS allowance. The proxy still
fails CLOSED on the api leg (App creds absent -> 503, never forward).