claude code がsandboxで実行されるため、テストが実行できない問題を対処できるオプションを追加

This commit is contained in:
nrslib 2026-02-13 21:46:11 +09:00
parent 652630eeca
commit c85f23cb6e
17 changed files with 371 additions and 35 deletions

168
docs/provider-sandbox.md Normal file
View File

@ -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.

View File

@ -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();
});
});

View File

@ -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,

View File

@ -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;

View File

@ -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<string, 'claude' | 'codex' | 'opencode' | 'mock'>;
/** 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;
}

View File

@ -14,6 +14,7 @@ export type {
PartResult,
TeamLeaderConfig,
PieceRule,
MovementProviderOptions,
PieceMovement,
ArpeggioMovementConfig,
ArpeggioMergeMovementConfig,

View File

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

View File

@ -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,
});

View File

@ -37,6 +37,7 @@ export type {
OutputContractItem,
OutputContractEntry,
McpServerConfig,
MovementProviderOptions,
PieceMovement,
ArpeggioMovementConfig,
ArpeggioMergeMovementConfig,

View File

@ -52,6 +52,7 @@ export class ClaudeClient {
bypassPermissions: options.bypassPermissions,
anthropicApiKey: options.anthropicApiKey,
outputSchema: options.outputSchema,
sandbox: options.sandbox,
};
}

View File

@ -95,6 +95,10 @@ export class SdkOptionsBuilder {
sdkOptions.stderr = this.options.onStderr;
}
if (this.options.sandbox) {
sdkOptions.sandbox = this.options.sandbox;
}
return sdkOptions;
}

View File

@ -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<string, unknown>;
/** 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<string, unknown>;
/** Callback for stderr output from the Claude Code process */
onStderr?: (data: string) => void;
/** Sandbox settings for Claude SDK */
sandbox?: SandboxSettings;
}

View File

@ -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,

View File

@ -24,34 +24,61 @@ import {
type RawStep = z.output<typeof PieceMovementRawSchema>;
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). */

View File

@ -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<string, PieceCategoryConfigNode>;
/** Show uncategorized pieces under Others category */

View File

@ -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,
};
}

View File

@ -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;