feat: プロバイダー非依存の permission_mode 値を導入し sandboxMode を設定可能にする (#87)

- permission_mode を readonly/edit/full に統一(プロバイダー非依存)
- Claude SDK: readonly→default, edit→acceptEdits, full→bypassPermissions とマッピング
- Codex SDK: readonly→read-only, edit→workspace-write, full→danger-full-access とマッピング
- Legacy値(default/acceptEdits/bypassPermissions)のサポートを削除
- 全ビルトインワークフローを新しい permission_mode 値に更新
- AgentRunner の ProviderCallOptions 生成ロジックをリファクタリング(DRY化)
This commit is contained in:
nrslib 2026-02-03 00:59:16 +09:00
parent 18894e2587
commit 7377c5f9d9
19 changed files with 141 additions and 75 deletions

View File

@ -99,7 +99,7 @@ steps:
- Bash - Bash
- WebSearch - WebSearch
- WebFetch - WebFetch
permission_mode: acceptEdits permission_mode: edit
rules: rules:
- condition: Implementation complete - condition: Implementation complete
next: ai_review next: ai_review
@ -220,7 +220,7 @@ steps:
- Bash - Bash
- WebSearch - WebSearch
- WebFetch - WebFetch
permission_mode: acceptEdits permission_mode: edit
rules: rules:
- condition: AI issues fixed - condition: AI issues fixed
next: ai_review next: ai_review
@ -389,7 +389,7 @@ steps:
- Bash - Bash
- WebSearch - WebSearch
- WebFetch - WebFetch
permission_mode: acceptEdits permission_mode: edit
rules: rules:
- condition: Fix complete - condition: Fix complete
next: reviewers next: reviewers

View File

@ -502,7 +502,7 @@ steps:
- Bash - Bash
- WebSearch - WebSearch
- WebFetch - WebFetch
permission_mode: acceptEdits permission_mode: edit
rules: rules:
- condition: Fix complete - condition: Fix complete
next: reviewers next: reviewers

View File

@ -515,7 +515,7 @@ steps:
- Bash - Bash
- WebSearch - WebSearch
- WebFetch - WebFetch
permission_mode: acceptEdits permission_mode: edit
rules: rules:
- condition: Fix complete - condition: Fix complete
next: reviewers next: reviewers

View File

@ -95,7 +95,7 @@ steps:
- Bash - Bash
- WebSearch - WebSearch
- WebFetch - WebFetch
permission_mode: acceptEdits permission_mode: edit
rules: rules:
- condition: Implementation complete - condition: Implementation complete
next: ai_review next: ai_review

View File

@ -90,7 +90,7 @@ steps:
- Bash - Bash
- WebSearch - WebSearch
- WebFetch - WebFetch
permission_mode: acceptEdits permission_mode: edit
rules: rules:
- condition: 実装完了 - condition: 実装完了
next: ai_review next: ai_review
@ -215,7 +215,7 @@ steps:
- Bash - Bash
- WebSearch - WebSearch
- WebFetch - WebFetch
permission_mode: acceptEdits permission_mode: edit
pass_previous_response: true pass_previous_response: true
rules: rules:
- condition: AI問題の修正完了 - condition: AI問題の修正完了
@ -395,7 +395,7 @@ steps:
- Bash - Bash
- WebSearch - WebSearch
- WebFetch - WebFetch
permission_mode: acceptEdits permission_mode: edit
rules: rules:
- condition: 修正完了 - condition: 修正完了
next: reviewers next: reviewers

View File

@ -510,7 +510,7 @@ steps:
- Bash - Bash
- WebSearch - WebSearch
- WebFetch - WebFetch
permission_mode: acceptEdits permission_mode: edit
rules: rules:
- condition: 修正が完了した - condition: 修正が完了した
next: reviewers next: reviewers

View File

@ -501,7 +501,7 @@ steps:
- Bash - Bash
- WebSearch - WebSearch
- WebFetch - WebFetch
permission_mode: acceptEdits permission_mode: edit
rules: rules:
- condition: 修正が完了した - condition: 修正が完了した
next: reviewers next: reviewers

View File

@ -94,7 +94,7 @@ steps:
- Bash - Bash
- WebSearch - WebSearch
- WebFetch - WebFetch
permission_mode: acceptEdits permission_mode: edit
instruction_template: | instruction_template: |
planステップで立てた計画に従って実装してください。 planステップで立てた計画に従って実装してください。
計画レポート({report:00-plan.md})を参照し、実装を進めてください。 計画レポート({report:00-plan.md})を参照し、実装を進めてください。

View File

@ -43,14 +43,17 @@ describe('StatusSchema', () => {
describe('PermissionModeSchema', () => { describe('PermissionModeSchema', () => {
it('should accept valid permission modes', () => { it('should accept valid permission modes', () => {
expect(PermissionModeSchema.parse('default')).toBe('default'); expect(PermissionModeSchema.parse('readonly')).toBe('readonly');
expect(PermissionModeSchema.parse('acceptEdits')).toBe('acceptEdits'); expect(PermissionModeSchema.parse('edit')).toBe('edit');
expect(PermissionModeSchema.parse('bypassPermissions')).toBe('bypassPermissions'); expect(PermissionModeSchema.parse('full')).toBe('full');
}); });
it('should reject invalid permission modes', () => { it('should reject invalid permission modes', () => {
expect(() => PermissionModeSchema.parse('readOnly')).toThrow(); expect(() => PermissionModeSchema.parse('readOnly')).toThrow();
expect(() => PermissionModeSchema.parse('admin')).toThrow(); expect(() => PermissionModeSchema.parse('admin')).toThrow();
expect(() => PermissionModeSchema.parse('default')).toThrow();
expect(() => PermissionModeSchema.parse('acceptEdits')).toThrow();
expect(() => PermissionModeSchema.parse('bypassPermissions')).toThrow();
}); });
}); });
@ -87,7 +90,7 @@ describe('WorkflowConfigRawSchema', () => {
name: 'implement', name: 'implement',
agent: 'coder', agent: 'coder',
allowed_tools: ['Read', 'Edit', 'Write', 'Bash'], allowed_tools: ['Read', 'Edit', 'Write', 'Bash'],
permission_mode: 'acceptEdits', permission_mode: 'edit',
instruction: '{task}', instruction: '{task}',
rules: [ rules: [
{ condition: 'Done', next: 'COMPLETE' }, { condition: 'Done', next: 'COMPLETE' },
@ -97,7 +100,7 @@ describe('WorkflowConfigRawSchema', () => {
}; };
const result = WorkflowConfigRawSchema.parse(config); const result = WorkflowConfigRawSchema.parse(config);
expect(result.steps[0]?.permission_mode).toBe('acceptEdits'); expect(result.steps[0]?.permission_mode).toBe('edit');
}); });
it('should allow omitting permission_mode', () => { it('should allow omitting permission_mode', () => {

View File

@ -0,0 +1,54 @@
/**
* Tests for permission mode mapping functions
*/
import { describe, it, expect } from 'vitest';
import { SdkOptionsBuilder } from '../infra/claude/options-builder.js';
import { mapToCodexSandboxMode } from '../infra/codex/types.js';
import type { PermissionMode } from '../core/models/index.js';
describe('SdkOptionsBuilder.mapToSdkPermissionMode', () => {
it('should map readonly to SDK default', () => {
expect(SdkOptionsBuilder.mapToSdkPermissionMode('readonly')).toBe('default');
});
it('should map edit to SDK acceptEdits', () => {
expect(SdkOptionsBuilder.mapToSdkPermissionMode('edit')).toBe('acceptEdits');
});
it('should map full to SDK bypassPermissions', () => {
expect(SdkOptionsBuilder.mapToSdkPermissionMode('full')).toBe('bypassPermissions');
});
it('should map all PermissionMode values exhaustively', () => {
const modes: PermissionMode[] = ['readonly', 'edit', 'full'];
for (const mode of modes) {
const result = SdkOptionsBuilder.mapToSdkPermissionMode(mode);
expect(result).toBeDefined();
expect(typeof result).toBe('string');
}
});
});
describe('mapToCodexSandboxMode', () => {
it('should map readonly to read-only', () => {
expect(mapToCodexSandboxMode('readonly')).toBe('read-only');
});
it('should map edit to workspace-write', () => {
expect(mapToCodexSandboxMode('edit')).toBe('workspace-write');
});
it('should map full to danger-full-access', () => {
expect(mapToCodexSandboxMode('full')).toBe('danger-full-access');
});
it('should map all PermissionMode values exhaustively', () => {
const modes: PermissionMode[] = ['readonly', 'edit', 'full'];
for (const mode of modes) {
const result = mapToCodexSandboxMode(mode);
expect(result).toBeDefined();
expect(typeof result).toBe('string');
}
});
});

View File

@ -154,6 +154,26 @@ export class AgentRunner {
return provider.callCustom(agentConfig.name, task, systemPrompt, callOptions); return provider.callCustom(agentConfig.name, task, systemPrompt, callOptions);
} }
/** Build ProviderCallOptions from RunAgentOptions with optional systemPrompt override */
private static buildProviderCallOptions(
options: RunAgentOptions,
systemPrompt?: string,
): ProviderCallOptions {
return {
cwd: options.cwd,
sessionId: options.sessionId,
allowedTools: options.allowedTools,
maxTurns: options.maxTurns,
model: AgentRunner.resolveModel(options.cwd, options),
systemPrompt,
permissionMode: options.permissionMode,
onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions,
};
}
/** Run an agent by name, path, inline prompt string, or no agent at all */ /** Run an agent by name, path, inline prompt string, or no agent at all */
async run( async run(
agentSpec: string | undefined, agentSpec: string | undefined,
@ -174,25 +194,9 @@ export class AgentRunner {
// 1. If agentPath is provided (resolved file exists), load prompt from file // 1. If agentPath is provided (resolved file exists), load prompt from file
if (options.agentPath) { if (options.agentPath) {
const systemPrompt = AgentRunner.loadAgentPromptFromPath(options.agentPath); const systemPrompt = AgentRunner.loadAgentPromptFromPath(options.agentPath);
const providerType = AgentRunner.resolveProvider(options.cwd, options); const providerType = AgentRunner.resolveProvider(options.cwd, options);
const provider = getProvider(providerType); const provider = getProvider(providerType);
return provider.call(agentName, task, AgentRunner.buildProviderCallOptions(options, systemPrompt));
const callOptions: ProviderCallOptions = {
cwd: options.cwd,
sessionId: options.sessionId,
allowedTools: options.allowedTools,
maxTurns: options.maxTurns,
model: AgentRunner.resolveModel(options.cwd, options),
systemPrompt,
permissionMode: options.permissionMode,
onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions,
};
return provider.call(agentName, task, callOptions);
} }
// 2. If agentSpec is provided but no agentPath (file not found), try custom agent first, // 2. If agentSpec is provided but no agentPath (file not found), try custom agent first,
@ -207,42 +211,13 @@ export class AgentRunner {
// Use agentSpec string as inline system prompt // Use agentSpec string as inline system prompt
const providerType = AgentRunner.resolveProvider(options.cwd, options); const providerType = AgentRunner.resolveProvider(options.cwd, options);
const provider = getProvider(providerType); const provider = getProvider(providerType);
return provider.call(agentName, task, AgentRunner.buildProviderCallOptions(options, agentSpec));
const callOptions: ProviderCallOptions = {
cwd: options.cwd,
sessionId: options.sessionId,
allowedTools: options.allowedTools,
maxTurns: options.maxTurns,
model: AgentRunner.resolveModel(options.cwd, options),
systemPrompt: agentSpec,
permissionMode: options.permissionMode,
onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions,
};
return provider.call(agentName, task, callOptions);
} }
// 3. No agent specified — run with instruction_template only (no system prompt) // 3. No agent specified — run with instruction_template only (no system prompt)
const providerType = AgentRunner.resolveProvider(options.cwd, options); const providerType = AgentRunner.resolveProvider(options.cwd, options);
const provider = getProvider(providerType); const provider = getProvider(providerType);
return provider.call(agentName, task, AgentRunner.buildProviderCallOptions(options));
const callOptions: ProviderCallOptions = {
cwd: options.cwd,
sessionId: options.sessionId,
allowedTools: options.allowedTools,
maxTurns: options.maxTurns,
model: AgentRunner.resolveModel(options.cwd, options),
permissionMode: options.permissionMode,
onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions,
};
return provider.call(agentName, task, callOptions);
} }
} }

View File

@ -53,7 +53,7 @@ export const StatusSchema = z.enum([
]); ]);
/** Permission mode schema for tool execution */ /** Permission mode schema for tool execution */
export const PermissionModeSchema = z.enum(['default', 'acceptEdits', 'bypassPermissions']); export const PermissionModeSchema = z.enum(['readonly', 'edit', 'full']);
/** /**
* Report object schema (new structured format). * Report object schema (new structured format).

View File

@ -25,5 +25,5 @@ export type RuleMatchMethod =
| 'ai_judge' | 'ai_judge'
| 'ai_judge_fallback'; | 'ai_judge_fallback';
/** Permission mode for tool execution */ /** Permission mode for tool execution (provider-agnostic) */
export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions'; export type PermissionMode = 'readonly' | 'edit' | 'full';

View File

@ -14,8 +14,9 @@ import type {
HookInput, HookInput,
HookJSONOutput, HookJSONOutput,
PreToolUseHookInput, PreToolUseHookInput,
PermissionMode, PermissionMode as SdkPermissionMode,
} from '@anthropic-ai/claude-agent-sdk'; } from '@anthropic-ai/claude-agent-sdk';
import type { PermissionMode } from '../../core/models/index.js';
import { createLogger } from '../../shared/utils/index.js'; import { createLogger } from '../../shared/utils/index.js';
import type { import type {
PermissionHandler, PermissionHandler,
@ -86,13 +87,23 @@ export class SdkOptionsBuilder {
return sdkOptions; return sdkOptions;
} }
/** Map TAKT PermissionMode to Claude SDK PermissionMode */
static mapToSdkPermissionMode(mode: PermissionMode): SdkPermissionMode {
const mapping: Record<PermissionMode, SdkPermissionMode> = {
readonly: 'default',
edit: 'acceptEdits',
full: 'bypassPermissions',
};
return mapping[mode];
}
/** Resolve permission mode with priority: bypassPermissions > explicit > callback-based > default */ /** Resolve permission mode with priority: bypassPermissions > explicit > callback-based > default */
private resolvePermissionMode(): PermissionMode { private resolvePermissionMode(): SdkPermissionMode {
if (this.options.bypassPermissions) { if (this.options.bypassPermissions) {
return 'bypassPermissions'; return 'bypassPermissions';
} }
if (this.options.permissionMode) { if (this.options.permissionMode) {
return this.options.permissionMode; return SdkOptionsBuilder.mapToSdkPermissionMode(this.options.permissionMode);
} }
if (this.options.onPermissionRequest) { if (this.options.onPermissionRequest) {
return 'default'; return 'default';

View File

@ -152,8 +152,8 @@ export interface ClaudeSpawnOptions {
onStream?: StreamCallback; onStream?: StreamCallback;
/** Custom agents to register */ /** Custom agents to register */
agents?: Record<string, AgentDefinition>; agents?: Record<string, AgentDefinition>;
/** Permission mode for tool execution (default: 'default' for interactive) */ /** Permission mode for tool execution (TAKT abstract value, mapped to SDK value in SdkOptionsBuilder) */
permissionMode?: SdkPermissionMode; permissionMode?: PermissionMode;
/** Custom permission handler for interactive permission prompts */ /** Custom permission handler for interactive permission prompts */
onPermissionRequest?: PermissionHandler; onPermissionRequest?: PermissionHandler;
/** Custom handler for AskUserQuestion tool */ /** Custom handler for AskUserQuestion tool */

View File

@ -7,7 +7,7 @@
import { Codex } from '@openai/codex-sdk'; import { Codex } from '@openai/codex-sdk';
import type { AgentResponse } from '../../core/models/index.js'; import type { AgentResponse } from '../../core/models/index.js';
import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; import { createLogger, getErrorMessage } from '../../shared/utils/index.js';
import type { CodexCallOptions } from './types.js'; import { mapToCodexSandboxMode, type CodexCallOptions } from './types.js';
import { import {
type CodexEvent, type CodexEvent,
type CodexItem, type CodexItem,
@ -39,9 +39,13 @@ export class CodexClient {
options: CodexCallOptions, options: CodexCallOptions,
): Promise<AgentResponse> { ): Promise<AgentResponse> {
const codex = new Codex(options.openaiApiKey ? { apiKey: options.openaiApiKey } : undefined); const codex = new Codex(options.openaiApiKey ? { apiKey: options.openaiApiKey } : undefined);
const sandboxMode = options.permissionMode
? mapToCodexSandboxMode(options.permissionMode)
: 'workspace-write';
const threadOptions = { const threadOptions = {
model: options.model, model: options.model,
workingDirectory: options.cwd, workingDirectory: options.cwd,
sandboxMode,
}; };
const thread = options.sessionId const thread = options.sessionId
? await codex.resumeThread(options.sessionId, threadOptions) ? await codex.resumeThread(options.sessionId, threadOptions)

View File

@ -3,4 +3,5 @@
*/ */
export { CodexClient, callCodex, callCodexCustom } from './client.js'; export { CodexClient, callCodex, callCodexCustom } from './client.js';
export type { CodexCallOptions } from './types.js'; export { mapToCodexSandboxMode } from './types.js';
export type { CodexCallOptions, CodexSandboxMode } from './types.js';

View File

@ -3,6 +3,20 @@
*/ */
import type { StreamCallback } from '../claude/index.js'; import type { StreamCallback } from '../claude/index.js';
import type { PermissionMode } from '../../core/models/index.js';
/** Codex sandbox mode values */
export type CodexSandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access';
/** Map TAKT PermissionMode to Codex sandbox mode */
export function mapToCodexSandboxMode(mode: PermissionMode): CodexSandboxMode {
const mapping: Record<PermissionMode, CodexSandboxMode> = {
readonly: 'read-only',
edit: 'workspace-write',
full: 'danger-full-access',
};
return mapping[mode];
}
/** Options for calling Codex */ /** Options for calling Codex */
export interface CodexCallOptions { export interface CodexCallOptions {
@ -10,6 +24,8 @@ export interface CodexCallOptions {
sessionId?: string; sessionId?: string;
model?: string; model?: string;
systemPrompt?: string; systemPrompt?: string;
/** Permission mode for sandbox configuration */
permissionMode?: PermissionMode;
/** Enable streaming mode with callback (best-effort) */ /** Enable streaming mode with callback (best-effort) */
onStream?: StreamCallback; onStream?: StreamCallback;
/** OpenAI API key (bypasses CLI auth) */ /** OpenAI API key (bypasses CLI auth) */

View File

@ -15,6 +15,7 @@ export class CodexProvider implements Provider {
sessionId: options.sessionId, sessionId: options.sessionId,
model: options.model, model: options.model,
systemPrompt: options.systemPrompt, systemPrompt: options.systemPrompt,
permissionMode: options.permissionMode,
onStream: options.onStream, onStream: options.onStream,
openaiApiKey: options.openaiApiKey ?? resolveOpenaiApiKey(), openaiApiKey: options.openaiApiKey ?? resolveOpenaiApiKey(),
}; };
@ -27,6 +28,7 @@ export class CodexProvider implements Provider {
cwd: options.cwd, cwd: options.cwd,
sessionId: options.sessionId, sessionId: options.sessionId,
model: options.model, model: options.model,
permissionMode: options.permissionMode,
onStream: options.onStream, onStream: options.onStream,
openaiApiKey: options.openaiApiKey ?? resolveOpenaiApiKey(), openaiApiKey: options.openaiApiKey ?? resolveOpenaiApiKey(),
}; };