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
30 changes: 30 additions & 0 deletions src/Exec/AuditMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace Claw\Exec;

use Claw\Agent\ToolResultBlock;
use Claw\Store\SessionStore;
use Claw\Tool\ToolCall;

/**
* Records every tool call — including blocked and denied ones — to the audit log.
* Sits outermost in the chain so it captures the verdict produced by the inner
* stages. A no-op when no store is configured (e.g. the in-memory tests).
*/
final readonly class AuditMiddleware implements MiddlewareInterface
{
public function __construct(private ?SessionStore $store = null)
{
}

public function handle(ToolCall $call, callable $next): ToolResultBlock
{
$result = $next($call);

$this->store?->logToolCall($call->summary(), $result->content, $result->isError);

return $result;
}
}
43 changes: 43 additions & 0 deletions src/Exec/ChainExecutor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace Claw\Exec;

use Claw\Agent\ToolResultBlock;
use Claw\Tool\ToolCall;

/**
* Composes the middleware onion around a terminal handler. call() wraps the
* middlewares outer-to-inner; each may short-circuit (skip $next) or pass through
* to the terminal, which resolves and runs the tool. Adding behaviour = adding a
* middleware, not editing the loop.
*/
final class ChainExecutor implements ExecutorInterface
{
/** @var list<MiddlewareInterface> */
private readonly array $middlewares;

/** @var \Closure(ToolCall): ToolResultBlock */
private readonly \Closure $terminal;

/**
* @param list<MiddlewareInterface> $middlewares outer first
* @param \Closure(ToolCall): ToolResultBlock $terminal resolves and runs the tool
*/
public function __construct(array $middlewares, \Closure $terminal)
{
$this->middlewares = $middlewares;
$this->terminal = $terminal;
}

public function call(ToolCall $call): ToolResultBlock
{
$next = $this->terminal;
foreach (array_reverse($this->middlewares) as $middleware) {
$next = static fn (ToolCall $c): ToolResultBlock => $middleware->handle($c, $next);
}

return $next($call);
}
}
17 changes: 17 additions & 0 deletions src/Exec/ExecutorInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Claw\Exec;

use Claw\Agent\ToolResultBlock;
use Claw\Tool\ToolCall;

/**
* One transparent entry point for running a tool. The turn loop never touches a
* tool directly — everything (security, audit, …) is a middleware behind this.
*/
interface ExecutorInterface
{
public function call(ToolCall $call): ToolResultBlock;
}
21 changes: 21 additions & 0 deletions src/Exec/MiddlewareInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Claw\Exec;

use Claw\Agent\ToolResultBlock;
use Claw\Tool\ToolCall;

/**
* One stage of the executor onion. Wrap $next: inspect, short-circuit (skip it),
* modify, time, or log. The whole chain is await-able, so a stage may suspend
* (e.g. Permission awaiting the user) without blocking the thread.
*/
interface MiddlewareInterface
{
/**
* @param callable(ToolCall): ToolResultBlock $next
*/
public function handle(ToolCall $call, callable $next): ToolResultBlock;
}
73 changes: 73 additions & 0 deletions src/Exec/PermissionMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

declare(strict_types=1);

namespace Claw\Exec;

use Claw\Agent\ToolResultBlock;
use Claw\Chat\Approval;
use Claw\Chat\ConversationInterface;
use Claw\Exceptions\ToolException;
use Claw\Permission\Decision;
use Claw\Permission\Policy;
use Claw\Store\SessionStore;
use Claw\Tool\Registry;
use Claw\Tool\ToolCall;

/**
* The security layer, as a middleware. Resolves the call's tool to read its Risk,
* asks the Policy for a verdict, then blocks, asks the user (Confirm), or passes
* through. An "always" approval is persisted as a rule so we never ask again.
*/
final readonly class PermissionMiddleware implements MiddlewareInterface
{
public function __construct(
private Policy $policy,
private Registry $tools,
private ConversationInterface $conversation,
private ?SessionStore $store = null,
) {
}

public function handle(ToolCall $call, callable $next): ToolResultBlock
{
try {
$tool = $this->tools->get($call->name);
} catch (ToolException) {
return $next($call); // unknown tool — let the terminal report it
}

$verdict = $this->policy->check($tool, $call->input);

if ($verdict->decision === Decision::Deny) {
return new ToolResultBlock($call->id, 'blocked: ' . $verdict->reason, true);
}

if ($verdict->decision === Decision::Confirm && !$this->approved($call)) {
return new ToolResultBlock($call->id, 'denied by the user', true);
}

return $next($call);
}

/** A saved "always" rule skips the prompt; otherwise ask, and remember "always". */
private function approved(ToolCall $call): bool
{
if ($this->store !== null && $this->store->isToolAllowed($call->name)) {
return true;
}

return match ($this->conversation->confirm('Allow ' . $call->summary() . '?')) {
Approval::No => false,
Approval::Once => true,
Approval::Always => $this->remember($call->name),
};
}

private function remember(string $tool): bool
{
$this->store?->allowTool($tool);

return true;
}
}
80 changes: 25 additions & 55 deletions src/Session.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
use Claw\Agent\ToolResultBlock;
use Claw\Agent\ToolSpec;
use Claw\Agent\ToolUseBlock;
use Claw\Chat\Approval;
use Claw\Chat\ConversationInterface;
use Claw\Chat\Status;
use Claw\Exceptions\AgentException;
Expand All @@ -20,10 +19,14 @@
use Claw\Exceptions\QuotaExceededException;
use Claw\Exceptions\RateLimitException;
use Claw\Exceptions\ToolException;
use Claw\Permission\Decision;
use Claw\Exec\AuditMiddleware;
use Claw\Exec\ChainExecutor;
use Claw\Exec\ExecutorInterface;
use Claw\Exec\PermissionMiddleware;
use Claw\Permission\Policy;
use Claw\Store\SessionStore;
use Claw\Tool\Registry;
use Claw\Tool\ToolCall;
use Claw\Tool\ToolInterface;

