Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions config/copilot.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,19 @@
|
*/
'model' => env('COPILOT_MODEL'),

/*
|--------------------------------------------------------------------------
| Auto-approve Permission Requests
|--------------------------------------------------------------------------
|
| When true, all permission requests (file write, shell command, etc.)
| are automatically approved when using the Copilot facade (CopilotManager).
| Set to false to require explicit permission handling like the official SDK.
|
| Note: This only applies when using Copilot::run(), Copilot::start(), etc.
| Direct Client usage always requires an explicit onPermissionRequest handler.
|
*/
'permission_approve' => env('COPILOT_PERMISSION_APPROVE', true),
];
48 changes: 45 additions & 3 deletions docs/jp/permission-request.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,31 @@
# Permission Request

## Deny by Default
## Auto-approve (デフォルト)

ツールの操作(ファイル書き込み、シェルコマンド、URL取得、MCP呼び出しなど)はデフォルトで**拒否**される。許可するには `onPermissionRequest` ハンドラを指定する必要がある。
`config/copilot.php`で`permission_approve`が`true`(デフォルト)の場合、`Copilot::run()`や`Copilot::start()`を使う時は自動的にすべてのPermission Requestが許可される。

```php
// config/copilot.php
'permission_approve' => env('COPILOT_PERMISSION_APPROVE', true),
```

この設定が有効な場合、`onPermissionRequest`を明示的に指定しなくても`PermissionHandler::approveAll()`が自動的に使われる。

```php
use Revolution\Copilot\Facades\Copilot;

// onPermissionRequestを指定しなくても自動的に全許可
$response = Copilot::run(prompt: 'Hello');
```

公式SDKと同様にデフォルトで拒否したい場合は`false`に設定する。

```php
// config/copilot.php
'permission_approve' => false,
```

この場合は`onPermissionRequest`の指定が必須になる。

## PermissionHandler::approveAll()

