UNPKG

@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
/** * 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 }