/**
Expand All @@ -45,6 +48,9 @@ final class Session
*/
private readonly array $specs;

/** Runs each tool call through the security/audit middleware chain. */
private readonly ExecutorInterface $executor;

public function __construct(
private readonly ConversationInterface $conversation,
private readonly AgentInterface $agent,
Expand All @@ -56,6 +62,16 @@ public function __construct(
private readonly ?SessionStore $store = null,
) {
$this->specs = $this->buildSpecs();

// Tool execution funnels through one chain: audit logs every call (even
// denials), permission gates it, and the terminal stage runs the tool.
$this->executor = new ChainExecutor(
middlewares: [
new AuditMiddleware($this->store),
new PermissionMiddleware($this->policy, $this->tools, $this->conversation, $this->store),
],
terminal: $this->runTool(...),
);
}

/** Drive the conversation: each message is one task. Ends when it closes. */
Expand Down Expand Up @@ -171,63 +187,17 @@ private function persist(): void

private function execute(ToolUseBlock $call): ToolResultBlock
{
// The gatekeeper runs before the tool: a hard rule blocks it outright, a
// Mutating tool asks the user, Safe ones run straight through. A refusal
// (or block) is returned to the model as an error tool_result, so the
// agent simply continues without having done the action.
try {
$tool = $this->tools->get($call->name);

$verdict = $this->policy->check($tool, $call->input);
if ($verdict->decision === Decision::Deny) {
return new ToolResultBlock($call->id, 'blocked: ' . $verdict->reason, true);
}

if ($verdict->decision === Decision::Confirm && !$this->approved($call)) {
return new ToolResultBlock($call->id, 'denied by the user', true);
}

return new ToolResultBlock($call->id, $tool->handle($call->input), false);
} catch (ToolException $e) {
return new ToolResultBlock($call->id, $e->getMessage(), true);
}
return $this->executor->call(new ToolCall($call->id, $call->name, $call->input));
}

/**
* Decide a Confirm: a saved "always" rule skips the prompt; otherwise ask the
* user. An "always" answer is remembered so we never ask for that tool again.
*/
private function approved(ToolUseBlock $call): bool
/** Terminal stage of the executor: resolve the tool and run it; failures become error results. */
private function runTool(ToolCall $call): ToolResultBlock
{
if ($this->store !== null && $this->store->isToolAllowed($call->name)) {
return true;
}

return match ($this->conversation->confirm($this->confirmPrompt($call))) {
Approval::No => false,
Approval::Once => true,
Approval::Always => $this->remember($call->name),
};
}

/** Persist an "always allow" rule (if a store is configured) and allow the call. */
private function remember(string $tool): bool
{
$this->store?->allowTool($tool);

return true;
}

/** A short, human-readable summary of a tool call for the approval prompt. */
private function confirmPrompt(ToolUseBlock $call): string
{
$detail = '';
if ($call->input !== []) {
$encoded = json_encode($call->input, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$detail = $encoded === false ? '' : ' ' . $encoded;
try {
return new ToolResultBlock($call->id, $this->tools->get($call->name)->handle($call->input), false);
} catch (ToolException $e) {
return new ToolResultBlock($call->id, $e->getMessage(), true);
}

return "Allow `{$call->name}`{$detail}?";
}

/**
Expand Down
45 changes: 45 additions & 0 deletions src/Store/SessionStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ public function __construct(string $path)
);
// Persisted "always allow" rules: a row means the tool runs without asking.
$this->pdo->exec('CREATE TABLE IF NOT EXISTS rules (name TEXT PRIMARY KEY)');
// Audit log: one row per tool call (including blocked/denied ones).
$this->pdo->exec(
'CREATE TABLE IF NOT EXISTS audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
call TEXT NOT NULL,
is_error INTEGER NOT NULL,
result TEXT NOT NULL
)',
);
} catch (\PDOException $e) {
throw new ClawException('SessionStore: cannot open ' . $path . ': ' . $e->getMessage(), 0, $e);
}
Expand Down Expand Up @@ -99,6 +108,42 @@ public function allowTool(string $tool): void
$stmt->execute(['name' => $tool]);
}

/** Record one tool call (its summary), the outcome flag, and the result text. */
public function logToolCall(string $call, string $result, bool $isError): void
{
$stmt = $this->pdo->prepare('INSERT INTO audit (call, is_error, result) VALUES (:call, :err, :result)');
if ($stmt === false) {
throw new ClawException('SessionStore: failed to prepare audit insert');
}

$stmt->execute(['call' => $call, 'err' => $isError ? 1 : 0, 'result' => $result]);
}

/**
* The full audit trail, oldest first.
*
* @return list<array{call: string, isError: bool, result: string}>
*/
public function auditTrail(): array
{
$stmt = $this->pdo->query('SELECT call, is_error, result FROM audit ORDER BY id');
if ($stmt === false) {
return [];
}

/** @var list<array{call: string, is_error: int, result: string}> $rows */
$rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);

return array_map(
static fn (array $row): array => [
'call' => $row['call'],
'isError' => $row['is_error'] !== 0,
'result' => $row['result'],
],
$rows,
);
}

/**
* @param list<ContentBlockInterface> $content
*/
Expand Down
Loading
Loading