Expand All @@ -20,6 +43,25 @@ $config = new SessionConfig(
$response = Copilot::run(prompt: 'Hello', config: $config);
```

## Clientの直接使用

`CopilotClient`を直接使用する場合は、公式SDK同様に`onPermissionRequest`の指定が**必須**。

```php
use Revolution\Copilot\Support\PermissionHandler;

$client = app(CopilotClient::class);
$client->start();

// onPermissionRequestが必須
$session = $client->createSession([
'onPermissionRequest' => PermissionHandler::approveAll(),
]);

// 指定しないとInvalidArgumentExceptionがスローされる
// $session = $client->createSession([]); // Error!
```

## カスタムハンドラ

リクエストの種類に応じて個別に許可・拒否を制御する場合は、クロージャを指定する。`$request`と`$invocation`は下記のような内容の配列。
Expand Down Expand Up @@ -70,7 +112,7 @@ Artisan::command('copilot:chat', function () {

`kind`と`toolCallId`以外はkindによって内容が異なる。
```
kind: "shell" | "write" | "mcp" | "read" | "url"
kind: "shell" | "write" | "mcp" | "read" | "url" | "custom-tool"
```

```php
Expand Down
32 changes: 20 additions & 12 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -224,16 +224,23 @@ public function isTcpMode(): bool
/**
* Create a new conversation session.
*
* @param SessionConfig|array{session_id?: string, model?: string, tools?: array, system_message?: array, available_tools?: array, excluded_tools?: array, provider?: array, on_permission_request?: callable, on_user_input_request?: callable, hooks?: array, working_directory?: string, streaming?: bool, mcp_servers?: array, custom_agents?: array, config_dir?: string, skill_directories?: array, disabled_skills?: array} $config
* @param SessionConfig|array{session_id?: string, model?: string, tools?: array, system_message?: array, available_tools?: array, excluded_tools?: array, provider?: array, on_permission_request: callable, on_user_input_request?: callable, hooks?: array, working_directory?: string, streaming?: bool, mcp_servers?: array, custom_agents?: array, config_dir?: string, skill_directories?: array, disabled_skills?: array} $config
*
* @throws JsonRpcException
*/
public function createSession(SessionConfig|array $config = []): CopilotSession
public function createSession(SessionConfig|array $config): CopilotSession
{
$this->ensureConnected();

$config = is_array($config) ? $config : $config->toArray();

if (! isset($config['onPermissionRequest']) || ! is_callable($config['onPermissionRequest'])) {
throw new \InvalidArgumentException(
'An onPermissionRequest handler is required when creating a session. '
.'For example, to allow all permissions, use new SessionConfig(onPermissionRequest: PermissionHandler::approveAll()).',
);
}

$tools = $config['tools'] ?? [];
$toolsForRequest = array_map(fn ($tool) => [
'name' => $tool['name'],
Expand Down Expand Up @@ -278,10 +285,7 @@ public function createSession(SessionConfig|array $config = []): CopilotSession
'workspacePath' => $workspacePath,
]);
$session->registerTools($tools);

if (isset($config['onPermissionRequest']) && is_callable($config['onPermissionRequest'])) {
$session->registerPermissionHandler($config['onPermissionRequest']);
}
$session->registerPermissionHandler($config['onPermissionRequest']);

if (isset($config['onUserInputRequest']) && is_callable($config['onUserInputRequest'])) {
$session->registerUserInputHandler($config['onUserInputRequest']);
Expand All @@ -301,16 +305,23 @@ public function createSession(SessionConfig|array $config = []): CopilotSession
/**
* Resume an existing session.
*
* @param ResumeSessionConfig|array{tools?: array, provider?: array, on_permission_request?: callable, on_user_input_request?: callable, hooks?: array, working_directory?: string, streaming?: bool, mcp_servers?: array, custom_agents?: array, skill_directories?: array, disabled_skills?: array} $config
* @param ResumeSessionConfig|array{tools?: array, provider?: array, on_permission_request: callable, on_user_input_request?: callable, hooks?: array, working_directory?: string, streaming?: bool, mcp_servers?: array, custom_agents?: array, skill_directories?: array, disabled_skills?: array} $config
*
* @throws JsonRpcException
*/
public function resumeSession(string $sessionId, ResumeSessionConfig|array $config = []): CopilotSession
public function resumeSession(string $sessionId, ResumeSessionConfig|array $config): CopilotSession
{
$this->ensureConnected();

$config = is_array($config) ? $config : $config->toArray();

if (! isset($config['onPermissionRequest']) || ! is_callable($config['onPermissionRequest'])) {
throw new \InvalidArgumentException(
'An onPermissionRequest handler is required when resuming a session. '
.'For example, to allow all permissions, use new ResumeSessionConfig(onPermissionRequest: PermissionHandler::approveAll()).',
);
}

$tools = $config['tools'] ?? [];
$toolsForRequest = array_map(fn ($tool) => [
'name' => $tool['name'],
Expand Down Expand Up @@ -350,10 +361,7 @@ public function resumeSession(string $sessionId, ResumeSessionConfig|array $conf
'workspacePath' => $workspacePath,
]);
$session->registerTools($tools);

if (isset($config['onPermissionRequest']) && is_callable($config['onPermissionRequest'])) {
$session->registerPermissionHandler($config['onPermissionRequest']);
}
$session->registerPermissionHandler($config['onPermissionRequest']);

if (isset($config['onUserInputRequest']) && is_callable($config['onUserInputRequest'])) {
$session->registerUserInputHandler($config['onUserInputRequest']);
Expand Down
8 changes: 6 additions & 2 deletions src/Contracts/CopilotClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,20 @@ public function start(): static;
/**
* Create a new conversation session.
*
* An onPermissionRequest handler is required in the config.
*
* @throws JsonRpcException
*/
public function createSession(SessionConfig|array $config = []): CopilotSession;
public function createSession(SessionConfig|array $config): CopilotSession;

/**
* Resume an existing session.
*
* An onPermissionRequest handler is required in the config.
*
* @throws JsonRpcException
*/
public function resumeSession(string $sessionId, ResumeSessionConfig|array $config = []): CopilotSession;
public function resumeSession(string $sessionId, ResumeSessionConfig|array $config): CopilotSession;

/**
* Send a ping to verify connectivity.
Expand Down
24 changes: 24 additions & 0 deletions src/CopilotManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Revolution\Copilot\Contracts\CopilotClient;
use Revolution\Copilot\Contracts\CopilotSession;
use Revolution\Copilot\Contracts\Factory;
use Revolution\Copilot\Support\PermissionHandler;
use Revolution\Copilot\Testing\WithFake;
use Revolution\Copilot\Types\ResumeSessionConfig;
use Revolution\Copilot\Types\SessionConfig;
Expand Down Expand Up @@ -117,6 +118,8 @@ protected function prepareSession(SessionConfig|ResumeSessionConfig|array $confi

$config = is_array($config) ? $config : $config->toArray();

$config = $this->ensurePermissionHandler($config);

if (empty($resume)) {
if (is_array($config)) {
$config = SessionConfig::fromArray(array_merge(
Expand Down Expand Up @@ -147,6 +150,10 @@ public function createSession(SessionConfig|array $config = []): CopilotSession
return $this->fake->createSession($config);
}

$config = is_array($config) ? $config : $config->toArray();

$config = $this->ensurePermissionHandler($config);

if (is_array($config)) {
$config = SessionConfig::fromArray(array_merge(
['model' => $this->config['model'] ?? null],
Expand All @@ -157,6 +164,23 @@ public function createSession(SessionConfig|array $config = []): CopilotSession
return $this->client()->createSession($config);
}

/**
* Ensure the config has an onPermissionRequest handler.
*
* If no handler is provided and the `permission_approve` config is true,
* automatically injects PermissionHandler::approveAll().
*/
protected function ensurePermissionHandler(array $config): array
{
if (! isset($config['onPermissionRequest'])) {
if ($this->config['permission_approve'] ?? config('copilot.permission_approve', true)) {
$config['onPermissionRequest'] = PermissionHandler::approveAll();
}
}

return $config;
}

/**
* Get or create the CopilotClient instance.
*
Expand Down
80 changes: 76 additions & 4 deletions tests/Feature/ClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Revolution\Copilot\JsonRpc\JsonRpcClient;
use Revolution\Copilot\Process\ProcessManager;
use Revolution\Copilot\Session;
use Revolution\Copilot\Support\PermissionHandler;
use Revolution\Copilot\Transport\StdioTransport;

beforeEach(function () {
Expand Down Expand Up @@ -193,14 +194,17 @@

$mockSession = Mockery::mock(Session::class);
$mockSession->shouldReceive('registerTools')->once()->with([]);
$mockSession->shouldReceive('registerPermissionHandler')->once();

$this->app->bind(ProcessManager::class, fn () => $mockProcessManager);
$this->app->bind(JsonRpcClient::class, fn () => $mockRpcClient);
$this->app->bind(Session::class, fn () => $mockSession);

$client = new Client;
$client->start();
$session = $client->createSession();
$session = $client->createSession([
'onPermissionRequest' => PermissionHandler::approveAll(),
]);

expect($session)->toBe($mockSession);

Expand All @@ -216,8 +220,73 @@

$client = new Client;

expect(fn () => $client->createSession())
->toThrow(RuntimeException::class, 'Client not connected');
expect(fn () => $client->createSession([
'onPermissionRequest' => PermissionHandler::approveAll(),
]))->toThrow(RuntimeException::class, 'Client not connected');
});

it('throws when createSession called without onPermissionRequest', function () {
$stdin = fopen('php://memory', 'r+');
$stdout = fopen('php://memory', 'r+');

$mockStdioTransport = Mockery::mock(StdioTransport::class);

$mockProcessManager = Mockery::mock(ProcessManager::class);
$mockProcessManager->shouldReceive('start')->once();
$mockProcessManager->shouldReceive('getStdioTransport')->andReturn($mockStdioTransport);

$mockRpcClient = Mockery::mock(JsonRpcClient::class);
$mockRpcClient->shouldReceive('start')->once();
$mockRpcClient->shouldReceive('setNotificationHandler')->once();
$mockRpcClient->shouldReceive('setRequestHandler')->times(4);
$mockRpcClient->shouldReceive('request')
->with('status.get')
->once()
->andReturn(['version' => '', 'protocolVersion' => 2]);

$this->app->bind(ProcessManager::class, fn () => $mockProcessManager);
$this->app->bind(JsonRpcClient::class, fn () => $mockRpcClient);

$client = new Client;
$client->start();

expect(fn () => $client->createSession([]))
->toThrow(\InvalidArgumentException::class, 'onPermissionRequest handler is required');

fclose($stdin);
fclose($stdout);
});

it('throws when resumeSession called without onPermissionRequest', function () {
$stdin = fopen('php://memory', 'r+');
$stdout = fopen('php://memory', 'r+');

$mockStdioTransport = Mockery::mock(StdioTransport::class);

$mockProcessManager = Mockery::mock(ProcessManager::class);
$mockProcessManager->shouldReceive('start')->once();
$mockProcessManager->shouldReceive('getStdioTransport')->andReturn($mockStdioTransport);

$mockRpcClient = Mockery::mock(JsonRpcClient::class);
$mockRpcClient->shouldReceive('start')->once();
$mockRpcClient->shouldReceive('setNotificationHandler')->once();
$mockRpcClient->shouldReceive('setRequestHandler')->times(4);
$mockRpcClient->shouldReceive('request')
->with('status.get')
->once()
->andReturn(['version' => '', 'protocolVersion' => 2]);

$this->app->bind(ProcessManager::class, fn () => $mockProcessManager);
$this->app->bind(JsonRpcClient::class, fn () => $mockRpcClient);

$client = new Client;
$client->start();

expect(fn () => $client->resumeSession('test-session-123', []))
->toThrow(\InvalidArgumentException::class, 'onPermissionRequest handler is required');

fclose($stdin);
fclose($stdout);
});

it('resumeSession resume and returns session', function () {
Expand Down Expand Up @@ -247,14 +316,17 @@

$mockSession = Mockery::mock(Session::class);
$mockSession->shouldReceive('registerTools')->once()->with([]);
$mockSession->shouldReceive('registerPermissionHandler')->once();

$this->app->bind(ProcessManager::class, fn () => $mockProcessManager);
$this->app->bind(JsonRpcClient::class, fn () => $mockRpcClient);
$this->app->bind(Session::class, fn () => $mockSession);

$client = new Client;
$client->start();
$session = $client->resumeSession('test-session-123');
$session = $client->resumeSession('test-session-123', [
'onPermissionRequest' => PermissionHandler::approveAll(),
]);

expect($session)->toBe($mockSession);

Expand Down