@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.
98 lines (89 loc) • 3.75 kB
text/typescript
/**
* Shared interactive-approval logic for harness adapters.
*
* Flow (rides chat()'s existing resume-based approval mechanism):
* 1. The agent (inside the sandbox) asks to run a risky action; the harness's
* host-side permission callback fires.
* 2. `resolveApproval` evaluates the sandbox policy: `allow`/`deny` are final;
* `ask` consults the client's approval decisions (threaded via
* `TextOptions.approvals`, keyed by a stable `approvalId`).
* 3. On `ask` with no decision yet, the adapter emits an `approval-requested`
* CUSTOM event (carrying the `approvalId`) and denies the action this turn.
* The client shows UI, then re-runs chat() with the decision in the message;
* the engine surfaces it as `approvals`, and the next run allows it.
*
* `approvalId` is stable for a given (provider, kind, target) so a client grant
* matches the same action on the resumed run.
*/
import { EventType, withTanstackMetadata } from '@tanstack/ai'
import { evaluateCommand } from './policy'
import type { SandboxPolicy } from './policy'
import type { StreamChunk } from '@tanstack/ai'
/** CUSTOM event name emitted when a harness action needs client approval. */
export const APPROVAL_REQUESTED_EVENT = 'approval-requested'
/** A stable, opaque approval id for a harness action. */
export function approvalId(input: {
provider: string
kind: 'command' | 'fileWrite' | 'network' | 'tool'
target: string
}): string {
return `${input.provider}:${input.kind}:${input.target}`
}
export interface ResolveApprovalInput {
policy: SandboxPolicy | undefined
/** Client approval decisions, keyed by `approvalId`. */
approvals: ReadonlyMap<string, boolean> | undefined
/** Precomputed approval id for this action. */
id: string
/** A shell command to match against `policy.commands`. */
command?: string
/** Named workspace scripts for policy alias resolution. */
scripts?: Record<string, string>
/** A coarse capability to match against `policy.capabilities`. */
capability?: 'fileWrite' | 'network'
}
export interface ApprovalOutcome {
decision: 'allow' | 'deny'
/** True when policy said `ask` and the client hasn't decided yet. */
needsApproval: boolean
}
/** Resolve a harness permission request against policy + client approvals. */
export function resolveApproval(input: ResolveApprovalInput): ApprovalOutcome {
const base =
input.command !== undefined
? evaluateCommand(input.command, input.policy, input.scripts)
: input.capability !== undefined
? (input.policy?.capabilities?.[input.capability] ??
input.policy?.default ??
'ask')
: (input.policy?.default ?? 'ask')
if (base === 'allow') return { decision: 'allow', needsApproval: false }
if (base === 'deny') return { decision: 'deny', needsApproval: false }
// base === 'ask' — consult the client's decision.
const granted = input.approvals?.get(input.id)
if (granted === true) return { decision: 'allow', needsApproval: false }
if (granted === false) return { decision: 'deny', needsApproval: false }
return { decision: 'deny', needsApproval: true }
}
/** Build the AG-UI `approval-requested` CUSTOM event for a harness action. */
export function buildApprovalRequestedEvent(input: {
approvalId: string
title: string
threadId: string
runId: string
detail?: Record<string, unknown>
}): StreamChunk {
return withTanstackMetadata(
{
type: EventType.CUSTOM,
name: APPROVAL_REQUESTED_EVENT,
value: {
approvalId: input.approvalId,
title: input.title,
...(input.detail ?? {}),
},
timestamp: Date.now(),
},
{ threadId: input.threadId, runId: input.runId },
) as StreamChunk
}