Sandboxed agent git relay
Agent Skills for Claude Code / agentskills.io-compatible agents. Cloudflare Workers + Discord integration patterns.
npx -y skills add okayus/okayus-skills --skill sandboxed-agent-git-relayAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Let a sandboxed coding agent (Claude Code in a Docker devcontainer with a bind-mounted repo) get its work onto GitHub as branches + PRs — and optionally all the way to MERGE — WITHOUT any credential ever entering the sandbox. A host-side systemd timer detects commits on claude/* branches, mints a 1-hour GitHub App installation token, pushes an exact refspec, opens the PR, and (only when the HEAD commit carries a Relay-Merge trailer) squash-merges it once the ruleset's required checks pass. Policy (branch prefix only, no force, never main, merge-by-explicit-signal) is enforced OUTSIDE the boundary. Use when an autonomous agent loop needs push/PR/merge but the rule is "no plaintext credentials in the sandbox". Covers why GitHub App beats deploy keys and GITHUB_TOKEN (PR CI approval gate), the App ID vs Installation ID confusion, the gh credential-helper hijack, the systemd AccuracySec=1min surprise, the squash-merge re-merge loop (three-dot diff can't detect a squash, so the relay re-merges every tick and mints empty commits — guard on a merged PR's head.sha), and reading PR/CI status from the sandbox with unauthenticated curl (gh refuses to run tokenless).
The file declares its own license as MIT. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.
SKILL.md
12.6 KB, ~2.9k tokens by cl100k_base, as published. Nobody here has run it
Sandboxed-Agent Git Relay (host-side push/PR with a GitHub App)
The agent in the sandbox can git commit (commits need no secret). Everything that
needs a credential — push and PR creation — is done by a tiny relay on the host,
reading the same .git through the bind mount. The sandbox never holds a token,
a key, or even a forwarded socket; it cannot push at all.
[sandbox: Claude] commit on claude/<topic> (bind-mounted worktree)
│ (filesystem — no network, no creds; merge signal = "Relay-Merge: yes"
│ trailer in the HEAD commit message, also just a commit)
[host: systemd timer, 60s] relay.mjs:
scan refs/heads/claude/* → diff vs origin/main? → mint 1h App token
→ push exact refspec → ensure PR (idempotent)
→ if HEAD has the Relay-Merge trailer: squash-merge once checks pass
REFUSE: non-claude/* refs, force push (diverged), main
[GitHub] ruleset on main (PR + required check, bypass_actors: []) = server-side backstop
(also the merge gate: an early merge attempt gets 405 → retried next tick)
This is the pattern Anthropic documents for agent deployments ("a proxy outside the agent's security boundary injects credentials; the agent never sees them") — applied to git with zero in-sandbox moving parts.
When to use this skill
- Autonomous agent working in an egress-restricted devcontainer must produce PRs unattended
- The project rule is "no plaintext credentials inside the sandbox" (cf. ADR-style secrets strategy)
- NOT when a human pushes anyway (just keep push on the host), and NOT for fork-based workflows
Why a GitHub App (and not the alternatives) — verified 2026-06
| Option | Verdict |
|---|---|
| GitHub App installation token | ✅ 1-hour expiry, mintable offline from the App private key, down-scopable per token (one repo, contents:write + pull_requests:write), pushes/PRs trigger CI normally, PRs show a bot author |
| Deploy key (SSH) | repo-wide write forever, cannot create PRs (no API), needs ruleset care; SSH-agent forwarding grants ambient use to the sandbox — capability inside the boundary |
GITHUB_TOKEN in Actions | PRs it creates get CI runs stuck in an approval-required state ("Approve workflows to run") — kills unattended loops |
| Fine-grained PAT | static user-bound bearer secret; expiry policy = recurring human toil |
| OIDC for git push | does not exist — GitHub's OIDC is outbound (Actions → clouds) only |
Setup
- Create the App (human, browser): github.com/settings/apps/new → name like
<project>-relay; Homepage URL = the repo; uncheck Webhook Active; Repository permissions:Contents: Read and write,Pull requests: Read and write; "Only on this account". Then Generate a private key (.pem downloads) and Install App → only the target repo. - Store the key on the host, outside every container mount:
~/.config/<project>-relay/app.pem, dir 700 / file 600. Sanity:openssl rsa -in app.pem -check -noout. - Record IDs in
config.envnext to the key — see references/github-app-setup.md for the config template, the JWT smoke test, and the App ID vs Installation ID trap (the number in the install URL is the installation id; the App ID is on the App settings page;GET /apps/<slug>404s for private apps, and/user/installationsneeds an App user token — so verify by minting a JWT and callingGET /app+GET /app/installations). - Install the relay: references/relay-mjs.md →
~/.config/<project>-relay/relay.mjs. ⚠️ Keep it outside the repo — the repo is writable from the sandbox, so in-repo policy code could be edited by the thing it polices. - systemd user units: references/systemd-units.md (60s timer; set
AccuracySec=10sor the default 1-minute coalescing turns your 60s timer into ~2min ticks). Validate manually first:set -a; . config.env; set +a; node relay.mjs→ expect an idle log line. - Server-side backstop (defense in depth, also the merge gate): ruleset on main with
bypass_actors: []anddelete_branch_on_merge=true— see thecloudflare-workers-builds-keyless-deployskill'sreferences/ruleset.md(works standalone). - Sandbox side: allow
git add/commit/checkout/switchin.claude/settings.json, keepBash(git push:*)indeny(deny rules hold even inbypassPermissionsmode); set a distinguishable git identity in the container (git config --global user.name "Claude (<project> sandbox)"); convention: all agent work onclaude/<topic>branches. - Sandbox reads PR/CI status with unauthenticated curl, not
gh:ghrefuses to run without a token even against public repos, and puttingGH_TOKENin the sandbox would break the whole premise. For a public repo, unauthenticated REST is enough (60 req/h/IP):curl -s https://api.github.com/repos/<owner>/<repo>/commits/<branch>/check-runsfor CI results,.../pulls?head=<owner>:<branch>for PR state. Add the matchingBash(curl -s https://api.github.com/*)allow entry; the egress firewall already covers GitHub's IP ranges.
Relay policy decisions (encoded in relay.mjs)
- Prefix-only: scans
refs/heads/claude/and pushesrefs/heads/X:refs/heads/Xexact refspecs. main is structurally unreachable. - No force, ever: if the remote branch is not an ancestor of the local tip, log
REFUSEand skip — divergence is for a human to resolve. - Skip when no diff vs
origin/main(three-dot diff): empty new branches produce no PR. ⚠️ This three-dot diff does NOT detect a squash merge — filtering squash residue isisTipAlreadyMerged's job (see "The squash-merge re-merge loop" below), not this check's. - Token hygiene: minted per tick only when needed, down-scoped (
repositories: [repo], contents+PR write), passed to git via env + an inline credential helper — never argv, never disk. Prepend-c credential.helper=(empty) first: without that reset, a globally configuredghcredential helper silently wins and pushes as the user, not the App. - Idempotent PR:
GET /pulls?head=owner:branchbeforePOST; reruns log "PR exists". - Merge only on explicit signal: see next section. No trailer → the relay never merges; the human does.
Optional: agent-initiated merge (the Relay-Merge: yes trailer)
By default the human merges every PR. To let the agent close the loop unattended (merge → deploy → verify), the relay also implements merge delegation — same philosophy as push: the signal comes from inside the boundary (a commit), the execution and the policy live outside it.
- Signal: the agent puts
Relay-Merge: yesas a trailer in the HEAD commit message of theclaude/*branch. A trailer on an older commit is void — pushing more commits requires re-signaling. This makes merge a deliberate, auditable, per-PR decision recorded in history (the trailer survives in the squash commit). - Execution: the relay squash-merges the open PR, passing the local tip
shaso GitHub rejects (409) if the branch moved in between (TOCTOU guard), then deletes the remote and local branch. - Safety: CI-green is enforced server-side by the main ruleset — the relay
doesn't need to (and doesn't) check CI itself. Signaling before CI finishes is
safe: GitHub answers 405 ("Required status check ... is expected"), logged as
merge pending, retried next tick. No extra App permission is needed (contents:writecovers the merge endpoint). - What you give up, knowingly: trailer'd PRs reach main (and any main-triggered
deploy) with no human review. Mitigations: (a) the agent's own convention —
don't add the trailer for risky or uncertain changes; (b) grow the ruleset's
required checks (tests!) as the project matures. Record the governance change in
an ADR; the revert path is "delete
tryMerge" — ruleset, App, and sandbox config are untouched.
The squash-merge re-merge loop (the one that bites — fix is mandatory)
squash merge does not make the branch's commits ancestors of main; only
their content lands, as one brand-new commit. So the three-dot diff
origin/main...branch stays non-empty after a squash merge, and
hasDiffAgainstMain alone judges the branch "still has unmerged work" forever.
Combine that with tryMerge's post-merge local-branch delete failing whenever the
sandbox still has the branch checked out, and the relay re-pushes → re-PRs →
re-squash-merges the same branch every tick, minting an empty squash commit onto
main (and firing the deploy) each time. This actually happened: 9 empty commits +
9 redundant deploys before it was caught (2026-06-13).
The guard — isTipAlreadyMerged(branch, sha), checked before push: ask GitHub
whether a closed PR with merged_at set and head.sha === the current local tip
already exists. If so, the content is already on main → skip push/PR/merge and
delete the local branch (best-effort). Do not rely on tryMerge's cleanup alone:
it cannot remove a checked-out branch, which is exactly the condition that ignites the
loop. The three-dot hasDiffAgainstMain check stays (it cheaply filters truly-empty
branches without an API call), but it is not the squash filter — this is.
Operational corollaries: (a) after signaling Relay-Merge, have the agent git switch off the branch so the local ref can be reaped next tick; (b) squash residue
already on main (empty commits) can't be removed without rewriting protected history
— leave it, it's inert. The guard makes the loop impossible regardless of (a).
Operations
systemctl --user list-timers <project>-relay.timer # next tick
journalctl --user -u <project>-relay.service -f # logs (one line per action)
systemctl --user start <project>-relay.service # manual one-shot
Log vocabulary: pushed / PR created / PR exists / REFUSE / merge pending #N / merged PR #N / idle.
The human's recurring job is reviewing and merging PRs — governance, not secret
handling. With the Relay-Merge trailer enabled, that becomes per-PR opt-out:
no trailer → human merges, as before.
E2E acceptance test
- In the container:
git checkout -b claude/e2e-test, commit a doc tweak,git checkout main. - Within ~70s the journal shows
pushed claude/e2e-test+PR created: <url>; PR author isapp/<project>-relay. - CI runs on the PR with no approval prompt and goes green.
- Merge (human) → branch auto-deleted; relay's next tick skips the residue (no diff vs main); deploy pipeline (e.g. Workers Builds) fires off main.
Merge-delegation variant (proves the trailer path end-to-end):
- In the container, on the open branch:
git commit --allow-empty -m "chore: request merge" -m "Relay-Merge: yes". - Journal shows
pushed(new tip) →merge pending #N(405 while CI re-runs — this confirms the server-side gate) → next tickmerged PR #N, branch deleted both sides. - The whole loop — commit → push → PR → CI → merge → deploy — ran with zero human steps and zero sandbox credentials.
What ships with it: 3 files
16.0 KB alongside SKILL.md
references/
- github-app-setup.md4.2 KB
- relay-mjs.md10.2 KB
- systemd-units.md1.5 KB