From c85f23cb6eabf7a930acd246e70221c2f5a61cd3 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Fri, 13 Feb 2026 21:46:11 +0900 Subject: [PATCH] =?UTF-8?q?claude=20code=20=E3=81=8Csandbox=E3=81=A7?= =?UTF-8?q?=E5=AE=9F=E8=A1=8C=E3=81=95=E3=82=8C=E3=82=8B=E3=81=9F=E3=82=81?= =?UTF-8?q?=E3=80=81=E3=83=86=E3=82=B9=E3=83=88=E3=81=8C=E5=AE=9F=E8=A1=8C?= =?UTF-8?q?=E3=81=A7=E3=81=8D=E3=81=AA=E3=81=84=E5=95=8F=E9=A1=8C=E3=82=92?= =?UTF-8?q?=E5=AF=BE=E5=87=A6=E3=81=A7=E3=81=8D=E3=82=8B=E3=82=AA=E3=83=97?= =?UTF-8?q?=E3=82=B7=E3=83=A7=E3=83=B3=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/provider-sandbox.md | 168 ++++++++++++++++++ .../provider-options-piece-parser.test.ts | 76 +++++++- src/agents/runner.ts | 23 ++- src/agents/types.ts | 7 +- src/core/models/global-config.ts | 5 + src/core/models/index.ts | 1 + src/core/models/piece-types.ts | 14 ++ src/core/models/schemas.ts | 12 ++ src/core/models/types.ts | 1 + src/infra/claude/client.ts | 1 + src/infra/claude/options-builder.ts | 4 + src/infra/claude/types.ts | 8 +- src/infra/config/global/globalConfig.ts | 2 + src/infra/config/loaders/pieceParser.ts | 69 ++++--- src/infra/config/types.ts | 3 + src/infra/providers/claude.ts | 5 + src/infra/providers/types.ts | 7 +- 17 files changed, 371 insertions(+), 35 deletions(-) create mode 100644 docs/provider-sandbox.md diff --git a/docs/provider-sandbox.md b/docs/provider-sandbox.md new file mode 100644 index 0000000..9553c00 --- /dev/null +++ b/docs/provider-sandbox.md @@ -0,0 +1,168 @@ +# Provider Sandbox Configuration + +TAKT supports configuring sandbox settings for AI agent providers. This document covers how sandbox isolation works across providers, how to configure it, and the security trade-offs. + +## Overview + +| Provider | Sandbox Mechanism | Build Tool Issues | TAKT Configuration | +|----------|------------------|-------------------|-------------------| +| **Claude Code** | macOS Seatbelt / Linux bubblewrap | Gradle/JVM blocked in `edit` mode | `provider_options.claude.sandbox` | +| **Codex CLI** | macOS Seatbelt / Linux Landlock+seccomp | npm/maven/pytest failures (widespread) | `provider_options.codex.network_access` | +| **OpenCode CLI** | None (no native sandbox) | No constraints (no security either) | N/A | + +## Claude Code Sandbox + +### The Problem + +When a movement uses `permission_mode: edit` (mapped to Claude SDK's `acceptEdits`), Bash commands run inside a macOS Seatbelt sandbox. This sandbox blocks: + +- Writes outside the working directory (e.g., `~/.gradle`) +- Certain system calls required by JVM initialization +- Network access (by default) + +As a result, build tools like Gradle, Maven, or any JVM-based tool fail with `Operation not permitted`. + +### Solution: `provider_options.claude.sandbox` + +TAKT exposes Claude SDK's `SandboxSettings` through `provider_options.claude.sandbox` at four configuration levels. + +#### Option A: `allow_unsandboxed_commands` (Recommended) + +Allow all Bash commands to run outside the sandbox while keeping file edit permissions controlled: + +```yaml +provider_options: + claude: + sandbox: + allow_unsandboxed_commands: true +``` + +#### Option B: `excluded_commands` + +Exclude only specific commands from the sandbox: + +```yaml +provider_options: + claude: + sandbox: + excluded_commands: + - ./gradlew + - npm + - npx +``` + +### Configuration Levels + +Settings are merged with the following priority (highest wins): + +``` +Movement > Piece > Project Local > Global +``` + +#### Global (`~/.takt/config.yaml`) + +Applies to all projects and all pieces: + +```yaml +# ~/.takt/config.yaml +provider_options: + claude: + sandbox: + allow_unsandboxed_commands: true +``` + +#### Project Local (`.takt/config.yaml`) + +Applies to this project only: + +```yaml +# .takt/config.yaml +provider_options: + claude: + sandbox: + excluded_commands: + - ./gradlew +``` + +#### Piece (`piece_config` section) + +Applies to all movements in this piece: + +```yaml +# pieces/my-piece.yaml +piece_config: + provider_options: + claude: + sandbox: + allow_unsandboxed_commands: true +``` + +#### Movement (per step) + +Applies to a specific movement only: + +```yaml +movements: + - name: implement + permission_mode: edit + provider_options: + claude: + sandbox: + allow_unsandboxed_commands: true + - name: review + permission_mode: readonly + # No sandbox config needed — readonly doesn't sandbox Bash +``` + +### Security Risk Comparison + +| Configuration | File Edits | Network | Bash Commands | CWD-external Writes | Risk Level | +|--------------|-----------|---------|---------------|---------------------|------------| +| `permission_mode: edit` (default) | Permitted | Blocked | Sandboxed | Blocked | Low | +| `excluded_commands: [./gradlew]` | Permitted | Blocked | Only `./gradlew` unsandboxed | Only via `./gradlew` | Low | +| `allow_unsandboxed_commands: true` | Permitted | Allowed | Unsandboxed | Allowed via Bash | **Medium** | +| `permission_mode: full` | All permitted | Allowed | Unsandboxed | All permitted | **High** | + +**Key difference between `allow_unsandboxed_commands` and `permission_mode: full`:** +- `allow_unsandboxed_commands`: File edits still require Claude Code's permission check (`acceptEdits` mode). Only Bash is unsandboxed. +- `permission_mode: full`: All permission checks are bypassed (`bypassPermissions` mode). No guardrails at all. + +### Practical Risk Assessment + +The "Medium" risk of `allow_unsandboxed_commands` is manageable in practice because: + +- TAKT runs locally on the developer's machine (not a public-facing service) +- Input comes from task instructions written by the developer +- Agent behavior is reviewed by the supervisor movement +- File edit operations still go through Claude Code's permission system + +## Codex CLI Sandbox + +Codex CLI uses macOS Seatbelt (same as Claude Code) but has **more severe compatibility issues** with build tools. Community reports show npm, Maven, pytest, and other tools frequently failing with `Operation not permitted` — even when the same commands work in Claude Code. + +Codex sandbox is configured via `~/.codex/config.toml` (not through TAKT): + +```toml +# ~/.codex/config.toml +sandbox_mode = "workspace-write" + +[sandbox_workspace_write] +network_access = true +writable_roots = ["/Users/YOU/.gradle"] +``` + +TAKT provides `provider_options.codex.network_access` to control network access via the Codex SDK: + +```yaml +provider_options: + codex: + network_access: true +``` + +For other sandbox settings (writable_roots, sandbox_mode), configure directly in `~/.codex/config.toml`. + +## OpenCode CLI Sandbox + +OpenCode CLI does not have a native sandbox mechanism. All commands run without filesystem or network restrictions. For isolation, the community recommends Docker containers (e.g., [opencode-sandbox](https://github.com/fabianlema/opencode-sandbox)). + +No TAKT-side sandbox configuration is needed or available for OpenCode. diff --git a/src/__tests__/provider-options-piece-parser.test.ts b/src/__tests__/provider-options-piece-parser.test.ts index 45d7adc..79e36b1 100644 --- a/src/__tests__/provider-options-piece-parser.test.ts +++ b/src/__tests__/provider-options-piece-parser.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { normalizePieceConfig } from '../infra/config/loaders/pieceParser.js'; +import { normalizePieceConfig, mergeProviderOptions } from '../infra/config/loaders/pieceParser.js'; describe('normalizePieceConfig provider_options', () => { it('piece-level global を movement に継承し、movement 側で上書きできる', () => { @@ -43,4 +43,78 @@ describe('normalizePieceConfig provider_options', () => { opencode: { networkAccess: false }, }); }); + + it('claude sandbox を piece-level で設定し movement で上書きできる', () => { + const raw = { + name: 'claude-sandbox', + piece_config: { + provider_options: { + claude: { + sandbox: { allow_unsandboxed_commands: true }, + }, + }, + }, + movements: [ + { + name: 'inherit', + instruction: '{task}', + }, + { + name: 'override', + provider_options: { + claude: { + sandbox: { + allow_unsandboxed_commands: false, + excluded_commands: ['./gradlew'], + }, + }, + }, + instruction: '{task}', + }, + ], + }; + + const config = normalizePieceConfig(raw, process.cwd()); + + expect(config.providerOptions).toEqual({ + claude: { sandbox: { allowUnsandboxedCommands: true } }, + }); + expect(config.movements[0]?.providerOptions).toEqual({ + claude: { sandbox: { allowUnsandboxedCommands: true } }, + }); + expect(config.movements[1]?.providerOptions).toEqual({ + claude: { + sandbox: { + allowUnsandboxedCommands: false, + excludedCommands: ['./gradlew'], + }, + }, + }); + }); +}); + +describe('mergeProviderOptions', () => { + it('複数層を正しくマージする(後の層が優先)', () => { + const global = { + claude: { sandbox: { allowUnsandboxedCommands: false, excludedCommands: ['./gradlew'] } }, + codex: { networkAccess: true }, + }; + const local = { + claude: { sandbox: { allowUnsandboxedCommands: true } }, + }; + const step = { + codex: { networkAccess: false }, + }; + + const result = mergeProviderOptions(global, local, step); + + expect(result).toEqual({ + claude: { sandbox: { allowUnsandboxedCommands: true, excludedCommands: ['./gradlew'] } }, + codex: { networkAccess: false }, + }); + }); + + it('すべて undefined なら undefined を返す', () => { + expect(mergeProviderOptions(undefined, undefined, undefined)).toBeUndefined(); + }); }); diff --git a/src/agents/runner.ts b/src/agents/runner.ts index 80952cd..75691a6 100644 --- a/src/agents/runner.ts +++ b/src/agents/runner.ts @@ -5,8 +5,9 @@ import { existsSync, readFileSync } from 'node:fs'; import { basename, dirname } from 'node:path'; import { loadCustomAgents, loadAgentPrompt, loadGlobalConfig, loadProjectConfig } from '../infra/config/index.js'; +import { mergeProviderOptions } from '../infra/config/loaders/pieceParser.js'; import { getProvider, type ProviderType, type ProviderCallOptions } from '../infra/providers/index.js'; -import type { AgentResponse, CustomAgentConfig } from '../core/models/index.js'; +import type { AgentResponse, CustomAgentConfig, MovementProviderOptions } from '../core/models/index.js'; import { createLogger } from '../shared/utils/index.js'; import { loadTemplate } from '../shared/prompts/index.js'; import type { RunAgentOptions } from './types.js'; @@ -92,6 +93,24 @@ export class AgentRunner { return `${dir}/${name}`; } + /** + * Resolve provider options with 4-layer priority: Global < Local < Step (piece+movement merged). + * Step already contains the piece+movement merge result from pieceParser. + */ + private static resolveProviderOptions( + cwd: string, + stepOptions?: MovementProviderOptions, + ): MovementProviderOptions | undefined { + let globalOptions: MovementProviderOptions | undefined; + try { + globalOptions = loadGlobalConfig().providerOptions; + } catch { /* ignore */ } + + const localOptions = loadProjectConfig(cwd).provider_options; + + return mergeProviderOptions(globalOptions, localOptions, stepOptions); + } + /** Build ProviderCallOptions from RunAgentOptions */ private static buildCallOptions( resolvedProvider: ProviderType, @@ -107,7 +126,7 @@ export class AgentRunner { maxTurns: options.maxTurns, model: AgentRunner.resolveModel(resolvedProvider, options, agentConfig), permissionMode: options.permissionMode, - providerOptions: options.providerOptions, + providerOptions: AgentRunner.resolveProviderOptions(options.cwd, options.providerOptions), onStream: options.onStream, onPermissionRequest: options.onPermissionRequest, onAskUserQuestion: options.onAskUserQuestion, diff --git a/src/agents/types.ts b/src/agents/types.ts index da7cea0..a7cbd06 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -3,7 +3,7 @@ */ import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../infra/claude/types.js'; -import type { PermissionMode, Language, McpServerConfig } from '../core/models/index.js'; +import type { PermissionMode, Language, McpServerConfig, MovementProviderOptions } from '../core/models/index.js'; export type { StreamCallback }; @@ -25,10 +25,7 @@ export interface RunAgentOptions { /** Permission mode for tool execution (from piece step) */ permissionMode?: PermissionMode; /** Provider-specific movement options */ - providerOptions?: { - codex?: { networkAccess?: boolean }; - opencode?: { networkAccess?: boolean }; - }; + providerOptions?: MovementProviderOptions; onStream?: StreamCallback; onPermissionRequest?: PermissionHandler; onAskUserQuestion?: AskUserQuestionHandler; diff --git a/src/core/models/global-config.ts b/src/core/models/global-config.ts index 7ab9db5..8e90589 100644 --- a/src/core/models/global-config.ts +++ b/src/core/models/global-config.ts @@ -2,6 +2,8 @@ * Configuration types (global and project) */ +import type { MovementProviderOptions } from './piece-types.js'; + /** Custom agent configuration */ export interface CustomAgentConfig { name: string; @@ -86,6 +88,8 @@ export interface GlobalConfig { pieceCategoriesFile?: string; /** Per-persona provider overrides (e.g., { coder: 'codex' }) */ personaProviders?: Record; + /** Global provider-specific options (lowest priority) */ + providerOptions?: MovementProviderOptions; /** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */ branchNameStrategy?: 'romaji' | 'ai'; /** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */ @@ -107,4 +111,5 @@ export interface ProjectConfig { piece?: string; agents?: CustomAgentConfig[]; provider?: 'claude' | 'codex' | 'opencode' | 'mock'; + providerOptions?: MovementProviderOptions; } diff --git a/src/core/models/index.ts b/src/core/models/index.ts index 9177e1b..9cd5116 100644 --- a/src/core/models/index.ts +++ b/src/core/models/index.ts @@ -14,6 +14,7 @@ export type { PartResult, TeamLeaderConfig, PieceRule, + MovementProviderOptions, PieceMovement, ArpeggioMovementConfig, ArpeggioMergeMovementConfig, diff --git a/src/core/models/piece-types.ts b/src/core/models/piece-types.ts index 5a02088..8449032 100644 --- a/src/core/models/piece-types.ts +++ b/src/core/models/piece-types.ts @@ -92,10 +92,24 @@ export interface OpenCodeProviderOptions { networkAccess?: boolean; } +/** Claude sandbox settings (maps to SDK SandboxSettings) */ +export interface ClaudeSandboxSettings { + /** Allow all Bash commands to run outside the sandbox */ + allowUnsandboxedCommands?: boolean; + /** Specific commands to exclude from sandbox (e.g., ["./gradlew", "npm test"]) */ + excludedCommands?: string[]; +} + +/** Claude provider-specific options */ +export interface ClaudeProviderOptions { + sandbox?: ClaudeSandboxSettings; +} + /** Provider-specific movement options */ export interface MovementProviderOptions { codex?: CodexProviderOptions; opencode?: OpenCodeProviderOptions; + claude?: ClaudeProviderOptions; } /** Single movement in a piece */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index 82a3dde..b23ff3b 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -59,6 +59,12 @@ export const StatusSchema = z.enum([ /** Permission mode schema for tool execution */ export const PermissionModeSchema = z.enum(['readonly', 'edit', 'full']); +/** Claude sandbox settings schema */ +export const ClaudeSandboxSchema = z.object({ + allow_unsandboxed_commands: z.boolean().optional(), + excluded_commands: z.array(z.string()).optional(), +}).optional(); + /** Provider-specific movement options schema */ export const MovementProviderOptionsSchema = z.object({ codex: z.object({ @@ -67,6 +73,9 @@ export const MovementProviderOptionsSchema = z.object({ opencode: z.object({ network_access: z.boolean().optional(), }).optional(), + claude: z.object({ + sandbox: ClaudeSandboxSchema, + }).optional(), }).optional(); /** Piece-level provider options schema */ @@ -414,6 +423,8 @@ export const GlobalConfigSchema = z.object({ piece_categories_file: z.string().optional(), /** Per-persona provider overrides (e.g., { coder: 'codex' }) */ persona_providers: z.record(z.string(), z.enum(['claude', 'codex', 'opencode', 'mock'])).optional(), + /** Global provider-specific options (lowest priority) */ + provider_options: MovementProviderOptionsSchema, /** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */ branch_name_strategy: z.enum(['romaji', 'ai']).optional(), /** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */ @@ -441,4 +452,5 @@ export const ProjectConfigSchema = z.object({ piece: z.string().optional(), agents: z.array(CustomAgentConfigSchema).optional(), provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(), + provider_options: MovementProviderOptionsSchema, }); diff --git a/src/core/models/types.ts b/src/core/models/types.ts index 84b8340..ce59af8 100644 --- a/src/core/models/types.ts +++ b/src/core/models/types.ts @@ -37,6 +37,7 @@ export type { OutputContractItem, OutputContractEntry, McpServerConfig, + MovementProviderOptions, PieceMovement, ArpeggioMovementConfig, ArpeggioMergeMovementConfig, diff --git a/src/infra/claude/client.ts b/src/infra/claude/client.ts index cb568e5..03da8c5 100644 --- a/src/infra/claude/client.ts +++ b/src/infra/claude/client.ts @@ -52,6 +52,7 @@ export class ClaudeClient { bypassPermissions: options.bypassPermissions, anthropicApiKey: options.anthropicApiKey, outputSchema: options.outputSchema, + sandbox: options.sandbox, }; } diff --git a/src/infra/claude/options-builder.ts b/src/infra/claude/options-builder.ts index dac37ab..9cf2ce6 100644 --- a/src/infra/claude/options-builder.ts +++ b/src/infra/claude/options-builder.ts @@ -95,6 +95,10 @@ export class SdkOptionsBuilder { sdkOptions.stderr = this.options.onStderr; } + if (this.options.sandbox) { + sdkOptions.sandbox = this.options.sandbox; + } + return sdkOptions; } diff --git a/src/infra/claude/types.ts b/src/infra/claude/types.ts index 1c19741..67114c6 100644 --- a/src/infra/claude/types.ts +++ b/src/infra/claude/types.ts @@ -5,8 +5,10 @@ * used throughout the Claude integration layer. */ -import type { PermissionUpdate, AgentDefinition } from '@anthropic-ai/claude-agent-sdk'; +import type { PermissionUpdate, AgentDefinition, SandboxSettings } from '@anthropic-ai/claude-agent-sdk'; import type { PermissionMode, McpServerConfig } from '../../core/models/index.js'; + +export type { SandboxSettings }; import type { PermissionResult } from '../../core/piece/index.js'; // Re-export PermissionResult for convenience @@ -145,6 +147,8 @@ export interface ClaudeCallOptions { anthropicApiKey?: string; /** JSON Schema for structured output */ outputSchema?: Record; + /** Sandbox settings for Claude SDK */ + sandbox?: SandboxSettings; } /** Options for spawning a Claude SDK query (low-level, used by executor/process) */ @@ -176,4 +180,6 @@ export interface ClaudeSpawnOptions { outputSchema?: Record; /** Callback for stderr output from the Claude Code process */ onStderr?: (data: string) => void; + /** Sandbox settings for Claude SDK */ + sandbox?: SandboxSettings; } diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index 763f138..4d86a5c 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -9,6 +9,7 @@ import { readFileSync, existsSync, writeFileSync } from 'node:fs'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { GlobalConfigSchema } from '../../../core/models/index.js'; import type { GlobalConfig, DebugConfig, Language } from '../../../core/models/index.js'; +import { normalizeProviderOptions } from '../loaders/pieceParser.js'; import { getGlobalConfigPath, getProjectConfigPath } from '../paths.js'; import { DEFAULT_LANGUAGE } from '../../../shared/constants.js'; import { parseProviderModel } from '../../../shared/utils/providerModel.js'; @@ -124,6 +125,7 @@ export class GlobalConfigManager { bookmarksFile: parsed.bookmarks_file, pieceCategoriesFile: parsed.piece_categories_file, personaProviders: parsed.persona_providers, + providerOptions: normalizeProviderOptions(parsed.provider_options), branchNameStrategy: parsed.branch_name_strategy, preventSleep: parsed.prevent_sleep, notificationSound: parsed.notification_sound, diff --git a/src/infra/config/loaders/pieceParser.ts b/src/infra/config/loaders/pieceParser.ts index 83df5dc..525fc04 100644 --- a/src/infra/config/loaders/pieceParser.ts +++ b/src/infra/config/loaders/pieceParser.ts @@ -24,34 +24,61 @@ import { type RawStep = z.output; -function normalizeProviderOptions( +import type { MovementProviderOptions } from '../../../core/models/piece-types.js'; + +/** Convert raw YAML provider_options (snake_case) to internal format (camelCase). */ +export function normalizeProviderOptions( raw: RawStep['provider_options'], -): PieceMovement['providerOptions'] { +): MovementProviderOptions | undefined { if (!raw) return undefined; - const codex = raw.codex?.network_access === undefined - ? undefined - : { networkAccess: raw.codex.network_access }; - const opencode = raw.opencode?.network_access === undefined - ? undefined - : { networkAccess: raw.opencode.network_access }; - - if (!codex && !opencode) return undefined; - return { ...(codex ? { codex } : {}), ...(opencode ? { opencode } : {}) }; + const result: MovementProviderOptions = {}; + if (raw.codex?.network_access !== undefined) { + result.codex = { networkAccess: raw.codex.network_access }; + } + if (raw.opencode?.network_access !== undefined) { + result.opencode = { networkAccess: raw.opencode.network_access }; + } + if (raw.claude?.sandbox) { + result.claude = { + sandbox: { + ...(raw.claude.sandbox.allow_unsandboxed_commands !== undefined + ? { allowUnsandboxedCommands: raw.claude.sandbox.allow_unsandboxed_commands } + : {}), + ...(raw.claude.sandbox.excluded_commands !== undefined + ? { excludedCommands: raw.claude.sandbox.excluded_commands } + : {}), + }, + }; + } + return Object.keys(result).length > 0 ? result : undefined; } -function mergeProviderOptions( - base: PieceMovement['providerOptions'], - override: PieceMovement['providerOptions'], -): PieceMovement['providerOptions'] { - const codexNetworkAccess = override?.codex?.networkAccess ?? base?.codex?.networkAccess; - const opencodeNetworkAccess = override?.opencode?.networkAccess ?? base?.opencode?.networkAccess; +/** + * Deep merge provider options. Later sources override earlier ones. + * Exported for reuse in runner.ts (4-layer resolution). + */ +export function mergeProviderOptions( + ...layers: (MovementProviderOptions | undefined)[] +): MovementProviderOptions | undefined { + const result: MovementProviderOptions = {}; - const codex = codexNetworkAccess === undefined ? undefined : { networkAccess: codexNetworkAccess }; - const opencode = opencodeNetworkAccess === undefined ? undefined : { networkAccess: opencodeNetworkAccess }; + for (const layer of layers) { + if (!layer) continue; + if (layer.codex) { + result.codex = { ...result.codex, ...layer.codex }; + } + if (layer.opencode) { + result.opencode = { ...result.opencode, ...layer.opencode }; + } + if (layer.claude?.sandbox) { + result.claude = { + sandbox: { ...result.claude?.sandbox, ...layer.claude.sandbox }, + }; + } + } - if (!codex && !opencode) return undefined; - return { ...(codex ? { codex } : {}), ...(opencode ? { opencode } : {}) }; + return Object.keys(result).length > 0 ? result : undefined; } /** Check if a raw output contract item is the object form (has 'name' property). */ diff --git a/src/infra/config/types.ts b/src/infra/config/types.ts index f29d537..334d105 100644 --- a/src/infra/config/types.ts +++ b/src/infra/config/types.ts @@ -3,6 +3,7 @@ */ import type { PieceCategoryConfigNode } from '../../core/models/schemas.js'; +import type { MovementProviderOptions } from '../../core/models/piece-types.js'; /** Permission mode for the project * - default: Uses Agent SDK's acceptEdits mode (auto-accepts file edits, minimal prompts) @@ -22,6 +23,8 @@ export interface ProjectLocalConfig { permissionMode?: PermissionMode; /** Verbose output mode */ verbose?: boolean; + /** Provider-specific options (overrides global, overridden by piece/movement) */ + provider_options?: MovementProviderOptions; /** Piece categories (name -> piece list) */ piece_categories?: Record; /** Show uncategorized pieces under Others category */ diff --git a/src/infra/providers/claude.ts b/src/infra/providers/claude.ts index a47702f..962afd5 100644 --- a/src/infra/providers/claude.ts +++ b/src/infra/providers/claude.ts @@ -9,6 +9,7 @@ import type { AgentResponse } from '../../core/models/index.js'; import type { AgentSetup, Provider, ProviderAgent, ProviderCallOptions } from './types.js'; function toClaudeOptions(options: ProviderCallOptions): ClaudeCallOptions { + const claudeSandbox = options.providerOptions?.claude?.sandbox; return { cwd: options.cwd, abortSignal: options.abortSignal, @@ -24,6 +25,10 @@ function toClaudeOptions(options: ProviderCallOptions): ClaudeCallOptions { bypassPermissions: options.bypassPermissions, anthropicApiKey: options.anthropicApiKey ?? resolveAnthropicApiKey(), outputSchema: options.outputSchema, + sandbox: claudeSandbox ? { + allowUnsandboxedCommands: claudeSandbox.allowUnsandboxedCommands, + excludedCommands: claudeSandbox.excludedCommands, + } : undefined, }; } diff --git a/src/infra/providers/types.ts b/src/infra/providers/types.ts index b47c2e8..9560cf2 100644 --- a/src/infra/providers/types.ts +++ b/src/infra/providers/types.ts @@ -3,7 +3,7 @@ */ import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../claude/index.js'; -import type { AgentResponse, PermissionMode, McpServerConfig } from '../../core/models/index.js'; +import type { AgentResponse, PermissionMode, McpServerConfig, MovementProviderOptions } from '../../core/models/index.js'; /** Agent setup configuration — determines HOW the provider invokes the agent */ export interface AgentSetup { @@ -31,10 +31,7 @@ export interface ProviderCallOptions { /** Permission mode for tool execution (from piece step) */ permissionMode?: PermissionMode; /** Provider-specific movement options */ - providerOptions?: { - codex?: { networkAccess?: boolean }; - opencode?: { networkAccess?: boolean }; - }; + providerOptions?: MovementProviderOptions; onStream?: StreamCallback; onPermissionRequest?: PermissionHandler; onAskUserQuestion?: AskUserQuestionHandler;