@tanstack/ai-sandbox
Version:
Provider-agnostic sandbox layer for TanStack AI — run harness adapters inside isolated sandboxes (defineSandbox, defineWorkspace, withSandbox) with a uniform SandboxHandle, workspace bootstrap, policy, and resumable lifecycle.
122 lines (113 loc) • 4.71 kB
text/typescript
/**
* An exec-backed {@link SandboxGit} implementation. Providers without a native
* git API (local-process, Docker) get a uniform `sandbox.git` by desugaring to
* `process.exec("git …")`. Providers WITH native git (Daytona, Cloudflare) may
* supply their own implementation instead.
*
* Security:
* - Every interpolated value is single-quote escaped (no shell injection).
* - A `--` end-of-options separator precedes untrusted positionals and values
* are rejected if they begin with `-`, so a repo URL / ref / path can't
* smuggle a git flag (e.g. `--upload-pack=…`).
* - Auth tokens NEVER appear in argv (they'd leak via `ps` / process logs).
* Instead a one-shot `credential.helper` reads the token from the child
* process ENV. The helper string is single-quoted so the OUTER shell never
* expands the env var — only git's own helper subshell does, at use time.
*
* NOTE: `SandboxProcess.exec` takes a command STRING by design (the sandbox
* runs shell commands), so we mitigate flag smuggling with `--` + validation
* rather than an argv array.
*/
import type { SandboxGit, SandboxProcess } from './contracts'
/** POSIX single-quote escape: wrap in '…' and escape embedded quotes. */
function q(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`
}
/** Reject values that could be parsed as a git flag when used as a positional. */
function assertNoLeadingDash(value: string, name: string): void {
if (value.startsWith('-')) {
throw new Error(
`git-exec: ${name} "${value}" must not begin with "-" (argument-injection guard).`,
)
}
}
// Credential helper that prints creds read from the child ENV. Single-quoted at
// the call site so the outer shell passes it literally; git expands the vars in
// its own helper subshell, keeping the token out of argv.
const CREDENTIAL_HELPER =
'!f() { echo "username=${GIT_ASKPASS_USER}"; echo "password=${GIT_ASKPASS_TOKEN}"; }; f'
export function createExecBackedGit(
process: SandboxProcess,
defaultRoot: string,
): SandboxGit {
const at = (dir?: string): string => {
const d = dir ?? defaultRoot
assertNoLeadingDash(d, 'dir')
return q(d)
}
return {
clone: async ({ url, dir, ref, auth, depth }) => {
assertNoLeadingDash(url, 'url')
const target = dir ?? defaultRoot
assertNoLeadingDash(target, 'dir')
if (ref !== undefined) assertNoLeadingDash(ref, 'ref')
const refArg = ref ? `--branch ${q(ref)} ` : ''
const resolvedDepth = depth ?? 1
// `depth` is interpolated unquoted into the command, so validate it the
// same way other positionals are guarded — a non-positive-integer (e.g. an
// untyped caller passing a string) must never reach the shell.
if (
resolvedDepth !== 'full' &&
(!Number.isInteger(resolvedDepth) || resolvedDepth <= 0)
) {
throw new Error('git-exec: depth must be a positive integer or "full".')
}
const depthArg =
resolvedDepth === 'full'
? ''
: `--depth ${resolvedDepth} --single-branch `
// `git clone` does not create missing parents. gitSkill clones into
// `<root>/.tanstack-skills/<name>`, so create that parent first.
const parentSlash = target.lastIndexOf('/')
if (parentSlash > 0) {
await process.exec(`mkdir -p ${q(target.slice(0, parentSlash))}`)
}
if (auth?.token) {
await process.exec(
`git -c credential.helper=${q(CREDENTIAL_HELPER)} clone ${refArg}${depthArg}-- ${q(url)} ${q(target)}`,
{
// Token lives only in the child env, never in argv.
env: {
GIT_ASKPASS_USER: auth.username ?? 'x-access-token',
GIT_ASKPASS_TOKEN: auth.token,
GIT_TERMINAL_PROMPT: '0',
},
},
)
return
}
await process.exec(
`git clone ${refArg}${depthArg}-- ${q(url)} ${q(target)}`,
)
},
status: async (dir) =>
(await process.exec(`git -C ${at(dir)} status --porcelain`)).stdout,
add: async (paths, dir) => {
paths.forEach((p, i) => assertNoLeadingDash(p, `path[${i}]`))
await process.exec(`git -C ${at(dir)} add -- ${paths.map(q).join(' ')}`)
},
commit: async (message, dir) => {
await process.exec(`git -C ${at(dir)} commit -m ${q(message)}`)
},
push: async (dir) => {
await process.exec(`git -C ${at(dir)} push`)
},
pull: async (dir) => {
await process.exec(`git -C ${at(dir)} pull`)
},
branch: async (dir) =>
(
await process.exec(`git -C ${at(dir)} rev-parse --abbrev-ref HEAD`)
).stdout.trim(),
}
}