# Turn a GitHub Issue Into a Pull Request From a Sandbox

> Create a sandbox with --no-env so it holds none of your secrets, pass one short-lived fine-grained GitHub token as GH_TOKEN, then clone, branch, run the agent, run the tests, push and open the pull request with gh. Delete the sandbox at the end. Our test run, with no model and no push, took 33 seconds.

Source: https://sandboxer.com/guides/turn-a-github-issue-into-a-pull-request-from-a-sandbox. Updated 2026-10-27.

**Short answer:** Create a sandbox with --no-env so it holds none of your secrets, pass one short-lived fine-grained GitHub token as GH_TOKEN, then clone, branch, run the agent, run the tests, push and open the pull request with gh. Delete the sandbox at the end. Our test run, with no model and no push, took 33 seconds.

## At a glance

| Topic | Detail |
| - | - |
| Isolation | One microVM per issue, created with `--no-env`: none of your variables, files or credentials |
| GitHub access | One fine-grained token for one repository, passed as `GH_TOKEN` with `-e` |
| Tools in the image | `git` 2.43, `gh` 2.102, Go, Node, Python (checked with `sbx exec`) |
| Size used below | `small`: 2 vCPU, 4 GB RAM, 12 GB disk, $0.018/hour, billed per second |
| Time per run | 33 seconds end to end on `google/uuid` (one run, 2026-10-27), with a placeholder agent step and no push |
| Cleanup | A shell `trap` deletes the sandbox even if a step fails |

