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
16 changes: 15 additions & 1 deletion bin/claw
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use Claw\Exceptions\ClawException;
use Claw\Http\CurlHttpClient;
use Claw\Http\HttpClientInterface;
use Claw\Session;
use Claw\Store\SessionStore;
use Claw\Tool\BashTool;
use Claw\Tool\DateTool;
use Claw\Tool\ListFilesTool;
Expand Down Expand Up @@ -73,7 +74,20 @@ if ($chat === null) {
exit(1);
}

new Session($chat->accept(), $agent, $tools, $system, $config->model, $config->maxHistory)->run();
// One SQLite file per conversation, so history survives restarts. The console is
// a single conversation; the Telegram gateway will open one file per chat_id.
$sessionsDir = $workspaceDir . '/sessions';
if (!is_dir($sessionsDir)) {
mkdir($sessionsDir, 0o775, true);
}
try {
$store = new SessionStore($sessionsDir . '/console.db');
} catch (ClawException $e) {
fwrite(STDERR, 'Store error: ' . $e->getMessage() . "\n");
exit(1);
}

new Session($chat->accept(), $agent, $tools, $system, $config->model, $config->maxHistory, store: $store)->run();

function makeAgent(Config $config, HttpClientInterface $http): ?AgentInterface
{
Expand Down
29 changes: 29 additions & 0 deletions src/Chat/Approval.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Claw\Chat;

/**
* The human's answer to a confirm() prompt:
*
* Once — allow this one time.
* Always — allow and remember it (the permission layer persists a rule).
* No — refuse.
*/
enum Approval
{
case Once;
case Always;
case No;

/** Map a typed line ("y" / "a" / anything else) to an answer. */
public static function fromInput(string $line): self
{
return match (strtolower(trim($line))) {
'y', 'yes' => self::Once,
'a', 'always' => self::Always,
default => self::No,
};
}
}
19 changes: 19 additions & 0 deletions src/Chat/AsyncConsoleConversation.php
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,25 @@ public function send(string $text): void
$this->appendChat($colored ?? $msg);
}

public function confirm(string $prompt): Approval
{
// The agent is mid-turn, so Session::run() is not consuming the inbox —
// the next line the background reader queues is the user's answer.
$this->cancelSpinner();
$this->writeStatus('');
$this->appendChat(self::C_SPIN . '⚠ ' . $prompt . ' [y = once / a = always / N = no]' . self::C_RESET . "\n");

while ($this->inbox === [] && !$this->eof) {
delay(50);
}

if ($this->inbox === []) {
return Approval::No; // EOF — treat as refusal
}

return Approval::fromInput((string) array_shift($this->inbox));
}

public function updateStatus(?Status $status): void
{
$this->cancelSpinner();
Expand Down
10 changes: 10 additions & 0 deletions src/Chat/ConsoleConversation.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ public function send(string $text): void
fwrite($this->output, 'Claw: ' . $text . "\n");
}

public function confirm(string $prompt): Approval
{
$this->clearStatus();
fwrite($this->output, $prompt . ' [y = once / a = always / N = no] ');

$line = fgets($this->input);

return $line === false ? Approval::No : Approval::fromInput($line);
}

