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.

112 lines (98 loc) 3.72 kB
/** * Sandbox policy — a portable, harness-agnostic description of what the agent * may do. Each harness adapter MAPS this onto its native permission system * (Claude Code → canUseTool + allowedTools/disallowedTools/permissionMode). * * Command rules are matched as glob/prefix patterns against the command line. * Precedence is deny > ask > allow; unmatched commands fall to `default`. * `'ask'` surfaces the existing resume-based `approval-requested` flow. */ export type PolicyDecision = 'allow' | 'ask' | 'deny' export interface CommandRules { /** Glob/prefix patterns to allow outright (e.g. 'pnpm *', 'git diff'). */ allow?: Array<string> /** Patterns that require approval before running. */ ask?: Array<string> /** Patterns to refuse (e.g. 'sudo *', 'rm -rf *'). */ deny?: Array<string> } /** Coarse, non-command capability gates for tools like Write/Edit and network. */ export interface CapabilityRules { /** File-modifying tools (Write/Edit). Defaults to the policy `default`. */ fileWrite?: PolicyDecision /** Outbound network access. Defaults to the policy `default`. */ network?: PolicyDecision } export interface SandboxPolicy { commands?: CommandRules capabilities?: CapabilityRules /** Decision for anything not matched by a rule. Defaults to `'ask'`. */ default?: PolicyDecision } export function defineSandboxPolicy(policy: SandboxPolicy): SandboxPolicy { return policy } /** Convert a glob/prefix pattern to a RegExp anchored to the full command. */ function patternToRegExp(pattern: string): RegExp { // Escape regex metacharacters except '*', then turn '*' into '.*'. const escaped = pattern .replace(/[.+?^${}()|[\]\\]/g, '\\$&') .replace(/\*/g, '.*') return new RegExp(`^${escaped}$`) } /** * All equivalent forms of a command line when workspace scripts are defined: * the literal command, its expanded script value, and any script name that * expands to the same value. */ export function commandAliases( command: string, scripts: Record<string, string> | undefined, ): Array<string> { const trimmed = command.trim() const aliases = new Set<string>([trimmed]) if (scripts === undefined) return [...aliases] const expanded = scripts[trimmed] if (expanded !== undefined) aliases.add(expanded) for (const [name, value] of Object.entries(scripts)) { if (value === trimmed) aliases.add(name) } return [...aliases] } function patternMatchesCommand( pattern: string, command: string, scripts: Record<string, string> | undefined, ): boolean { const commandForms = commandAliases(command, scripts) for (const patternForm of commandAliases(pattern, scripts)) { const re = patternToRegExp(patternForm) if (commandForms.some((form) => re.test(form))) return true } return false } /** * Resolve a command line against the policy. Precedence: deny > ask > allow, * then `default` (defaults to `'ask'`). Exported for adapter permission * mappers and unit tests. * * When `scripts` is provided, policy patterns may match either a script name * or its expanded command value (and vice versa for the command under test). */ export function evaluateCommand( command: string, policy: SandboxPolicy | undefined, scripts?: Record<string, string>, ): PolicyDecision { const fallback = policy?.default ?? 'ask' const rules = policy?.commands if (!rules) return fallback const matches = (patterns: Array<string> | undefined): boolean => (patterns ?? []).some((pattern) => patternMatchesCommand(pattern, command, scripts), ) if (matches(rules.deny)) return 'deny' if (matches(rules.ask)) return 'ask' if (matches(rules.allow)) return 'allow' return fallback }