diff --git a/.changeset/git-sandbox-network.md b/.changeset/git-sandbox-network.md new file mode 100644 index 0000000..dac3504 --- /dev/null +++ b/.changeset/git-sandbox-network.md @@ -0,0 +1,5 @@ +--- +"sql-fs-api": major +--- + +Add a sandbox `git` command backed by just-git, export server `GITHUB_TOKEN` into sandbox GitHub-compatible Git/curl env, and let MCP-created sandboxes request network access for clone/fetch/push. diff --git a/.env.example b/.env.example index 43d0123..2d1b560 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,21 @@ SESSION_IDLE_MS=600000 # evict idle sessions after 10 min MAX_CONCURRENT_PYTHON=5 # cap CPython WASM workers (~80 MB each) MAX_CONCURRENT_JS=5 # cap QuickJS workers (~64 MB each) +# ── Optional: sandbox git/GitHub defaults ───────────────────────────────────── + +# Shared deployment-wide GitHub identity exported into network:true sandboxes +# as GITHUB_TOKEN (for curl GitHub API calls), plus GIT_HTTP_USER=x-access-token +# and GIT_HTTP_PASSWORD= for git HTTPS auth. Readable by network-enabled +# sandbox code; use only with trusted agents. +# GITHUB_TOKEN= + +# Optional commit identity defaults exported into every sandbox. Per-request env +# overrides these values for that exec. +# GIT_AUTHOR_NAME= +# GIT_AUTHOR_EMAIL= +# GIT_COMMITTER_NAME= +# GIT_COMMITTER_EMAIL= + # ── Optional: Redis (required for multi-replica deployments) ────────────────── # REDIS_URL=redis://localhost:6379 diff --git a/CLAUDE.md b/CLAUDE.md index 5a6c693..6128175 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -207,6 +207,8 @@ const TABLE = Object.assign(Object.create(null) as Record, { | `SESSION_IDLE_MS` | No (default: 600000) | Idle timeout before session eviction (ms) | | `MAX_CONCURRENT_PYTHON` | No (default: 5) | Max concurrent Python executions across all sessions. CPython WASM workers cost ~80MB each (EXIT_RUNTIME per invocation); the semaphore caps concurrency to prevent OOM. Excess scripts queue FIFO. | | `MAX_CONCURRENT_JS` | No (default: 5) | Max concurrent JavaScript (`js-exec`/`node`) executions across all sessions. QuickJS executions cap at 64MB each. Excess scripts queue FIFO. Note: just-bash currently serializes `js-exec` internally through a single worker, so this cap is an upper bound that may not be binding today. | +| `GITHUB_TOKEN` | No | Optional shared GitHub token. When set, exported into `network:true` sandbox shell env as `GITHUB_TOKEN` for `curl` GitHub API calls, plus `GIT_HTTP_USER=x-access-token` and `GIT_HTTP_PASSWORD=` for GitHub-compatible `git` HTTPS auth. This is a deployment-wide identity readable by network-enabled sandbox code; use only with trusted agents. Per-request `env` overrides it. | +| `GIT_AUTHOR_NAME`, `GIT_AUTHOR_EMAIL`, `GIT_COMMITTER_NAME`, `GIT_COMMITTER_EMAIL` | No | Optional git identity values to export into every sandbox shell env so `git commit` has defaults. Per-request `env` overrides them. | | `REDIS_URL` | No | Redis connection string. Required for multi-replica deployments. When absent, distributed exec lock and all Redis caches are disabled — only in-process `session.mutex` protects execution. | | `REDIS_EXEC_LOCK_LEASE_MS` | No (default: 60000) | Distributed exec lock lease duration (ms). Lock auto-expires if the holder dies. Must be > `REDIS_EXEC_LOCK_RENEW_MS`. | | `REDIS_EXEC_LOCK_RENEW_MS` | No (default: 20000) | Heartbeat interval for exec lock renewal (ms). Must be strictly less than `REDIS_EXEC_LOCK_LEASE_MS` to guarantee renewal fires before expiry. | diff --git a/package.json b/package.json index 9437c4d..96f8fa7 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "ioredis": "^5.4.0", "jose": "^6.0.0", "just-bash": "^3.0.1", + "just-git": "^1.7.1", "lru-cache": "^11.0.0", "mssql": "^11.0.0", "mysql2": "^3.12.0", diff --git a/plugins/sql-fs/skills/api/ref/bash.md b/plugins/sql-fs/skills/api/ref/bash.md index 33456d3..a8fc374 100644 --- a/plugins/sql-fs/skills/api/ref/bash.md +++ b/plugins/sql-fs/skills/api/ref/bash.md @@ -75,16 +75,28 @@ js-exec script.js node script.js # alias for js-exec ``` - QuickJS WASM — fast startup, TypeScript supported -- No `npm`, no network, no `require('fs')` (use sandbox FS via bash instead) +- No `npm`, no `require('fs')` (use sandbox FS via bash instead) - Server-wide concurrency cap: **5 concurrent js-exec calls** +### Network — `network: true` + +Network access is opt-in at sandbox creation. With `network: true`: + +- `curl` can reach outbound HTTPS endpoints +- `git clone`, `git fetch`, and `git push` can reach HTTPS remotes +- `js-exec` `fetch()` can reach outbound HTTP(S) when `javascript: true` is also set + +With `network: false` (the default), outbound access is blocked. Local git +operations such as `git init`, `git add`, `git commit`, `git status`, and +`git log` still work. + --- ## NOT supported | Command | Why | |---------|-----| -| `curl`, `wget`, `nc`, `ssh` | No network access of any kind | +| `wget`, `nc`, `ssh` | Unsupported network tools/transports | | `apt`, `pip`, `npm`, `brew` | No package managers | | `vi`, `vim`, `nano`, `less`, `more` | No interactive/terminal-control commands | | `&` (background jobs) | No process control | diff --git a/plugins/sql-fs/skills/api/ref/endpoints.md b/plugins/sql-fs/skills/api/ref/endpoints.md index 3377469..1503d65 100644 --- a/plugins/sql-fs/skills/api/ref/endpoints.md +++ b/plugins/sql-fs/skills/api/ref/endpoints.md @@ -129,7 +129,7 @@ All body fields are **optional**: |---|---|---|---| | `python` | boolean | false | Enable CPython WASM — registers `python3` (and `python` alias), stdlib only, isolated per call | | `javascript` | boolean | false | Enable QuickJS WASM (`js-exec`/`node`) | -| `network` | boolean | false | Enable outbound `fetch()` from `js-exec` (see note below) | +| `network` | boolean | false | Enable outbound HTTP for `js-exec` fetch, Bash `curl`, and git clone/fetch/push (see note below) | | `files` | `Record` | — | Seed files (absolute path → plain text) | | `env` | `Record` | — | Default env vars for all exec calls | @@ -147,13 +147,18 @@ Response `201`: **Important:** `python`/`javascript`/`network` must be set at creation. They cannot be changed later. -**`network: true` — outbound fetch() from js-exec** +**`network: true` — outbound HTTP** -When `network: true` is set (requires `javascript: true`), `fetch()` inside -`js-exec` scripts can reach external HTTP endpoints. The js-exec timeout -extends to 60 s automatically. The Bash shell itself remains air-gapped — no -`curl`, `wget`, DNS, or raw socket access — only `fetch()` inside `js-exec` -gains outbound HTTP. Defaults to `false` (secure-by-default, no egress). +When `network: true` is set at sandbox creation, supported commands can reach +external HTTPS endpoints: + +- `fetch()` inside `js-exec` scripts when `javascript: true` is also set +- Bash `curl` +- `git clone`, `git fetch`, and `git push` + +The js-exec timeout extends to 60 s automatically. `wget`, raw sockets, +package managers, compilers, and SSH remain unsupported. Defaults to `false` +(secure-by-default, no egress); local git operations still work without network. ### GET /v1/sandboxes/:id — Get sandbox info diff --git a/plugins/sql-fs/skills/py-sdk/ref/client.md b/plugins/sql-fs/skills/py-sdk/ref/client.md index b1df8bb..0cd082e 100644 --- a/plugins/sql-fs/skills/py-sdk/ref/client.md +++ b/plugins/sql-fs/skills/py-sdk/ref/client.md @@ -117,17 +117,21 @@ sb = fs.sandboxes.create( files={"/home/user/seed.txt": "..."}, # text-only seed (use ingest_files for many/binary) python=False, # enable CPython WASM runtime javascript=False, # enable QuickJS runtime - network=False, # enable outbound fetch() from js-exec (opt-in) + network=False, # enable outbound HTTP/curl/git remote ops (opt-in) ) ``` All keyword args are optional — `fs.sandboxes.create()` is valid and creates an anonymous sandbox. -**`network=True` — enabling outbound fetch()** +**`network=True` — enabling outbound HTTP** -Pass `network=True` together with `javascript=True` to allow `fetch()` calls -inside `js-exec` scripts to reach external HTTP endpoints: +Pass `network=True` at sandbox creation to allow outbound HTTP from supported +commands. It enables: + +- `fetch()` inside `js-exec` scripts when `javascript=True` is also set +- Bash `curl` +- `git clone`, `git fetch`, and `git push` ```python sb = fs.sandboxes.create(javascript=True, network=True) @@ -139,11 +143,11 @@ r = sb.exec("""js-exec -c ' print(r.stdout) # origin: ``` -- **Bash remains air-gapped.** Even with `network=True`, the Bash shell has - no `curl`, `wget`, DNS, or raw socket access. Only `fetch()` inside `js-exec` - gains outbound HTTP. +- **Bash gains HTTP tools.** With `network=True`, `curl` is available and git + remote operations can reach HTTPS remotes. `wget`, raw sockets, package + managers, compilers, and SSH remain unsupported. - **Opt-in, default `False`.** Omitting `network` (or passing `network=False`) - produces a fully isolated sandbox. + blocks outbound access; local git operations still work. - **js-exec timeout extends to 60 s** when network is enabled (documented in the `node` alias help text). diff --git a/plugins/sql-fs/skills/ts-sdk/ref/client.md b/plugins/sql-fs/skills/ts-sdk/ref/client.md index 92c4a01..c01fa2d 100644 --- a/plugins/sql-fs/skills/ts-sdk/ref/client.md +++ b/plugins/sql-fs/skills/ts-sdk/ref/client.md @@ -113,17 +113,21 @@ const sb = await client.sandboxes.create({ files: { "/home/user/seed.txt": "..." }, // text-only seed (use ingestFiles for many/binary) python: false, // enable CPython WASM runtime javascript: false, // enable QuickJS runtime - network: false, // enable outbound fetch() from js-exec (opt-in) + network: false, // enable outbound HTTP/curl/git remote ops (opt-in) }); ``` All options are optional — `client.sandboxes.create()` is valid and creates an anonymous sandbox. -**`network: true` — enabling outbound fetch()** +**`network: true` — enabling outbound HTTP** -Pass `network: true` together with `javascript: true` to allow `fetch()` calls -inside `js-exec` scripts to reach external HTTP endpoints: +Pass `network: true` at sandbox creation to allow outbound HTTP from supported +commands. It enables: + +- `fetch()` inside `js-exec` scripts when `javascript: true` is also set +- Bash `curl` +- `git clone`, `git fetch`, and `git push` ```typescript const sb = await client.sandboxes.create({ javascript: true, network: true }); @@ -135,11 +139,11 @@ const r = await sb.exec(`js-exec -c ' console.log(r.stdout); // origin: ``` -- **Bash remains air-gapped.** Even with `network: true`, the Bash shell has - no `curl`, `wget`, DNS, or raw socket access. Only `fetch()` inside `js-exec` - gains outbound HTTP. +- **Bash gains HTTP tools.** With `network: true`, `curl` is available and git + remote operations can reach HTTPS remotes. `wget`, raw sockets, package + managers, compilers, and SSH remain unsupported. - **Opt-in, default `false`.** Omitting `network` (or passing `network: false`) - produces a fully isolated sandbox. + blocks outbound access; local git operations still work. - **js-exec timeout extends to 60 s** when network is enabled. ### `client.sandboxes.get(sandboxId): Promise` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 363da3b..e835c65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: just-bash: specifier: ^3.0.1 version: 3.0.1 + just-git: + specifier: ^1.7.1 + version: 1.7.1 lru-cache: specifier: ^11.0.0 version: 11.3.5 @@ -1972,6 +1975,9 @@ packages: resolution: {integrity: sha512-YVyzCN08fKarUnwqy7rKOAcX+2MLYLnYInuowmUXn3mqhrtd4ieZNBuzdQG+qYV9DqnIWuv9Whiph0WRIWsBtw==} hasBin: true + just-git@1.7.1: + resolution: {integrity: sha512-XAxD3x+8aSo8Kx62pkcj3Jzmhw2I4mhw1xvqTNbpVcTFUMIZKak5W00hVM7/LywUFLfMj+7Riu8cEcn8JnEHHw==} + jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} @@ -4517,6 +4523,8 @@ snapshots: transitivePeerDependencies: - supports-color + just-git@1.7.1: {} + jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 diff --git a/src/api/mcp/tools.ts b/src/api/mcp/tools.ts index b966122..f3fc6d5 100644 --- a/src/api/mcp/tools.ts +++ b/src/api/mcp/tools.ts @@ -31,11 +31,12 @@ const MAX_EXPORT_CONCURRENCY = positiveIntEnv(process.env.MAX_EXPORT_CONCURRENCY export function registerTools(server: McpServer, sessionManager: SessionManager, owner: string, tenant: string): void { server.tool( "sandbox_create", - "Create an isolated bash sandbox with a virtual filesystem. Optional runtime flags opt in to python3/python (CPython WASM, stdlib only) and js-exec/node (QuickJS WASM) commands. Optional name for human-readable identification.", + "Create an isolated bash sandbox with a virtual filesystem. Optional runtime flags opt in to python3/python (CPython WASM, stdlib only), js-exec/node (QuickJS WASM), and outbound HTTPS for curl plus git clone/fetch/push. Optional name for human-readable identification.", { name: z.string().max(255).optional().describe("Human-readable name for the sandbox"), python: z.boolean().optional(), javascript: z.boolean().optional(), + network: z.boolean().optional().describe("Grant outbound HTTPS (enables curl + git clone/fetch/push)"), }, async (args) => { const id = randomUUID(); @@ -43,7 +44,7 @@ export function registerTools(server: McpServer, sessionManager: SessionManager, const runtimeOptions = { python: args.python ?? false, javascript: args.javascript ?? false, - network: false, + network: args.network ?? false, }; try { const session = await sessionManager.getOrCreate(tenant, id, runtimeOptions, owner); @@ -59,7 +60,13 @@ export function registerTools(server: McpServer, sessionManager: SessionManager, content: [ { type: "text" as const, - text: JSON.stringify({ id, name, python: runtimeOptions.python, javascript: runtimeOptions.javascript }), + text: JSON.stringify({ + id, + name, + python: runtimeOptions.python, + javascript: runtimeOptions.javascript, + network: runtimeOptions.network, + }), }, ], }; @@ -97,6 +104,7 @@ export function registerTools(server: McpServer, sessionManager: SessionManager, createdAt: s.createdAt.toISOString(), python: s.python, javascript: s.javascript, + network: s.network, })), }), }, @@ -167,16 +175,20 @@ export function registerTools(server: McpServer, sessionManager: SessionManager, "environment variables, conditionals (if/else), loops (for/while), functions, arithmetic,", "base64, md5sum, sha256sum, tar, gzip, jq, yq, xan, sqlite3.", "", - "NOT supported: curl/wget (no network), apt/pip/npm (no package managers),", + "Network: sandboxes are air-gapped by default. If created with network:true,", + "curl and outbound git clone/fetch/push over HTTPS are available. wget is not supported.", + "", + "NOT supported: apt/pip/npm (no package managers),", "vi/vim/nano (no interactive), background jobs (&), kill/ps/top (no process control),", "/proc /sys /dev (no special filesystems), ln -s (symlinks off by default),", - "gcc/make/rustc (no compilers), network access of any kind.", + "gcc/make/rustc (no compilers).", "", "Optional runtimes (only if sandbox was created with python:true or javascript:true):", "- python3 / python — CPython WASM, stdlib only (no pip, no network, no os.system).", " Concurrent python3 executions across the server are capped to prevent OOM; excess", " scripts queue until a slot frees.", - "- js-exec / node — QuickJS WASM. TypeScript supported. No npm, no network.", + "- js-exec / node — QuickJS WASM. TypeScript supported. No npm. fetch is available", + " only when the sandbox was created with network:true.", ].join("\n"); server.tool( diff --git a/src/api/openapi-spec.ts b/src/api/openapi-spec.ts index 725ca41..80568ac 100644 --- a/src/api/openapi-spec.ts +++ b/src/api/openapi-spec.ts @@ -22,8 +22,9 @@ const sandboxSchema = { createdAt: { type: "string", format: "date-time" }, python: { type: "boolean", example: false }, javascript: { type: "boolean", example: false }, + network: { type: "boolean", example: false }, }, - required: ["id", "name", "owner", "createdAt", "python", "javascript"], + required: ["id", "name", "owner", "createdAt", "python", "javascript", "network"], } as const; const sandboxInfoSchema = { @@ -295,7 +296,8 @@ export const openapiSpec = { post: { tags: ["Sandboxes"], summary: "Create sandbox", - description: "Create a new isolated bash sandbox. Optionally seed with files and enable Python/JS runtimes.", + description: + "Create a new isolated bash sandbox. Optionally seed with files and enable Python/JS runtimes or outbound HTTPS for curl and git clone/fetch/push.", requestBody: { required: false, content: { @@ -320,6 +322,11 @@ export const openapiSpec = { }, python: { type: "boolean", default: false, description: "Enable CPython WASM runtime" }, javascript: { type: "boolean", default: false, description: "Enable QuickJS runtime" }, + network: { + type: "boolean", + default: false, + description: "Grant outbound HTTPS for curl and git clone/fetch/push", + }, }, }, }, diff --git a/src/api/session-manager.ts b/src/api/session-manager.ts index e9edfb8..e517282 100644 --- a/src/api/session-manager.ts +++ b/src/api/session-manager.ts @@ -13,8 +13,9 @@ */ import type { Redis } from "ioredis"; -import { Bash } from "just-bash"; +import { Bash, defineCommand } from "just-bash"; import type { BashExecResult, DefenseInDepthConfig, ExecOptions, IFileSystem, SecurityViolation } from "just-bash"; +import { createGit } from "just-git"; import { createEnoent } from "../sql-fs/errors.js"; import { createPostgresSandboxFs, destroyPostgresSandbox } from "../sql-fs/index.js"; import type { RedisBlobCache } from "../sql-fs/redis-blob-cache.js"; @@ -146,16 +147,46 @@ export interface RuntimeOptions { readonly python: boolean; readonly javascript: boolean; /** - * When true, the `js-exec` runtime is given a permissive `NetworkConfig` that - * allows all outbound HTTPS. Only `fetch()` inside `js-exec` benefits from this - * flag — the Bash layer itself remains air-gapped (no `curl`, `wget`, DNS, or - * raw sockets). Defaults to false (secure-by-default). + * When true, network-capable runtimes may use outbound HTTPS: `js-exec` + * gets unrestricted `fetch`, just-bash registers `curl`, and git remote + * transport can clone/fetch/push. Defaults to false (secure-by-default). */ readonly network: boolean; } const DEFAULT_RUNTIME_OPTIONS: RuntimeOptions = { python: false, javascript: false, network: false }; +export function buildSandboxBaseEnv(env: NodeJS.ProcessEnv = process.env): Record { + const out: Record = Object.create(null); + const token = env.GITHUB_TOKEN; + if (token) { + out.GITHUB_TOKEN = token; + out.GIT_HTTP_USER = "x-access-token"; + out.GIT_HTTP_PASSWORD = token; + } + for (const key of ["GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL"] as const) { + const value = env[key]; + if (value) out[key] = value; + } + return out; +} + +const SANDBOX_NETWORK_CREDENTIAL_KEYS = new Set([ + "GITHUB_TOKEN", + "GIT_HTTP_BEARER_TOKEN", + "GIT_HTTP_USER", + "GIT_HTTP_PASSWORD", +]); + +function buildRuntimeSandboxEnv(baseEnv: Record, network: boolean): Record | undefined { + const out: Record = Object.create(null); + for (const [key, value] of Object.entries(baseEnv)) { + if (!network && SANDBOX_NETWORK_CREDENTIAL_KEYS.has(key)) continue; + out[key] = value; + } + return Object.keys(out).length > 0 ? out : undefined; +} + /** * Syntactically valid sandbox id: UUIDs and dashed/underscored slugs, 1–128 * chars. Audit M2: an unvalidated sandbox id flows straight into Redis lock / @@ -377,6 +408,7 @@ export class SessionManager { private readonly jsSem: Semaphore; private readonly defenseInDepth: boolean; private readonly defenseAuditMode: boolean; + private readonly sandboxBaseEnv: Record; private shuttingDown = false; constructor({ @@ -439,6 +471,7 @@ export class SessionManager { }; this.defenseInDepth = defenseInDepth ?? process.env.JUST_BASH_DEFENSE_IN_DEPTH === "true"; this.defenseAuditMode = defenseAuditMode ?? process.env.JUST_BASH_DEFENSE_AUDIT_MODE !== "false"; + this.sandboxBaseEnv = buildSandboxBaseEnv(); } private sessionKey(tenantId: string, sandboxId: string): string { @@ -547,6 +580,14 @@ export class SessionManager { // NOTE: the `py-exec` warm-host-Python custom command is deliberately // not registered (audit C1 — host sandbox escape). Python sandboxes // run via just-bash's WASM `python3` (`python: true`), which is isolated. + const git = createGit({ + network: resolvedRuntime.network ? {} : false, + }); + const gitCommand = defineCommand("git", (args, ctx) => + // just-git shadows just-bash's CommandContext type, but only reads + // the structurally-compatible fs/cwd/env/stdin/exec/signal fields. + git.execute(args, ctx as Parameters[1]), + ); const customCommands = [ // Override just-bash's built-in nodeStubCommand with a smarter // version that translates `node -e CODE` → `js-exec -c CODE` and @@ -554,6 +595,7 @@ export class SessionManager { // Only registered when the javascript runtime is enabled so that // non-JS sandboxes keep the default "command not found" behaviour. ...(resolvedRuntime.javascript ? [nodeCommand] : []), + gitCommand, ]; const bash = new Bash({ @@ -561,9 +603,10 @@ export class SessionManager { python: resolvedRuntime.python || undefined, javascript: resolvedRuntime.javascript || undefined, // When network is enabled, grant js-exec unrestricted outbound HTTPS. - // Bash itself remains air-gapped — no curl/wget/DNS — because - // just-bash only gates fetch() through this NetworkConfig path. + // just-bash also registers curl through secureFetch; git remote + // transport is gated separately above and uses globalThis.fetch. network: resolvedRuntime.network ? { dangerouslyAllowFullInternetAccess: true } : undefined, + env: buildRuntimeSandboxEnv(this.sandboxBaseEnv, resolvedRuntime.network), defenseInDepth: defenseInDepthConfig, customCommands: customCommands.length > 0 ? customCommands : undefined, }); diff --git a/src/api/tests/integration/git-network.integration.test.ts b/src/api/tests/integration/git-network.integration.test.ts new file mode 100644 index 0000000..4f8e8b6 --- /dev/null +++ b/src/api/tests/integration/git-network.integration.test.ts @@ -0,0 +1,101 @@ +import { Bash, InMemoryFs, defineCommand } from "just-bash"; +import { createGit } from "just-git"; +import { readCommit, resolveRef } from "just-git/repo"; +import { type Auth, createServer } from "just-git/server"; +import { describe, expect, it } from "vitest"; + +const TOKEN = "test-token"; +const BASE_URL = "https://git.test"; + +function makeGitBash(network: ReturnType>["asNetwork"]>): Bash { + const fs = new InMemoryFs(); + const git = createGit({ network }); + const gitCommand = defineCommand("git", (args, ctx) => git.execute(args, ctx as Parameters[1])); + return new Bash({ fs, customCommands: [gitCommand] }); +} + +describe("git network transport", () => { + it("clones, commits with exec env identity, rejects missing tokens, and pushes with bearer auth", async () => { + const seenAuthorizations: Array = []; + const server = createServer({ + onError: false, + hooks: { + preReceive: ({ auth }) => { + const authorization = auth.request?.headers.get("authorization") ?? null; + seenAuthorizations.push(authorization); + if (authorization !== `Bearer ${TOKEN}`) { + return { reject: true, message: "missing or invalid bearer token" }; + } + }, + }, + }); + await server.createRepo("project"); + const initial = await server.commit("project", { + files: { "README.md": "# Project\n" }, + message: "initial remote commit", + author: { name: "Remote Bot", email: "remote@example.com" }, + branch: "main", + }); + + const bash = makeGitBash(server.asNetwork(BASE_URL)); + const clone = await bash.exec(`git clone ${BASE_URL}/project /repo`); + + expect(clone.exitCode, clone.stderr).toBe(0); + await expect(bash.exec("cat /repo/README.md")).resolves.toMatchObject({ + exitCode: 0, + stdout: "# Project\n", + }); + + const commitEnv = { + GIT_AUTHOR_NAME: "Agent Author", + GIT_AUTHOR_EMAIL: "author@example.com", + GIT_COMMITTER_NAME: "Agent Committer", + GIT_COMMITTER_EMAIL: "committer@example.com", + }; + const commit = await bash.exec( + [ + "cd /repo", + "printf '\\nupdated\\n' >> README.md", + "git add README.md", + "git commit -m 'agent update'", + "git log -1 --pretty=fuller", + ].join(" && "), + { env: commitEnv, cwd: "/repo" }, + ); + + expect(commit.exitCode, commit.stderr).toBe(0); + expect(commit.stdout).toContain("Author: Agent Author "); + expect(commit.stdout).toContain("Commit: Agent Committer "); + + const localHead = await bash.exec("git rev-parse HEAD", { cwd: "/repo" }); + expect(localHead.exitCode, localHead.stderr).toBe(0); + const localHeadHash = localHead.stdout.trim(); + expect(localHeadHash).not.toBe(initial.hash); + + const rejectedPush = await bash.exec("git push origin main", { cwd: "/repo" }); + expect(rejectedPush.exitCode).not.toBe(0); + expect(rejectedPush.stderr).toMatch(/missing or invalid bearer token|failed to push/i); + + const acceptedPush = await bash.exec("git push origin main", { + cwd: "/repo", + env: { GIT_HTTP_BEARER_TOKEN: TOKEN }, + }); + expect(acceptedPush.exitCode, acceptedPush.stderr).toBe(0); + expect(seenAuthorizations).toContain(null); + expect(seenAuthorizations).toContain(`Bearer ${TOKEN}`); + + const remoteRepo = await server.requireRepo("project"); + const remoteHead = await resolveRef(remoteRepo, "refs/heads/main"); + expect(remoteHead).toBe(localHeadHash); + + const remoteCommit = await readCommit(remoteRepo, localHeadHash); + expect(remoteCommit.author).toMatchObject({ + name: "Agent Author", + email: "author@example.com", + }); + expect(remoteCommit.committer).toMatchObject({ + name: "Agent Committer", + email: "committer@example.com", + }); + }); +}); diff --git a/src/api/tests/unit/git-command.test.ts b/src/api/tests/unit/git-command.test.ts new file mode 100644 index 0000000..ba08993 --- /dev/null +++ b/src/api/tests/unit/git-command.test.ts @@ -0,0 +1,132 @@ +import { InMemoryFs } from "just-bash"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SessionManager, buildSandboxBaseEnv } from "../../session-manager.js"; + +const T = "default"; + +const GIT_IDENTITY = "GIT_AUTHOR_NAME=a GIT_AUTHOR_EMAIL=a@x.com GIT_COMMITTER_NAME=a GIT_COMMITTER_EMAIL=a@x.com"; + +function makeSessionManager(): SessionManager { + return new SessionManager({ + createFs: async () => new InMemoryFs(), + defenseInDepth: false, + }); +} + +describe("buildSandboxBaseEnv", () => { + it("exports GitHub token auth env and optional git identity only when set", () => { + expect(buildSandboxBaseEnv({})).toEqual({}); + + expect( + buildSandboxBaseEnv({ + GITHUB_TOKEN: "server-token", + GIT_AUTHOR_NAME: "Agent", + GIT_AUTHOR_EMAIL: "agent@example.com", + GIT_COMMITTER_NAME: "", + }), + ).toEqual({ + GITHUB_TOKEN: "server-token", + GIT_HTTP_USER: "x-access-token", + GIT_HTTP_PASSWORD: "server-token", + GIT_AUTHOR_NAME: "Agent", + GIT_AUTHOR_EMAIL: "agent@example.com", + }); + }); +}); + +describe("SessionManager git command", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("registers git for local init/add/commit/log without network", async () => { + const sm = makeSessionManager(); + const session = await sm.getOrCreate(T, "git-local-cycle", { python: false, javascript: false, network: false }); + + const result = await session.bash.exec( + ["git init", "echo hi > a.txt", "git add .", `${GIT_IDENTITY} git commit -m init`, "git log --oneline"].join( + " && ", + ), + ); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("init"); + }); + + it("blocks remote git operations cleanly when network is disabled", async () => { + const sm = makeSessionManager(); + const session = await sm.getOrCreate(T, "git-network-blocked", { + python: false, + javascript: false, + network: false, + }); + + const result = await session.bash.exec("git clone https://github.com/x/y"); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toMatch(/network|disabled|blocked|not allowed/i); + }); + + it("does not inject server GitHub token credentials when network is disabled", async () => { + vi.stubEnv("GITHUB_TOKEN", "server-token"); + const sm = makeSessionManager(); + const session = await sm.getOrCreate(T, "git-token-env", { python: false, javascript: false, network: false }); + + const result = await session.bash.exec("env"); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).not.toMatch(/^GITHUB_TOKEN=/m); + expect(result.stdout).not.toMatch(/^GIT_HTTP_USER=/m); + expect(result.stdout).not.toMatch(/^GIT_HTTP_PASSWORD=/m); + }); + + it("injects server GitHub token env for network-enabled sandboxes and lets per-request env override it", async () => { + vi.stubEnv("GITHUB_TOKEN", "server-token"); + const sm = makeSessionManager(); + const session = await sm.getOrCreate(T, "git-token-network-env", { + python: false, + javascript: false, + network: true, + }); + + await expect( + session.bash.exec('printf \'%s:%s:%s\' "$GITHUB_TOKEN" "$GIT_HTTP_USER" "$GIT_HTTP_PASSWORD"'), + ).resolves.toMatchObject({ + exitCode: 0, + stdout: "server-token:x-access-token:server-token", + }); + + await expect( + session.bash.exec('printf \'%s:%s:%s\' "$GITHUB_TOKEN" "$GIT_HTTP_USER" "$GIT_HTTP_PASSWORD"', { + env: { GITHUB_TOKEN: "override", GIT_HTTP_USER: "override-user", GIT_HTTP_PASSWORD: "override-password" }, + }), + ).resolves.toMatchObject({ + exitCode: 0, + stdout: "override:override-user:override-password", + }); + + await expect( + session.bash.exec('printf \'%s:%s:%s\' "$GITHUB_TOKEN" "$GIT_HTTP_USER" "$GIT_HTTP_PASSWORD"'), + ).resolves.toMatchObject({ + exitCode: 0, + stdout: "server-token:x-access-token:server-token", + }); + }); + + it("omits GitHub token env when the server env is unset", async () => { + vi.stubEnv("GITHUB_TOKEN", undefined); + const sm = makeSessionManager(); + const session = await sm.getOrCreate(T, "git-token-env-unset", { + python: false, + javascript: false, + network: false, + }); + + const result = await session.bash.exec("env"); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).not.toMatch(/^GITHUB_TOKEN=/m); + expect(result.stdout).not.toMatch(/^GIT_HTTP_USER=/m); + expect(result.stdout).not.toMatch(/^GIT_HTTP_PASSWORD=/m); + }); +}); diff --git a/src/api/tests/unit/mcp.test.ts b/src/api/tests/unit/mcp.test.ts index 1971c01..dbf1821 100644 --- a/src/api/tests/unit/mcp.test.ts +++ b/src/api/tests/unit/mcp.test.ts @@ -138,6 +138,57 @@ describe("MCP tool — sandbox_create", () => { await client.close(); }); + it("sandbox_create accepts network:true and returns/persists the runtime flag", async () => { + const sessions = new Map(); + let createdRuntime: { python?: boolean; javascript?: boolean; network?: boolean } | undefined; + let persistedMeta: { python?: boolean; javascript?: boolean; network?: boolean } | undefined; + + const mockSessionManager = { + getOrCreate: async ( + _tenantId: string, + id: string, + runtime?: { python?: boolean; javascript?: boolean; network?: boolean }, + owner = "", + ): Promise => { + createdRuntime = runtime; + const session = { owner } as unknown as Session; + sessions.set(id, session); + return session; + }, + getSession: (_tenantId: string, id: string): Session | undefined => sessions.get(id), + persistSandboxMeta: async ( + _tenantId: string, + _id: string, + meta: { python?: boolean; javascript?: boolean; network?: boolean }, + ) => { + persistedMeta = meta; + }, + }; + + const server = createMcpServer(); + registerTools(server, mockSessionManager as never, "test-owner", "default"); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.connect(serverTransport); + await client.connect(clientTransport); + + const result = await client.callTool({ + name: "sandbox_create", + arguments: { javascript: true, network: true }, + }); + + const parsed = parseToolJson<{ id: string; javascript: boolean; network: boolean }>(result); + expect(typeof parsed.id).toBe("string"); + expect(parsed.javascript).toBe(true); + expect(parsed.network).toBe(true); + expect(createdRuntime).toEqual({ python: false, javascript: true, network: true }); + expect(persistedMeta).toMatchObject({ python: false, javascript: true, network: true }); + + await client.close(); + }); + it("sandbox_create sets session.owner to the caller", async () => { const sessionManager = new SessionManager({ createFs: async () => new InMemoryFs(), diff --git a/thoughts/shared/plans/2026-06-20_just-git-integration.md b/thoughts/shared/plans/2026-06-20_just-git-integration.md new file mode 100644 index 0000000..10eec28 --- /dev/null +++ b/thoughts/shared/plans/2026-06-20_just-git-integration.md @@ -0,0 +1,311 @@ +--- +date: 2026-06-20T16:40:53+09:30 +researcher: quangnguyentechno@gmail.com +git_commit: 87a08d67aefc8cce56cf6b34b80509ed2b4ad4b5 +branch: main +repository: virtualFS +task: "Integrate just-git as a sandbox git custom command" +tags: [implementation-plan, session-manager, custom-commands, just-git, mcp, network] +status: draft +last_updated: 2026-06-20 +last_updated_by: quangnguyentechno@gmail.com +--- + +# just-git Integration Implementation Plan + +## Overview + +Give every sandbox a real `git` command by registering [just-git](https://github.com/blindmansion/just-git)'s `createGit()` as a just-bash custom command. Local git ops (`init/add/commit/diff/log/status/branch/checkout`) work in every sandbox; network ops (`clone/fetch/push`) are gated by the existing `network` runtime flag. A single deployment-wide `GITHUB_TOKEN` (from the server `.env`) is **exported into every sandbox's shell environment** so agents can `git push` and `curl` the GitHub API (create issues/PRs) without supplying credentials per call; a per-request `env` value still overrides it. Close the MCP `sandbox_create` gap that hardcodes `network:false`, and correct stale "no network / no curl" comments. + +> **Security model (decided):** one shared GitHub identity for all sandboxes, token readable by sandbox code, full outbound preserved. This is only safe for **trusted agents on a single-tenant deployment**. The hardened alternative (egress header-injection so the token never enters the sandbox; per-tenant scoping) was explicitly deferred — see *What We're NOT Doing* and *Security / Operational Assumptions*. + +## Current State Analysis + +- **The only integration point** is the `customCommands` array passed to `new Bash({...})` in `SessionManager.getOrCreate` — `src/api/session-manager.ts:549-568`. Today it conditionally registers `nodeCommand` when `resolvedRuntime.javascript` is true. +- **`network` is already a first-class create option in the HTTP API** — `src/api/routes/sandboxes.ts:22` (`network: z.boolean().optional()`), persisted to sandbox meta (`:109`,`:130`), returned (`:134`), and rehydrated on reconnect (`session-manager.ts:1075`,`:1123`). When true, `session-manager.ts:565` passes `network: { dangerouslyAllowFullInternetAccess: true }` to just-bash. +- **`curl` already works when `network:true`.** just-bash registers network commands (`curl`) whenever `options.fetch || options.network` is set (`just-bash Bash.ts:466`; `NetworkCommandName = "curl"`, registry.ts:101). The comment at `session-manager.ts:562` ("Bash itself remains air-gapped — no curl/wget/DNS") is **stale** and predates this just-bash version. +- **MCP `sandbox_create` cannot enable network** — it hardcodes `network: false` and omits the option entirely (`src/api/mcp/tools.ts:38-56`). Tool descriptions also still claim "no network" / "no curl/wget" (`tools.ts:170-179`). +- **No `git` command exists in the sandbox today.** +- **No base env is injected today.** The `new Bash({...})` call (`session-manager.ts:558`) passes no `env`, so sandboxes start with only just-bash's defaults. There is no server-configured `GITHUB_TOKEN` reaching sandboxes; the only env path is per-request `body.env`. + +### Key Discoveries + +- **Registration shape (verified):** `createGit(opts)` returns a `Git` instance with `readonly name = "git"` and an `execute(args, ctx)` method (`just-git src/git.ts:247,370,489`). Wrapping it with just-bash's `defineCommand("git", (args, ctx) => git.execute(args, ctx))` is the clean path because **`defineCommand` auto-sets `trusted: true`** (`just-bash custom-commands.ts:48` → `{ name, trusted: true, execute }`). +- **Defense-in-depth is solved by construction.** just-bash's `Command.trusted` flag runs the command inside `DefenseInDepthBox.runTrustedAsync()` (`just-bash types.ts:238-247`: "Use for trusted host-extension commands that need direct Node.js globals"). git does `fetch` + crypto (sha1) inside the `bash.exec` patched scope; `trusted: true` exits that scope for git's whole execution — the same reason the existing `nodeCommand` is trusted. No manual `runTrustedDbAsync`-style wrapping of git's fetch is required. +- **Network semantics (verified `just-git src/lib/transport/remote.ts:88-89`):** `validateNetworkAccess` returns "allowed" when `policy.allowed` is absent. So `createGit({ network: {} })` → full outbound via `globalThis.fetch`; `createGit({ network: false })` → transport blocked, local ops unaffected. One gate: `network: resolvedRuntime.network ? {} : false`. +- **git's egress path is `globalThis.fetch`**, independent of just-bash's `secureFetch`/`curl` path. For full-outbound (the chosen posture) both end up unrestricted and consistent with the existing `dangerouslyAllowFullInternetAccess` js-exec posture. +- **Identity/credentials ride per-request `env` — no plumbing:** just-git reads `GIT_AUTHOR_NAME/EMAIL`, `GIT_COMMITTER_NAME/EMAIL` (`just-git src/lib/identity.ts:7-11,64-65`) and `GIT_HTTP_BEARER_TOKEN` / `GIT_HTTP_USER`+`GIT_HTTP_PASSWORD` (`just-git src/lib/transport/remote.ts:110-115`), plus URL-embedded `https://token@host/…`, all per-command from `ctx.env`. The exec route already forwards `body.env` (`src/api/routes/exec.ts:32,170,198`) into `bash.exec(script, { env, … })` via `execWithRuntimeThrottle` (`session-manager.ts:1499,1512`). +- **Supply chain (verified):** just-git `1.7.1` declares **no** `dependencies`/`peerDependencies`/`optionalDependencies`; only `dist/` is published. The `pg`/`ssh2`/`better-sqlite3`/`bun:sqlite`/`cloudflare:workers` imports live exclusively in `src/server/` and `src/proxy/` (separate `just-git/server`, `just-git/proxy` entry points we never import). The client path (`createGit` from `"just-git"`) reaches only Node builtins (`node:crypto`, `node:zlib`) with WebCrypto/`CompressionStream` fallbacks — no WASM, satisfying the inherited just-bash constraint. +- **Version compatibility:** just-git `1.7.1` targets `just-bash ^3.0.1`; virtualFS pins `just-bash ^3.0.1` (`3.0.1` installed). +- **Base-env injection + per-request override (verified):** `new Bash({ env })` seeds the session shell env (`just-bash Bash.ts:116,332`, exported at `:431`); `bash.exec(script, { env })` *merges* over that env per call (`Bash.ts:249` — "merged with the current environment and restored after execution"). So a server-injected `GITHUB_TOKEN` is the default, and a per-request `body.env.GITHUB_TOKEN` overrides it for that exec. just-bash stores env in a `Map` (prototype-pollution-safe, `Bash.ts:321`). +- **git auth precedence (verified `just-git remote.ts:121-133`):** credential provider > env vars > cache. We therefore use the **env-var path** (`GIT_HTTP_BEARER_TOKEN`), *not* a static `createGit({ credentials })` provider — because a provider would outrank and silently ignore any per-request token, defeating override. +- **Hardened alternative (deferred):** just-bash supports egress header-injection — `AllowedUrl.transform` ("Transforms are applied at the fetch boundary so secrets never enter the sandbox", `just-bash network/types.ts:38`) — which would keep the token invisible to sandbox code. It requires an allowlist (`allowedUrlPrefixes`) and is mutually exclusive with `dangerouslyAllowFullInternetAccess`, so it conflicts with the chosen full-outbound posture. Recorded for a future hardening pass. + +## Desired End State + +- A sandbox can run `git init && git add . && git commit -m "x" && git log` with no `network` flag. +- With `network:true`, a sandbox can `git clone https://github.com/org/repo`, edit, `git commit`, and `git push` — using the server-configured `GITHUB_TOKEN` by default, with optional per-request override. +- With `network:true` and `GITHUB_TOKEN` set, `curl -H "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/...` (e.g. `POST /repos/{o}/{r}/issues`, `POST /repos/{o}/{r}/pulls`) succeeds from the shell with no per-request credentials. +- When `GITHUB_TOKEN` is unset on the server, sandboxes behave exactly as before (no token in env); when `network:false`, no outbound at all. +- `curl` and `js-exec` `fetch` continue to work with `network:true` (unchanged). +- MCP-created sandboxes can opt into `network:true`. +- Stale "no network / no curl" comments/descriptions are corrected. +- `pnpm typecheck && pnpm lint:fix && pnpm test:unit` pass; a changeset is committed. + +## What We're NOT Doing + +- **No new `git` runtime flag.** git is always registered (decision: always-available). `RuntimeOptions`, the create schema's runtime flags, and meta persistence are NOT extended with a `git` field. +- **No host-allowlist / egress restriction.** Network posture is full outbound (matches existing js-exec). A `GIT_ALLOWED_HOSTS`-style allowlist (`NetworkPolicy.allowed`) is explicitly deferred. +- **No `curl` work.** It already exists when `network:true`. +- **No use of `just-git/server` or `just-git/proxy`** in production code (only optionally in tests, Phase 3). +- **No changes to just-bash or just-git source** — consumed as npm dependencies. +- **No per-tenant / per-owner token scoping.** A single deployment-wide `GITHUB_TOKEN` is shared by all sandboxes (decision). Multi-tenant credential isolation is deferred. +- **No hidden / egress-injected credentials.** We do NOT use just-bash `transform` header-injection or a `createGit({ credentials })` provider — the token is intentionally exported into the sandbox env (decision). Hardening deferred. +- **No secret persistence in the DB.** The token comes from server `process.env` only; it is never written to sandbox meta or any table. (It *is* visible inside the sandbox env by design — see assumptions.) + +## Implementation Approach + +Hybrid TDD: Phase 1 (command behavior) is test-first — assert git is registered and a local `init→add→commit→log` cycle works through `bash.exec`, then wire the registration. Phases 2–3 (MCP option, doc fixes, network integration test, changeset) are traditional wiring + verification. + +--- + +## Phase 1: Add dependency + register git custom command + +### Phase 1: Overview +Add `just-git`, construct a `Git` instance per session, register it (always) in `customCommands` with network gated on `resolvedRuntime.network`. + +### Phase 1: Changes Required + +#### 1. Dependency +**File**: `package.json` +**Changes**: `pnpm add just-git` (expect `just-git@^1.7.1`, zero transitive deps). Commit the updated `pnpm-lock.yaml`. + +#### 2. Register the git command +**File**: `src/api/session-manager.ts` (imports near `:16`, customCommands at `:549-556`) +**Changes**: import `createGit`, build a trusted `git` command, push it unconditionally. + +```typescript +// top imports +import { Bash, defineCommand } from "just-bash"; +import { createGit } from "just-git"; + +// inside getOrCreate's creationPromise, replacing the customCommands block (~:549) +const git = createGit({ + // {} → no `allowed` list → full outbound via globalThis.fetch (remote.ts:88). + // false → clone/fetch/push blocked; local git (init/add/commit/log) still works. + network: resolvedRuntime.network ? {} : false, +}); +// defineCommand sets trusted:true → git runs inside DefenseInDepthBox.runTrustedAsync, +// so its fetch + crypto are not flagged when JUST_BASH_DEFENSE_IN_DEPTH is on. +const gitCommand = defineCommand("git", (args, ctx) => git.execute(args, ctx)); + +const customCommands = [ + ...(resolvedRuntime.javascript ? [nodeCommand] : []), + gitCommand, +]; +``` + +**Type-variance note:** just-git's `Git.execute` uses its own structurally-compatible *shadow* `CommandContext`/`ExecResult` (`just-git git.ts:42-50`), not exported. Write the call as above first. If `pnpm typecheck` flags a mismatch at the boundary (most likely `ctx.fs`: just-bash `IFileSystem` vs just-git `FileSystem`, or `ctx.stdin`), bridge with a single localized, commented cast — `git.execute(args, ctx as Parameters[1])` — never `any` (per CLAUDE.md). The cast is runtime-safe: just-bash supplies `fs/cwd/env/stdin/exec/signal` that just-git reads. + +#### 3. Inject the server `GITHUB_TOKEN` into the sandbox base env +**File**: `src/api/session-manager.ts` (a new small helper + the `new Bash({...})` call at `:558`) +**Changes**: build a base-env record from server config (read once, e.g. a module-level const or `SessionManager` field), and pass it as `env` to `new Bash`. Only include keys that are actually set, so behavior is unchanged when `GITHUB_TOKEN` is absent. `GITHUB_TOKEN` is aliased to `GIT_HTTP_BEARER_TOKEN` so just-git's env auth path (`remote.ts:110`) authenticates `git push` without URL-embedding. + +```typescript +// Exported builder (unit-testable — pass a fake env; defaults to process.env). +export function buildSandboxBaseEnv( + env: NodeJS.ProcessEnv = process.env, +): Record { + const out: Record = Object.create(null); + const token = env.GITHUB_TOKEN; + if (token) { + out.GITHUB_TOKEN = token; // for `curl -H "Authorization: Bearer $GITHUB_TOKEN"` + out.GIT_HTTP_BEARER_TOKEN = token; // for just-git push/clone over HTTP (remote.ts:110) + } + // Optional committer-identity passthrough (so `git commit` works out of the box); + // only injected when the operator sets them on the server env. + for (const k of ["GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL"] as const) { + const v = env[k]; + if (v) out[k] = v; + } + return out; +} + +// Read once at SessionManager construction (or module load) so it's stable per process: +const sandboxBaseEnv = buildSandboxBaseEnv(); + +// in new Bash({...}) — add: +env: Object.keys(sandboxBaseEnv).length > 0 ? { ...sandboxBaseEnv } : undefined, +``` + +Notes: +- **Per-request override is automatic** — `bash.exec({ env })` merges over this base (`Bash.ts:249`), so a caller passing `GITHUB_TOKEN`/`GIT_HTTP_BEARER_TOKEN` in `body.env` wins for that exec. +- **Does not require `network:true`** to be *present* in env, but is only *useful* when network is on (token is inert without outbound). Injecting it unconditionally is fine and keeps the env stable across runtime flags. +- **No new dependency on git** — this base-env path also benefits `curl`/`js-exec`; it's logically independent but shares the same `new Bash` edit. +- Pass a shallow copy (`{ ...SANDBOX_BASE_ENV }`) so just-bash can't mutate the shared const. + +### Phase 1: Success Criteria + +#### Phase 1: Automated Verification +- [ ] `pnpm typecheck` passes. +- [ ] `pnpm lint:fix` clean. +- [ ] New unit test passes: `pnpm test -- src/api/tests/unit/git-command.test.ts`. +- [ ] `pnpm test:unit` (full unit suite) passes. + +#### Phase 1: Manual Verification +- [ ] In a `network:false` sandbox: `git init && echo hi > a.txt && git add . && GIT_AUTHOR_NAME=a GIT_AUTHOR_EMAIL=a@x.com GIT_COMMITTER_NAME=a GIT_COMMITTER_EMAIL=a@x.com git commit -m init && git log --oneline` succeeds. +- [ ] In a `network:false` sandbox: `git clone https://github.com/x/y` fails cleanly (no crash, non-zero exit, sanitized message) — local ops unaffected. +- [ ] With server `GITHUB_TOKEN` set: `echo $GITHUB_TOKEN` in a new sandbox prints the token; with it unset, prints empty (no `GITHUB_TOKEN`/`GIT_HTTP_BEARER_TOKEN` keys in env). +- [ ] A per-request `env: { GITHUB_TOKEN: "override" }` shadows the server default for that exec only. + +### Phase 1: Discoveries and Notable Information +[Filled by the implementing agent during Phase 1 execution.] + +--- + +## Phase 2: Expose `network` in MCP + correct stale docs + +### Phase 2: Overview +Let MCP-created sandboxes opt into network, and fix comments/descriptions that wrongly claim no network/curl. + +### Phase 2: Changes Required + +#### 1. MCP `sandbox_create` network option +**File**: `src/api/mcp/tools.ts` (`:32-56`) +**Changes**: add `network: z.boolean().optional()` to the tool's input schema (with a `.describe(...)`), read `args.network ?? false` into `runtimeOptions.network`, keep passing it to `persistSandboxMeta`, and include `network` in the returned JSON (parity with the HTTP route `sandboxes.ts:134`). + +```typescript +{ + name: z.string().max(255).optional().describe("Human-readable name for the sandbox"), + python: z.boolean().optional(), + javascript: z.boolean().optional(), + network: z.boolean().optional().describe("Grant outbound HTTPS (enables curl + git clone/fetch/push)"), +}, +// ... +const runtimeOptions = { + python: args.python ?? false, + javascript: args.javascript ?? false, + network: args.network ?? false, +}; +// ...return text JSON includes network: runtimeOptions.network +``` + +#### 2. Correct stale capability descriptions +**File**: `src/api/mcp/tools.ts` (`:170-179`) +**Changes**: the bash-tool capability text says "no network", "no curl/wget", "network access of any kind" not supported. Update to state that **when the sandbox is created with `network:true`**, `curl` and outbound `git clone/fetch/push` are available (still no apt/pip/npm/compilers). Keep the air-gapped wording scoped to `network:false`. + +**File**: `src/api/session-manager.ts` (`:562-565`) +**Changes**: rewrite the comment so it no longer claims bash is air-gapped with "no curl/wget/DNS" when network is on. Describe reality: `network:true` registers just-bash's `curl` (via `secureFetch`) and enables git's outbound transport (via `globalThis.fetch`). + +#### 3. OpenAPI spec (only if missing) +**File**: `src/api/openapi-spec.ts` +**Changes**: confirm the `POST /v1/sandboxes` request body and sandbox response schemas document `network: boolean` alongside `python`/`javascript` (`:321-322`, `:23-24`). If absent, add it. (Do not bump `info.version` — that is the release workflow's job.) + +#### 4. Document `GITHUB_TOKEN` (and identity passthrough) in the env-var table +**File**: `CLAUDE.md` (Environment Variables table) and `.env.example` (if present) +**Changes**: add a `GITHUB_TOKEN` row — "Optional. When set, exported into every sandbox's shell env as `GITHUB_TOKEN` (for `curl` GitHub API) and `GIT_HTTP_BEARER_TOKEN` (for `git push/clone`). Shared, deployment-wide identity; readable by sandbox code — use only with trusted agents. Per-request `env` overrides it." Document the optional `GIT_AUTHOR_NAME/EMAIL`, `GIT_COMMITTER_NAME/EMAIL` passthrough. Add `GITHUB_TOKEN=` to `.env.example` with a comment. + +### Phase 2: Success Criteria + +#### Phase 2: Automated Verification +- [ ] `pnpm typecheck` passes. +- [ ] `pnpm lint:fix` clean. +- [ ] `pnpm test:unit` passes (incl. any MCP tool tests). + +#### Phase 2: Manual Verification +- [ ] MCP `sandbox_create` with `{ network: true }` yields a sandbox where `curl https://api.github.com/zen` and `git clone` work. +- [ ] MCP `sandbox_create` with no `network` (or `false`) still blocks outbound (curl unavailable, clone fails cleanly). +- [ ] Tool descriptions read correctly for both network states. + +### Phase 2: Discoveries and Notable Information +[Filled by the implementing agent during Phase 2 execution.] + +--- + +## Phase 3: Network/credentials integration test + changeset + +### Phase 3: Overview +Prove the end-to-end network path (clone → commit → push with env-supplied identity/token) hermetically, and record the change. + +### Phase 3: Changes Required + +#### 1. Integration test (hermetic, in-process remote) +**File**: `src/api/tests/integration/git-network.integration.test.ts` (new) +**Changes**: stand up just-git's in-memory server (`createServer` from `just-git/server`) as the remote, and route the sandbox git instance's `network.fetch` to `server.fetch` for the test (test-only `createGit({ network: { fetch } })`). Verify, through `bash.exec`: +- `git clone /repo` populates files. +- a commit using `GIT_AUTHOR_*`/`GIT_COMMITTER_*` from the exec `env` records the expected author/committer. +- `GIT_HTTP_BEARER_TOKEN` from `env` is accepted by a token-gated `preReceive` hook on the server; an absent/wrong token is rejected. +- `git push` updates the server ref. + +Gate with `describe.skipIf(...)` only if the in-memory-server harness proves heavy; otherwise it runs with the unit suite (no external network, no DB). Follow the `tests/` conventions in CLAUDE.md (≤300 lines, cleanup in `afterEach`). + +For the bearer-token leg, set the server token via the test's `process.env.GITHUB_TOKEN` (or construct the session manager with it) and assert the in-process server's `preReceive` sees `Authorization: Bearer ` — proving the injected `GIT_HTTP_BEARER_TOKEN` reaches the transport without per-request creds. + +#### 2. Env-injection unit test +**File**: `src/api/tests/unit/sandbox-base-env.test.ts` (new) +**Changes**: with `GITHUB_TOKEN` set, a new session's `bash.exec("echo \"$GITHUB_TOKEN:$GIT_HTTP_BEARER_TOKEN\"")` prints the token twice; with it unset, prints `:` (both empty). A per-request `env` override shadows the base for that call only. (Module-level `SANDBOX_BASE_ENV` reads `process.env` at load — structure the helper so the test can inject config, e.g. a tiny exported `buildSandboxBaseEnv(env = process.env)` builder rather than a frozen const, to keep it unit-testable.) + +#### 3. Changeset +**File**: `.changeset/*.md` (new, via `pnpm changeset`) +**Changes**: minor bump; describe "Add `git` command to sandboxes (just-git); export server `GITHUB_TOKEN` into sandbox env; MCP `sandbox_create` accepts `network`." + +### Phase 3: Success Criteria + +#### Phase 3: Automated Verification +- [ ] `pnpm test -- src/api/tests/integration/git-network.integration.test.ts` passes. +- [ ] `pnpm typecheck && pnpm lint:fix && pnpm test:unit` all pass. +- [ ] A `.changeset/*.md` file exists and describes the change. + +#### Phase 3: Manual Verification +- [ ] Against a real remote (e.g. a throwaway GitHub repo, `network:true`): clone → edit → commit (identity via `env`) → push (token via `GIT_HTTP_BEARER_TOKEN`) → confirm the commit lands on GitHub. + +### Phase 3: Discoveries and Notable Information +[Filled by the implementing agent during Phase 3 execution.] + +--- + +## Testing Strategy + +### Unit Tests +- git command is present in a freshly built session's command set. +- Local lifecycle through `bash.exec`: `init → add → commit → log/status/diff` (no network). +- `network:false` ⇒ `git clone` exits non-zero with a sanitized message; local ops still succeed. +- Committer identity is taken from exec `env` (`GIT_AUTHOR_*`/`GIT_COMMITTER_*`). + +### Integration Tests +- Hermetic clone/commit/push against an in-process just-git server (Phase 3), incl. token gating via `GIT_HTTP_BEARER_TOKEN`. + +### Manual Testing Steps +1. Create a `network:true` sandbox (HTTP and MCP). 2. `curl https://api.github.com/zen`. 3. `git clone` a public repo. 4. Edit + commit with identity env. 5. `git push` to a writable repo with a token env. 6. Create a `network:false` sandbox and confirm curl is absent and clone fails cleanly while local git works. + +## Performance Considerations + +- `createGit()` is called once per session build (alongside `new Bash`), negligible cost. git operates on the same `IFileSystem` (SqlFs) — object/pack writes become blob/inode writes through the existing cache + transaction path; large clones produce many writes, bounded by existing session/exec limits. No new hot loop in the request path. +- `trusted: true` means git runs outside the defense-in-depth patched scope — no added per-call patch/unpatch beyond what just-bash already does for trusted commands. + +## Migration Notes + +- No schema or data migration. Existing sandboxes gain `git` on their next session build (sessions are warm, in-memory; eviction/rebuild picks it up). +- `network` semantics are unchanged for existing sandboxes; only the MCP create path gains the option. +- Rollback = remove `gitCommand` from `customCommands` and drop the dependency; no persisted state depends on git. + +## Security / Operational Assumptions + +These follow from the two decisions (export-into-env · single deployment-wide token) and MUST hold for this design to be safe: + +- **Trusted agents only.** With full outbound and `GITHUB_TOKEN` readable in the sandbox (`echo $GITHUB_TOKEN`), any code running in a sandbox can exfiltrate the token to an arbitrary host. Acceptable only if the agents/scripts driving sandboxes are trusted. Untrusted/user-submitted scripts would require the deferred hardened posture (egress header-injection + allowlist). +- **Single-tenant identity.** The one `GITHUB_TOKEN` is a shared GitHub identity across every sandbox/tenant. Scope the token's GitHub permissions to the minimum needed (e.g. a fine-grained PAT limited to the target repos, `contents:write` + `pull_requests:write` + `issues:write`). Do not use a broad classic PAT. +- **Token never persisted.** It lives only in server `process.env`; it is not written to `sandboxes` meta or any table, and must not be echoed into structured logs (avoid logging sandbox env / `body.env`). +- **Rotation = redeploy.** Because the value is read at process start, rotating the token means updating `.env` and restarting/redeploying replicas. Warm sessions created before a rotation keep the old token until evicted/rebuilt. +- **Revisit on multi-tenancy.** If this deployment ever serves distinct customers, this plan's token model must change (per-tenant resolution or per-request-only) before that rollout. + +## References + +- Trace-analysis harness (motivation): `/Users/nguyendangquang/master/Web-Dev/just-bash/thoughts/trace-analysis-agent-harness.md` +- just-git client docs: `https://github.com/blindmansion/just-git` (`docs/CLIENT.md`) +- Integration point: `src/api/session-manager.ts:549-568` +- Existing custom-command pattern: `src/api/commands/node-command.ts` +- MCP create tool: `src/api/mcp/tools.ts:32-56` +- HTTP create route (network already wired): `src/api/routes/sandboxes.ts:22,52,109,130,134` +- Defense-in-depth helper (pattern reference, not needed for git): `src/sql-fs/defense.ts` +- just-git registration/network facts: `git.ts:489`, `custom-commands.ts:48`, `types.ts:238-247`, `remote.ts:88-115`, `identity.ts:7-65`