public function updateStatus(?Status $status): void
{
if ($status === null) {
Expand Down
7 changes: 7 additions & 0 deletions src/Chat/ConversationInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ public function receive(): ?string;

public function send(string $text): void;

/**
* Ask the human to approve an action. May await. Used by the permission
* layer before running a Mutating tool. A closed conversation (EOF) is
* treated as a refusal (Approval::No).
*/
public function confirm(string $prompt): Approval;

/**
* Show a transient status line (typing indicator, tool call, token usage).
* Pass null to clear it. The status must never interleave with send() output —
Expand Down
19 changes: 19 additions & 0 deletions src/Permission/Decision.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace Claw\Permission;

/**
* What the permission layer decided about a tool call.
*
* Allow — run it, no questions.
* Confirm — ask the human first.
* Deny — never run it; tell the model why.
*/
enum Decision
{
case Allow;
case Confirm;
case Deny;
}
60 changes: 60 additions & 0 deletions src/Permission/Policy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

declare(strict_types=1);

namespace Claw\Permission;

use Claw\Tool\Risk;
use Claw\Tool\ToolInterface;

/**
* The gatekeeper: plain, deterministic code that decides whether a tool call may
* run — deliberately NOT an AI, so it can't be talked out of a rule by prompt
* injection. Ordered checks, first decisive wins:
*
* 1. Denylist — hard rules (rm -rf /, fork bombs, …) → always Deny.
* 2. Risk — Safe → Allow, Mutating → Confirm (ask the human), Dangerous → Deny.
*
* Persisted allow/deny rules and an audit trail are a later layer (SQLite).
*/
final class Policy
{
/** Hard-blocked command substrings (matched case-insensitively). */
private const array DENYLIST = [
'rm -rf /',
'rm -rf /*',
':(){', // fork bomb
'mkfs',
'dd if=',
'> /dev/sd',
];

/**
* @param array<string, mixed> $input
*/
public function check(ToolInterface $tool, array $input): Verdict
{
$command = isset($input['command']) && \is_string($input['command']) ? $input['command'] : '';
if ($command !== '' && $this->isDenied($command)) {
return Verdict::deny('command matches a hard-blocked pattern');
}

return match ($tool->risk()) {
Risk::Safe => Verdict::allow(),
Risk::Mutating => Verdict::confirm(),
Risk::Dangerous => Verdict::deny('dangerous tools are disabled'),
};
}

private function isDenied(string $command): bool
{
$haystack = strtolower($command);
foreach (self::DENYLIST as $needle) {
if (str_contains($haystack, strtolower($needle))) {
return true;
}
}

return false;
}
}
33 changes: 33 additions & 0 deletions src/Permission/Verdict.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Claw\Permission;

/**
* The permission layer's answer for one tool call: a Decision plus, for a Deny,
* a short reason the model can read in the tool_result.
*/
final readonly class Verdict
{
public function __construct(
public Decision $decision,
public string $reason = '',
) {
}

public static function allow(): self
{
return new self(Decision::Allow);
}

public static function confirm(): self
{
return new self(Decision::Confirm);
}

public static function deny(string $reason): self
{
return new self(Decision::Deny, $reason);
}
}
86 changes: 84 additions & 2 deletions src/Session.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
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 @@ -19,6 +20,9 @@
use Claw\Exceptions\QuotaExceededException;
use Claw\Exceptions\RateLimitException;
use Claw\Exceptions\ToolException;
use Claw\Permission\Decision;
use Claw\Permission\Policy;
use Claw\Store\SessionStore;
use Claw\Tool\Registry;
use Claw\Tool\ToolInterface;

Expand All @@ -31,6 +35,9 @@ final class Session
/** @var list<Message> */
private array $history = [];

/** How many history messages are already written to the store. */
private int $persisted = 0;

/**
* Tool specs are constant for the session — built once.
*
Expand All @@ -45,17 +52,27 @@ public function __construct(
private readonly string $system,
private readonly string $model,
private readonly int $maxHistory = 0,
private readonly Policy $policy = new Policy(),
private readonly ?SessionStore $store = null,
) {
$this->specs = $this->buildSpecs();
}

/** Drive the conversation: each message is one task. Ends when it closes. */
public function run(): void
{
// Resume a prior conversation: the stored history becomes the starting
// context, so the agent "remembers" across restarts.
if ($this->store !== null) {
$this->history = $this->store->load();
$this->persisted = \count($this->history);
}

while (($text = $this->conversation->receive()) !== null) {
// A failure ends this task, not the conversation. React by cause.
try {
$this->handle($text);
$this->persist();
} catch (ContextLengthException $e) {
$this->conversation->send('The conversation got too long for the model. Please start a new one.');
} catch (QuotaExceededException $e) {
Expand Down Expand Up @@ -138,16 +155,81 @@ private function handle(string $text): void
}
}

/** Write the messages added since the last save to the store (the new tail only). */
private function persist(): void
{
if ($this->store === null) {
return;
}

$new = \array_slice($this->history, $this->persisted);
if ($new !== []) {
$this->store->append(...$new);
$this->persisted = \count($this->history);
}
}

private function execute(ToolUseBlock $call): ToolResultBlock
{
// LATER: through the Executor + middleware chain (security, approvals, audit).
// 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 {
return new ToolResultBlock($call->id, $this->tools->get($call->name)->handle($call->input), false);
$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);
}
}

/**
* 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
{
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;
}

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

/**
* Build the tool specs advertised to the model (Tool -> Agent bridge).
*
Expand Down
Loading
Loading