What we ran on prod for this page: the whole script below against the public repo [google/uuid](https://github.com/google/uuid) and its open issue #147. Two things did **not** run. No model ran: the agent step is a placeholder script. Nothing was pushed: we have no token for that repo, so the run stops at the commit and a `git format-patch` file, which is exactly what the push would send.

## 1. Make a token for this one job

Create a [fine-grained personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token) on GitHub with:

- **Repository access:** only select repositories, and pick the one repo the agent works on.
- **Permissions:** Contents read and write (to push the branch) and Pull requests read and write (to open the PR). Nothing else.
- **Expiration:** the shortest your workflow allows.

The [GitHub CLI reads `GH_TOKEN`](https://cli.github.com/manual/gh_help_environment) from the environment, and [`gh auth setup-git`](https://cli.github.com/manual/gh_auth_setup-git) makes `git push` use the same token. So the sandbox needs one variable and no login step.

## 2. The orchestration script

It runs on your laptop or in CI, with `sbx` signed in. Each step is a plain `sbx` call, so you can move it into the [TypeScript or Python SDK](/docs/sdk-typescript) later.

```bash
#!/usr/bin/env bash
# Usage: ./issue-to-pr.sh OWNER/REPO ISSUE_NUMBER
#   GH_TOKEN  fine-grained token for this one repo (optional until you push)
#   PUSH=1    push the branch and open the pull request
set -euo pipefail
repo="$1"; issue="$2"; branch="agent/issue-$issue"
t0=$SECONDS; step() { echo "[$((SECONDS - t0))s] $*" >&2; }

# 1. A sandbox with none of your secrets, plus only the token this job needs
args=(--no-env --type small --no-snapshots --ttl 3600 --name "${NAME:-issue-$issue}")
[ -n "${GH_TOKEN:-}" ] && args+=(-e "GH_TOKEN=$GH_TOKEN")
id="$(sbx new "${args[@]}" --json | jq -r 'select(.event == "ready") | .id')"
trap 'sbx delete "$id" --yes >/dev/null && step "deleted $id"' EXIT
step "created $id"

# 2. The issue goes in as a file, never into a shell command line
curl -fsS "https://api.github.com/repos/$repo/issues/$issue" > issue.json
jq -r '"# \(.title)\n\n\(.body)"' issue.json > ISSUE.md
jq -r --arg n "$issue" '"Refs #\($n): \(.title)"' issue.json > COMMIT_MSG
for f in ISSUE.md COMMIT_MSG agent-step.sh; do sbx scp "$f" "$id:/home/user/" >/dev/null; done
sbx exec "$id" --timeout 120 "git clone -q https://github.com/$repo work && git -C work switch -q -c $branch"
step "cloned $repo, on $branch"

# 3. The agent step (a placeholder here; see below)
sbx exec "$id" --cwd work --timeout 600 "bash ../agent-step.sh"
step "agent step finished"

# 4. Tests must pass before anything leaves the sandbox
sbx exec "$id" --cwd work --timeout 600 "go test ./..."
step "tests passed"

# 5. Commit, and keep the patch as the artifact
sbx exec "$id" --cwd work "git add -A && git -c user.name='Issue bot' -c user.email=bot@example.com commit -q -F ../COMMIT_MSG && git format-patch -1 --stdout" > "issue-$issue.patch"
step "committed; patch in issue-$issue.patch"

# 6. Push and open the pull request
if [ "${PUSH:-0}" = 1 ]; then
  sbx exec "$id" --cwd work --timeout 120 "gh auth setup-git && git push -q -u origin $branch && gh pr create --fill --head $branch"
  step "pull request opened"
fi
```

`sbx exec` exits with the remote command's exit code, so `set -e` stops the script on the first failed step, and the `trap` still deletes the sandbox. `gh pr create --fill` takes the title and body from the commit, and `--head` names the branch you just pushed (see the [gh pr create manual](https://cli.github.com/manual/gh_pr_create)).

The placeholder agent step, `agent-step.sh`, writes the change a model would make. For issue #147 (`uuid.Parse` and surrounding spaces) it adds a test that pins today's behavior:

```bash
#!/usr/bin/env bash
# Placeholder for the agent. A real run replaces this file's body with a coding
# agent (for example `claude -p "$(cat ../ISSUE.md)"`). No model runs here.
set -euo pipefail
cat > parse_spaces_test.go <<'GO'
package uuid

import "testing"

// Issue #147: Parse does not trim leading or trailing spaces.
// Pin the current behavior so a future change to it is deliberate.
func TestParseRejectsSurroundingSpaces(t *testing.T) {
	s := "f47ac10b-58cc-0372-8567-0e02b2c3d479"
	for _, in := range []string{" " + s, s + " "} {
		if _, err := Parse(in); err == nil {
			t.Errorf("Parse(%q) returned no error", in)
		}
	}
}
GO
```

## 3. What a run looks like

We ran it without a token and without `PUSH=1`:

```bash
NAME=aeo-issuepr-2 ./issue-to-pr.sh google/uuid 147
```

```text
[3s] created sb_i5e31908kq
[12s] cloned google/uuid, on agent/issue-147
[12s] agent step finished
ok  	github.com/google/uuid	0.754s
[31s] tests passed
[31s] committed; patch in issue-147.patch
[33s] deleted sb_i5e31908kq
```

The artifact is the patch the push would have sent:

```text
From 12ca5fcf06ce80186b294ec1b23df7e420770ba7 Mon Sep 17 00:00:00 2001
From: Issue bot <bot@example.com>
Date: Tue, 27 Oct 2026 02:47:23 +0000
Subject: [PATCH] Refs #147: uuid.Parse Function Does Not Handle
 Leading/Trailing Spaces in UUIDs

---
 parse_spaces_test.go | 14 ++++++++++++++
 1 file changed, 14 insertions(+)
 create mode 100644 parse_spaces_test.go
…
```

With a token and `PUSH=1`, step 6 pushes `agent/issue-147` and prints the PR URL. We did not run that step.

### Why the tests run inside the sandbox first

Our first version of the placeholder also asserted that `"\t" + s + "\n"` fails to parse. `go test` in the sandbox failed:

```text
=== RUN   TestParseRejectsSurroundingSpaces
    parse_spaces_test.go:11: Parse("\tf47ac10b-58cc-0372-8567-0e02b2c3d479\n") returned no error
--- FAIL: TestParseRejectsSurroundingSpaces (0.00s)
FAIL
```

`Parse` treats any 38-character input as the `{…}` form and drops the first character without checking it, which is the behavior issue #147 reports. A wrong change stayed in the sandbox instead of becoming a pull request. Agents make the same kind of wrong guess, so keep the test gate before the push.

## 4. Swap in a real agent

Replace the body of `agent-step.sh` with the agent you use, and pass its key the same way as the GitHub token. For example, with the Claude Code CLI that ships in every sandbox, add `-e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY"` to `args` and run it on the issue file. Or skip the script file and use the integrated agent from the orchestrator; `sbx prompt` streams the agent's events until the turn ends:

```bash
sbx prompt "$id" --provider claude "Read ~/ISSUE.md and fix it in ~/work. Run go test ./... until it passes. Don't commit."
```

We didn't run either: no model key was used for this page. See [Run Claude Code in a cloud VM](/guides/run-claude-code-in-a-cloud-vm) for keys and providers. For agent steps longer than 600 seconds, use `sbx exec --detach` and `--attach` instead of one synchronous call.

## 5. Secrets hygiene

The issue body is input from anyone who can open an issue. Treat the agent that reads it as untrusted, and limit what a bad instruction could reach.

Here is what a `--no-env` sandbox holds when you pass only a token. We used a fake value so nothing real was exposed:

```bash
id="$(sbx new --no-env --type small --no-snapshots -e GH_TOKEN=not-a-real-token --json | jq -r 'select(.event == "ready") | .id')"
sbx exec "$id" "env | cut -d= -f1 | sort | grep -E '^(GH_|GITHUB|ANTHROPIC|OPENAI|SANDBOXER)'"
sbx exec "$id" "gh auth status"
```

```text
GH_TOKEN
SANDBOXER_API_URL
SANDBOXER_ID
SANDBOXER_SUBDOMAIN
SANDBOXER_TOKEN
github.com
  X Failed to log in to github.com using token (GH_TOKEN)
  - Active account: true
  - The token in GH_TOKEN is invalid.
```

The token is the only credential from outside, and `gh` picks it up with no login step (a real token logs in instead of failing). `gh auth setup-git` then set `credential.https://github.com.helper` to `!/usr/bin/gh auth git-credential`, so `git push` uses the same token. The value did not appear in `sbx info` output.

- **`--no-env`.** The sandbox gets none of your environment's variables, secret files, GitHub token or agent logins, and can't act on your account or your other sandboxes ([Environments](/docs/environments#sandboxes-for-other-people-noenv)). Its own `SANDBOXER_TOKEN` is limited to reading that one sandbox and hosting its ports ([Security](/docs/security)).
- **One token, one repo, short life.** If the agent leaks `GH_TOKEN`, the damage is one repo's contents and pull requests until the token expires. Revoke it after a batch of runs.
- **Issue text as a file.** The script copies `ISSUE.md` and `COMMIT_MSG` into the sandbox. It never pastes the title or body into a shell command, where a crafted title could run code on your machine.
- **Nothing kept.** `--no-snapshots` means no disk snapshot holds the token or the clone, and the `trap` deletes the sandbox. Add `--no-monitoring` if you also don't want command output kept.
- **Protect the base branch.** Contents write lets a token push to any branch, including the default one. A branch protection rule that requires a pull request keeps the agent's work on its own branch until someone reviews it.

If you'd rather not create tokens by hand, a [GitHub App](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-an-installation-access-token-for-a-github-app) can mint an installation token per run, limited to chosen repositories and permissions, that expires after one hour. Pass it the same way.

## 6. Time and cost per run

| Step | Time in our run (one run, 2026-10-27) |
| - | - |
| Create the `small` sandbox | 3 s |
| Fetch the issue, copy files, full clone of `google/uuid` | 9 s |
| Agent step (placeholder) | under 1 s |
| `go test ./...` with a cold build cache | 19 s |
| Commit and `format-patch` | under 1 s |
| Delete | 2 s |
| **Total** | **33 s** |

A real agent adds its own minutes on top. At $0.018/hour for `small`, 33 seconds of machine time costs well under a cent. Each issue is one start, and starts are limited per minute, hour and day by plan ([Limits](/docs/limits#starts)); check `sbx limits` before a large batch.

## FAQ

### Can the agent push without me giving it my GitHub login?

Yes. With `--no-env` the sandbox gets no GitHub credentials from your account. It only has the fine-grained `GH_TOKEN` you pass with `-e`, and `gh auth setup-git` makes `git push` use it.

### Does gh work in the sandbox without a token?

No. `gh` is installed, but without `GH_TOKEN` even reading a public issue prints `To get started with GitHub CLI, please run: gh auth login`. That's why the script fetches the issue with `curl` from the orchestrator. Unauthenticated GitHub API calls have a low rate limit, so add an `Authorization` header there for large batches.

### Can I work on a repo I don't own?

Push to your fork instead of `origin`: add the fork as a remote, push the branch there, and run `gh pr create --head <user>:<branch>`. The token then needs Contents write on the fork only.

### Why not use my environment's GitHub connection?

You can: an [environment](/docs/environments#repositories) with repositories and `--github true` clones the repo with a token that can push. That token is the one from your GitHub connection, so use it for your own trusted agents, and use `--no-env` plus a scoped token when the input is an issue from the public.

### How do I run many issues at once?

Run the script once per issue, in parallel. Each run gets its own sandbox, branch and patch file. See [Run coding agents in parallel](/guides/run-coding-agents-in-parallel).

## Related

- [Environments and secrets](/docs/environments)
- [Claude Code without permission prompts](/guides/claude-code-skip-permissions-safely)
- [Run coding agents in parallel](/guides/run-coding-agents-in-parallel)
- [Integrated agents](/docs/agents)
