agentleFS
okayusokayus/okayus-skills/skills/sandboxed-agent-git-relay/SKILL.md

sandboxed-agent-git-relay

Skill0 starsChanged 5 days agoMITView on GitHub

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 a GitHub App beats deploy keys and GITHUB_TOKEN, the App ID vs Installation ID confusion, the gh credential-helper hijack, the squash-merge re-merge loop guard, and reading PR/CI status from the sandbox with unauthenticated curl (gh refuses to run tokenless).

  • Reads credentials
  • Deletes or force-pushes
  • Commits and pushes

The file

We split this skill into the rules it gives an agent

1 rule. About 0 are common, 1 is distinctive, and 0 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈1

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 0

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    NOT when a human pushes anyway (just keep push on the host), and NOT for fork-based workflows

    line 39

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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

OptionVerdict
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 ActionsPRs it creates get CI runs stuck in an approval-required state ("Approve workflows to run") — kills unattended loops
Fine-grained PATstatic user-bound bearer secret; expiry policy = recurring human toil — but if a repo-scoped, expiring token inside the sandbox is an acceptable trade for having no relay at all, that path is documented in sandboxed-agent-github-token-via-1password (token resolved from 1Password at docker compose up, env only)
OIDC for git pushdoes not exist — GitHub's OIDC is outbound (Actions → clouds) only

Setup

  1. 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 — and deliberately no Workflows permission: withholding it means the agent pipeline cannot modify its own CI gate (pushes touching .github/workflows/** get a clean REFUSE; land those with a human-credentialed push — see "Relay policy decisions" and references/github-app-setup.md); "Only on this account". Then Generate a private key (.pem downloads) and Install App → only the target repo.
  2. 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.
  3. Record IDs in config.env next 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/installations needs an App user token — so verify by minting a JWT and calling GET /app + GET /app/installations).
  4. 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.
  5. systemd user units: references/systemd-units.md (60s timer; set AccuracySec=10s or 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.
  6. Server-side backstop (defense in depth, also the merge gate): ruleset on main with bypass_actors: [] and delete_branch_on_merge=true — see the cloudflare-workers-builds-keyless-deploy skill's references/ruleset.md (works standalone).
  7. Sandbox side: allow git add/commit/checkout/switch in .claude/settings.json, keep Bash(git push:*) in deny (deny rules hold even in bypassPermissions mode); set a distinguishable git identity in the container (git config --global user.name "Claude (<project> sandbox)"); convention: all agent work on claude/<topic> branches.
  8. Sandbox reads PR/CI status with unauthenticated curl, not gh: gh refuses to run without a token even against public repos, and putting GH_TOKEN in 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-runs for CI results, .../pulls?head=<owner>:<branch> for PR state. Add the matching Bash(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 pushes refs/heads/X:refs/heads/X exact refspecs. main is structurally unreachable.
  • No force, ever: if the remote branch is not an ancestor of the local tip, log REFUSE and skip — divergence is for a human to resolve. ⚠️ The usual trigger is the agent --amending or rebasing a commit the relay already pushed — the rewritten SHA is no longer a descendant of the pushed tip, so every tick REFUSEs and the PR is stuck (the relay won't force). Recover forward, never force: git reset --hard <pushed-sha>, then add a fast-forward follow-up commit (--allow-empty if it only needs to re-carry a Relay-Merge: yes trailer). Don't be lured into amending by a git log --format='%(trailers:…)' self-check showing the trailer empty: the relay matches the signal with a multiline regex on the full body (/^Relay-Merge: yes$/m), so a blank line before it or its position among other trailers is irrelevant — git's stricter trailer parser is not what detects it.
  • 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 is isTipAlreadyMerged'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 configured gh credential helper silently wins and pushes as the user, not the App.
  • Idempotent PR: GET /pulls?head=owner:branch before POST; reruns log "PR exists".
  • Merge only on explicit signal: see next section. No trailer → the relay never merges; the human does.
  • No workflows permission — on purpose: the token is contents+pull_requests only, so GitHub rejects any push touching .github/workflows/** (refusing to allow a GitHub App to create or update workflow ... without 'workflows' permission). This is a feature, not a gap: the agent pipeline cannot modify its own CI gate — the same gate that enforces CI-green-before-merge for the Relay-Merge path. The relay detects that one rejection and logs REFUSE <branch>: workflow-file change needs a human push (returning cleanly, not crashing the tick with exitCode=1 on every run). Land CI changes with a human-credentialed push (a user/gh token carrying the workflow scope): git push origin claude/<branch> → next tick the relay sees remote == local, skips the push, and opens the PR normally. Grant the App workflows: write only if you deliberately want agent-authored workflow edits — it hands the autonomous loop the power to weaken its own gate.

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: yes as a trailer in the HEAD commit message of the claude/* 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 sha so 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:write covers 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

  1. In the container: git checkout -b claude/e2e-test, commit a doc tweak, git checkout main.
  2. Within ~70s the journal shows pushed claude/e2e-test + PR created: <url>; PR author is app/<project>-relay.
  3. CI runs on the PR with no approval prompt and goes green.
  4. Merge (human) → branch auto-deleted; relay's next tick skips the residue via the isTipAlreadyMerged guard (a squash leaves the three-dot diff non-empty, so "no diff vs main" is not what saves you — see the squash-merge re-merge loop above); deploy pipeline (e.g. Workers Builds) fires off main.

Merge-delegation variant (proves the trailer path end-to-end):

  1. In the container, on the open branch: git commit --allow-empty -m "chore: request merge" -m "Relay-Merge: yes".
  2. Journal shows pushed (new tip) → merge pending #N (405 while CI re-runs — this confirms the server-side gate) → next tick merged PR #N, branch deleted both sides.
  3. The whole loop — commit → push → PR → CI → merge → deploy — ran with zero human steps and zero sandbox credentials.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.