38 Commits

Author SHA1 Message Date
Yeachan-Heo
a9efc734d5 Preserve plugin and hook semantics while finishing the hook-pipeline merge
The merge now keeps plugin lifecycle management, plugin tool permissions,
hook abort/progress handling, permission-rule config, and shared slash-command
help aligned across runtime and CLI codepaths.

Constraint: Merge had to retain both plugin runtime behavior and hook-pipeline permission/abort features
Rejected: Drop plugin-aware runtime paths during merge | would regress installed plugin hooks and lifecycle handling
Rejected: Prefer hook-pipeline tool permissions over the global tool registry | would lose plugin tool permission mapping
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep runtime hook flow, permission policy wiring, and slash-command surfaces synchronized across crates during future merges
Tested: cargo test; cargo fmt --all --check; git diff --check
Not-tested: Live networked ANTHROPIC_API_KEY smoke path
2026-04-01 09:00:55 +00:00
Yeachan-Heo
f509d569ae fix: handle new slash command variants in cli 2026-04-01 08:40:20 +00:00
Yeachan-Heo
8ae96376a2 merge: integrate plugin subsystem and command surface from rcc/plugins 2026-04-01 08:17:27 +00:00
Yeachan-Heo
486fccfa3e feat: expand slash command surface 2026-04-01 08:15:23 +00:00
Yeachan-Heo
7464302fd3 feat: command surface follow-up integration 2026-04-01 08:10:36 +00:00
Yeachan-Heo
0755a36811 Clear stale enabled state during plugin loader pruning
The plugin loader already pruned stale registry entries, but stale enabled state
could linger in settings.json after bundled or installed plugin discovery
cleaned up missing installs. This change removes those orphaned enabled flags
when stale registry entries are dropped so loader-managed state stays coherent.

Constraint: Commit only plugin loader/registry code in this pass
Rejected: Leave stale enabled flags in settings.json | state drift would survive loader self-healing
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Any future loader-side pruning should remove matching enabled state in the same code path
Tested: cargo fmt --all; cargo test -p plugins
Not-tested: Interactive CLI /plugins flows against manually edited settings.json
2026-04-01 08:10:36 +00:00
Yeachan-Heo
7f33569f3a feat: command surface and slash completion wiring 2026-04-01 08:06:10 +00:00
Yeachan-Heo
24fea5db9e Prove raw tool output truncation stays display-only
Add a renderer regression test for long non-JSON tool output so the CLI's fallback rendering path is covered alongside Read and structured tool payload truncation.

Constraint: This follow-up must commit only renderer-related changes
Rejected: Touch commands crate to fix unrelated slash-command work in progress | outside the requested renderer-only scope
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep truncation guarantees covered at the renderer boundary for both structured and raw tool payloads
Tested: cargo fmt --all; cargo test -p rusty-claude-cli tool_rendering_ -- --nocapture; cargo clippy -p rusty-claude-cli --all-targets -- -D warnings
Not-tested: cargo test --workspace and cargo clippy --workspace --all-targets -- -D warnings currently fail in rust/crates/commands/src/lib.rs due pre-existing incomplete agents/skills changes outside this commit
2026-04-01 08:06:10 +00:00
Yeachan-Heo
782d9cea71 Preserve ILM-style conversation continuity during auto compaction
Auto compaction was keying off cumulative usage and re-summarizing from the front of the session, which made long chats shed continuity after the first compaction. The runtime now compacts against the current turn's prompt pressure and preserves prior compacted context as retained summary state instead of treating it like disposable history.

Constraint: Existing /compact behavior and saved-session resume flow had to keep working without schema changes
Rejected: Keep using cumulative input tokens | caused repeat compaction after every subsequent turn once the threshold was crossed
Rejected: Re-summarize prior compacted system messages as ordinary history | degraded continuity and could drop earlier context
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Preserve compacted-summary boundaries when extending compaction again; do not fold prior compacted context back into raw-message removal
Tested: cargo fmt --check; cargo clippy -p runtime -p commands --tests -- -D warnings; cargo test -p runtime; cargo test -p commands
Not-tested: End-to-end interactive CLI auto-compaction against a live Anthropic session
2026-04-01 08:06:10 +00:00
Yeachan-Heo
97d725d5e5 Keep CLI tool previews readable without truncating session data
Extend the CLI renderer's generic tool-result path to reuse the existing display-only truncation helper, so large plugin or unknown-tool payloads no longer flood the terminal while the original tool result still flows through runtime/session state unchanged.

The renderer now pretty-prints structured fallback payloads before truncating them for display, and the test suite covers both Read output and generic long tool output rendering. I also added a narrow clippy allow on an oversized slash-command parser test so the workspace lint gate stays green during verification.

Constraint: Tool result truncation must affect screen rendering only, not stored tool output
Rejected: Truncate tool results at execution time | would lose session fidelity and break downstream consumers
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep future tool-output shortening in renderer helpers only; do not trim runtime tool payloads before persistence
Tested: cargo fmt --all; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace
Not-tested: Manual interactive terminal run showing truncation in a live REPL session
2026-04-01 08:06:10 +00:00
Yeachan-Heo
d794acd3f4 Keep CLI tool rendering readable without dropping result fidelity
Some tools, especially Read, can emit very large payloads that overwhelm the interactive renderer. This change truncates only the displayed preview for long tool outputs while leaving the underlying tool result string untouched for downstream logic and persisted session state.

Constraint: Rendering changes must not modify stored tool outputs or tool-result messages
Rejected: Truncate tool output before returning from the executor | would corrupt session history and downstream processing
Confidence: high
Scope-risk: narrow
Directive: Keep truncation strictly in presentation helpers; do not move it into tool execution or session persistence paths
Tested: cargo test -p rusty-claude-cli tool_rendering_truncates_ -- --nocapture; cargo test -p rusty-claude-cli tool_rendering_helpers_compact_output -- --nocapture
Not-tested: Manual terminal rendering with real multi-megabyte tool output
2026-04-01 08:06:10 +00:00
Yeachan-Heo
4c1eaa16e0 Ignore reasoning blocks in runtime adapters without affecting tool/text flows
After the parser can accept thinking-style blocks, the CLI and tools adapters must explicitly ignore them so only user-visible text and tool calls drive runtime behavior. This keeps reasoning metadata from surfacing as text or interfering with tool accumulation.

Constraint: Runtime behavior must remain unchanged for normal text/tool streaming
Rejected: Treat thinking blocks as assistant text | would leak hidden reasoning into visible output and session flow
Confidence: high
Scope-risk: narrow
Directive: If future features need persisted reasoning blocks, add a dedicated runtime representation instead of overloading text handling
Tested: cargo test -p rusty-claude-cli response_to_events_ignores_thinking_blocks -- --nocapture; cargo test -p tools response_to_events_ignores_thinking_blocks -- --nocapture
Not-tested: End-to-end interactive run against a live thinking-enabled model
2026-04-01 08:06:10 +00:00
Yeachan-Heo
13851d800f Accept reasoning-style content blocks in the Rust API parser
The Rust API layer rejected thinking-enabled responses because it only recognized text and tool_use content blocks. This commit extends the response and SSE parser types to accept reasoning-style content blocks and deltas, with regression coverage for both non-streaming and streaming responses.

Constraint: Keep parsing compatible with existing text and tool-use message flows
Rejected: Deserialize unknown content blocks into an untyped catch-all | would weaken protocol coverage and test precision
Confidence: high
Scope-risk: narrow
Directive: Keep new protocol variants covered at the API boundary so downstream code can make explicit choices about preservation vs. ignoring
Tested: cargo test -p api thinking -- --nocapture
Not-tested: Live API traffic from a real thinking-enabled model
2026-04-01 08:06:10 +00:00
Yeachan-Heo
9e717192f8 Allow subagent tool flows to reach plugin-provided tools
The subagent runtime still advertised and executed only built-in tools, which left plugin-provided tools outside the Agent execution path. This change loads the same plugin-aware registry used by the CLI for subagent tool definitions, permission policy, and execution lookup so delegated runs can resolve plugin tools consistently.

Constraint: Plugin tools must respect the existing runtime plugin config and enabled-plugin state

Rejected: Thread plugin-specific exceptions through execute_tool directly | would bypass registry validation and duplicate lookup rules

Confidence: medium

Scope-risk: moderate

Reversibility: clean

Directive: Keep CLI and subagent registry construction aligned when plugin tool loading rules change

Tested: cargo test -p tools -p rusty-claude-cli

Not-tested: Live Anthropic subagent runs invoking plugin tools end-to-end
2026-04-01 07:36:05 +00:00
Yeachan-Heo
6584ed1ad7 Harden installed-plugin discovery against stale registry state
Expanded the plugin manager so installed plugin discovery now falls back across
install-root scans and registry-only paths without breaking on stale entries.
Missing registry install paths are pruned during discovery, while valid
registry-backed installs outside the install root remain loadable.

Constraint: Keep the change isolated to plugin manifest/manager/registry code
Rejected: Fail listing when any registry install path is missing | stale local state should not block plugin discovery
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Discovery now self-heals missing registry install paths; preserve the registry-fallback path for valid installs outside install_root
Tested: cargo fmt --all; cargo test -p plugins
Not-tested: End-to-end CLI flows with mixed stale and git-backed installed plugins
2026-04-01 07:34:55 +00:00
Yeachan-Heo
46abf52143 feat: plugin subsystem progress 2026-04-01 07:30:20 +00:00
Yeachan-Heo
28be7b3e24 Tighten plugin manifest validation and installed-plugin discovery
Expanded the Rust plugin loader coverage around manifest parsing so invalid
permission values, invalid tool permissions, and multi-error manifests are
validated in a structured way. Added scan-path coverage for installed plugin
directories so both root and packaged manifests are discovered from the install
root, independent of registry entries.

Constraint: Keep plugin loader changes isolated to the plugins crate surface
Rejected: Add a new manifest crate for shared schemas | unnecessary scope for this pass
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If manifest permissions or tool permission labels expand, update both the enums and validation tests together
Tested: cargo fmt --all; cargo test -p plugins
Not-tested: Cross-crate runtime consumption of any future expanded manifest permission variants
2026-04-01 07:23:10 +00:00
Yeachan-Heo
a10bbaf8de Keep plugin-aware CLI validation aligned with the shared registry
The shared /plugins command flow already routes through the plugin registry, but
allowed-tool normalization still fell back to builtin tools when registry
construction failed. This keeps plugin-related validation errors visible at the
CLI boundary and updates tools tests to use the enum-based plugin permission
API so workspace verification remains green.

Constraint: Plugin tool permissions are now strongly typed in the plugins crate
Rejected: Restore string-based permission arguments in tests | weakens the plugin API contract
Rejected: Keep builtin fallback in normalize_allowed_tools | masks plugin registry integration failures
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Do not silently bypass current_tool_registry() failures unless plugin-aware allowed-tool validation is intentionally being disabled
Tested: cargo test -p commands -- --nocapture; cargo test --workspace
Not-tested: Manual REPL /plugins interaction in a live session
2026-04-01 07:22:41 +00:00
Yeachan-Heo
f967484b9a feat: plugin system follow-up progress 2026-04-01 07:20:13 +00:00
Yeachan-Heo
6520cf8c3f test: cover installed plugin directory scanning 2026-04-01 07:16:13 +00:00
Yeachan-Heo
5f66392f45 feat: plugin subsystem final in-flight progress 2026-04-01 07:11:42 +00:00
Yeachan-Heo
123a7f4013 feat: plugin hooks + tool registry + CLI integration 2026-04-01 07:11:42 +00:00
Yeachan-Heo
0db9660727 feat: plugin subsystem progress 2026-04-01 07:11:25 +00:00
Yeachan-Heo
e488e94307 feat: plugin subsystem — loader, hooks, tools, bundled, CLI 2026-04-01 07:10:25 +00:00
Yeachan-Heo
f8d4da3e68 feat: plugins progress 2026-04-01 07:10:25 +00:00
Yeachan-Heo
8f6d8db958 feat: plugin registry + validation + hooks 2026-04-01 07:09:29 +00:00
Yeachan-Heo
7b17b037cb wip: plugins progress 2026-04-01 07:09:29 +00:00
Yeachan-Heo
b905b611f0 wip: plugins progress 2026-04-01 07:09:06 +00:00
Yeachan-Heo
c38eac7a90 feat: hook-pipeline progress — tests passing 2026-04-01 05:58:00 +00:00
Yeachan-Heo
197065bfc8 feat: hook abort signal + Ctrl-C cancellation pipeline 2026-04-01 05:55:24 +00:00
Yeachan-Heo
555a245456 wip: hook progress UI + documentation 2026-04-01 04:50:26 +00:00
Yeachan-Heo
9efd029e26 wip: hook-pipeline progress 2026-04-01 04:40:18 +00:00
Yeachan-Heo
eb89fc95e7 wip: hook-pipeline progress 2026-04-01 04:30:25 +00:00
Yeachan-Heo
94199beabb wip: hook pipeline progress 2026-04-01 04:20:16 +00:00
Yeachan-Heo
9ade3a70d7 fix: auto compaction threshold default 200k tokens 2026-04-01 03:55:00 +00:00
Yeachan-Heo
91ab8ea9d9 feat: auto compaction + ant-only commands (merge rcc/ant-tools) 2026-04-01 03:51:10 +00:00
Yeachan-Heo
992681c4fd Prevent long sessions from stalling and expose the requested internal command surface
The runtime now auto-compacts completed conversations once cumulative input usage
crosses a configurable threshold, preserving recent context while surfacing an
explicit user notice. The CLI also publishes the requested ant-only slash
commands through the shared commands crate and main dispatch, using meaningful
local implementations for commit/PR/issue/teleport/debug workflows.

Constraint: Reuse the existing Rust compaction pipeline instead of introducing a new summarization stack
Constraint: No new dependencies or broad command-framework rewrite
Rejected: Implement API-driven compaction inside ConversationRuntime now | too much new plumbing for this delivery
Rejected: Expose new commands as parse-only stubs | would not satisfy the requested command availability
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: If runtime later gains true API-backed compaction, preserve the TurnSummary auto-compaction metadata shape so CLI call sites stay stable
Tested: cargo test; cargo build --release; cargo fmt --all; git diff --check; LSP diagnostics directory check
Not-tested: Live Anthropic-backed specialist command flows; gh-authenticated PR/issue creation in a real repo
2026-04-01 03:48:50 +00:00
Yeachan-Heo
77427245c1 rebrand: Claude Code -> Claw Code in all prompts and source text 2026-04-01 03:45:42 +00:00
35 changed files with 9033 additions and 1548 deletions

22
rust/Cargo.lock generated
View File

@@ -25,7 +25,6 @@ dependencies = [
"runtime",
"serde",
"serde_json",
"telemetry",
"tokio",
]
@@ -112,6 +111,7 @@ dependencies = [
name = "commands"
version = "0.1.0"
dependencies = [
"plugins",
"runtime",
]
@@ -826,6 +826,14 @@ dependencies = [
"time",
]
[[package]]
name = "plugins"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -1093,11 +1101,11 @@ name = "runtime"
version = "0.1.0"
dependencies = [
"glob",
"plugins",
"regex",
"serde",
"serde_json",
"sha2",
"telemetry",
"tokio",
"walkdir",
]
@@ -1183,6 +1191,7 @@ dependencies = [
"commands",
"compat-harness",
"crossterm",
"plugins",
"pulldown-cmark",
"runtime",
"rustyline",
@@ -1430,14 +1439,6 @@ dependencies = [
"yaml-rust",
]
[[package]]
name = "telemetry"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "thiserror"
version = "2.0.18"
@@ -1556,6 +1557,7 @@ name = "tools"
version = "0.1.0"
dependencies = [
"api",
"plugins",
"reqwest",
"runtime",
"serde",

View File

@@ -1,6 +1,6 @@
# 🦞 Claw Code — Rust Implementation
A high-performance Rust rewrite of the Claude Code CLI agent harness. Built for speed, safety, and native tool execution.
A high-performance Rust rewrite of the Claw Code CLI agent harness. Built for speed, safety, and native tool execution.
## Quick Start

View File

@@ -10,7 +10,6 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
runtime = { path = "../runtime" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
telemetry = { path = "../telemetry" }
tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] }
[lints]

View File

@@ -2,19 +2,17 @@ use std::collections::VecDeque;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use runtime::{
format_usd, load_oauth_credentials, pricing_for_model, save_oauth_credentials, OAuthConfig,
OAuthRefreshRequest, OAuthTokenExchangeRequest,
load_oauth_credentials, save_oauth_credentials, OAuthConfig, OAuthRefreshRequest,
OAuthTokenExchangeRequest,
};
use serde::Deserialize;
use serde_json::{Map, Value};
use telemetry::{AnalyticsEvent, AnthropicRequestProfile, ClientIdentity, SessionTracer};
use crate::error::ApiError;
use crate::sse::SseParser;
use crate::types::{MessageRequest, MessageResponse, StreamEvent};
const DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
const MESSAGES_PATH: &str = "/v1/messages";
const ANTHROPIC_VERSION: &str = "2023-06-01";
const REQUEST_ID_HEADER: &str = "request-id";
const ALT_REQUEST_ID_HEADER: &str = "x-request-id";
const DEFAULT_INITIAL_BACKOFF: Duration = Duration::from_millis(200);
@@ -110,8 +108,6 @@ pub struct AnthropicClient {
max_retries: u32,
initial_backoff: Duration,
max_backoff: Duration,
request_profile: AnthropicRequestProfile,
session_tracer: Option<SessionTracer>,
}
impl AnthropicClient {
@@ -124,8 +120,6 @@ impl AnthropicClient {
max_retries: DEFAULT_MAX_RETRIES,
initial_backoff: DEFAULT_INITIAL_BACKOFF,
max_backoff: DEFAULT_MAX_BACKOFF,
request_profile: AnthropicRequestProfile::default(),
session_tracer: None,
}
}
@@ -138,8 +132,6 @@ impl AnthropicClient {
max_retries: DEFAULT_MAX_RETRIES,
initial_backoff: DEFAULT_INITIAL_BACKOFF,
max_backoff: DEFAULT_MAX_BACKOFF,
request_profile: AnthropicRequestProfile::default(),
session_tracer: None,
}
}
@@ -184,39 +176,6 @@ impl AnthropicClient {
self
}
#[must_use]
pub fn with_request_profile(mut self, request_profile: AnthropicRequestProfile) -> Self {
self.request_profile = request_profile;
self
}
#[must_use]
pub fn with_client_identity(mut self, client_identity: ClientIdentity) -> Self {
self.request_profile.client_identity = client_identity;
self
}
#[must_use]
pub fn with_beta(mut self, beta: impl Into<String>) -> Self {
let beta = beta.into();
if !self.request_profile.betas.contains(&beta) {
self.request_profile.betas.push(beta);
}
self
}
#[must_use]
pub fn with_extra_body_param(mut self, key: impl Into<String>, value: Value) -> Self {
self.request_profile.extra_body.insert(key.into(), value);
self
}
#[must_use]
pub fn with_session_tracer(mut self, session_tracer: SessionTracer) -> Self {
self.session_tracer = Some(session_tracer);
self
}
#[must_use]
pub fn with_retry_policy(
mut self,
@@ -252,7 +211,6 @@ impl AnthropicClient {
if response.request_id.is_none() {
response.request_id = request_id;
}
self.record_response_usage(&response);
Ok(response)
}
@@ -321,30 +279,18 @@ impl AnthropicClient {
loop {
attempts += 1;
self.record_request_started(request, attempts);
match self.send_raw_request(request).await {
Ok(response) => match expect_success(response).await {
Ok(response) => {
self.record_request_succeeded(request, attempts, &response);
return Ok(response);
}
Ok(response) => return Ok(response),
Err(error) if error.is_retryable() && attempts <= self.max_retries + 1 => {
self.record_request_failed(request, attempts, &error);
last_error = Some(error);
}
Err(error) => {
self.record_request_failed(request, attempts, &error);
return Err(error);
}
Err(error) => return Err(error),
},
Err(error) if error.is_retryable() && attempts <= self.max_retries + 1 => {
self.record_request_failed(request, attempts, &error);
last_error = Some(error);
}
Err(error) => {
self.record_request_failed(request, attempts, &error);
return Err(error);
}
Err(error) => return Err(error),
}
if attempts > self.max_retries {
@@ -364,213 +310,18 @@ impl AnthropicClient {
&self,
request: &MessageRequest,
) -> Result<reqwest::Response, ApiError> {
let request_url = format!("{}{}", self.base_url.trim_end_matches('/'), MESSAGES_PATH);
let mut request_builder = self
let request_url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
let request_builder = self
.http
.post(&request_url)
.header("anthropic-version", ANTHROPIC_VERSION)
.header("content-type", "application/json");
for (name, value) in self.request_profile.header_pairs() {
request_builder = request_builder.header(name, value);
}
let mut request_builder = self.auth.apply(request_builder);
let request_body = self.request_profile.render_json_body(request)?;
request_builder = request_builder.json(&request_body);
request_builder = request_builder.json(request);
request_builder.send().await.map_err(ApiError::from)
}
fn record_request_started(&self, request: &MessageRequest, attempt: u32) {
if let Some(tracer) = &self.session_tracer {
tracer.record_http_request_started(
attempt,
"POST",
MESSAGES_PATH,
self.request_attributes(request),
);
}
}
fn record_request_succeeded(
&self,
request: &MessageRequest,
attempt: u32,
response: &reqwest::Response,
) {
if let Some(tracer) = &self.session_tracer {
tracer.record_http_request_succeeded(
attempt,
"POST",
MESSAGES_PATH,
response.status().as_u16(),
request_id_from_headers(response.headers()),
self.request_attributes(request),
);
}
}
fn record_request_failed(&self, request: &MessageRequest, attempt: u32, error: &ApiError) {
if let Some(tracer) = &self.session_tracer {
tracer.record_http_request_failed(
attempt,
"POST",
MESSAGES_PATH,
error.to_string(),
error.is_retryable(),
self.error_attributes(request, error),
);
}
}
fn record_response_usage(&self, response: &MessageResponse) {
let Some(tracer) = &self.session_tracer else {
return;
};
let cost = response.usage.estimated_cost_usd(&response.model);
let pricing_source = if pricing_for_model(&response.model).is_some() {
"model-specific"
} else {
"default-sonnet"
};
let mut properties = Map::new();
properties.insert("model".to_string(), Value::String(response.model.clone()));
properties.insert(
"pricing_source".to_string(),
Value::String(pricing_source.to_string()),
);
properties.insert(
"input_tokens".to_string(),
Value::from(response.usage.input_tokens),
);
properties.insert(
"output_tokens".to_string(),
Value::from(response.usage.output_tokens),
);
properties.insert(
"cache_creation_input_tokens".to_string(),
Value::from(response.usage.cache_creation_input_tokens),
);
properties.insert(
"cache_read_input_tokens".to_string(),
Value::from(response.usage.cache_read_input_tokens),
);
properties.insert(
"total_tokens".to_string(),
Value::from(response.usage.total_tokens()),
);
properties.insert(
"estimated_cost_usd".to_string(),
Value::String(format_usd(cost.total_cost_usd())),
);
properties.insert(
"estimated_input_cost_usd".to_string(),
Value::String(format_usd(cost.input_cost_usd)),
);
properties.insert(
"estimated_output_cost_usd".to_string(),
Value::String(format_usd(cost.output_cost_usd)),
);
properties.insert(
"estimated_cache_creation_cost_usd".to_string(),
Value::String(format_usd(cost.cache_creation_cost_usd)),
);
properties.insert(
"estimated_cache_read_cost_usd".to_string(),
Value::String(format_usd(cost.cache_read_cost_usd)),
);
if let Some(request_id) = &response.request_id {
properties.insert("request_id".to_string(), Value::String(request_id.clone()));
}
tracer.record_analytics(AnalyticsEvent {
namespace: "api".to_string(),
action: "message_usage".to_string(),
properties,
});
}
fn request_attributes(&self, request: &MessageRequest) -> Map<String, Value> {
let mut attributes = Map::new();
attributes.insert("model".to_string(), Value::String(request.model.clone()));
attributes.insert("stream".to_string(), Value::Bool(request.stream));
attributes.insert("max_tokens".to_string(), Value::from(request.max_tokens));
attributes.insert(
"message_count".to_string(),
Value::from(u64::try_from(request.messages.len()).unwrap_or(u64::MAX)),
);
attributes.insert(
"tool_count".to_string(),
Value::from(
u64::try_from(request.tools.as_ref().map_or(0, Vec::len)).unwrap_or(u64::MAX),
),
);
attributes.insert(
"beta_count".to_string(),
Value::from(u64::try_from(self.request_profile.betas.len()).unwrap_or(u64::MAX)),
);
if !self.request_profile.betas.is_empty() {
attributes.insert(
"betas".to_string(),
Value::Array(
self.request_profile
.betas
.iter()
.cloned()
.map(Value::String)
.collect(),
),
);
}
if !self.request_profile.extra_body.is_empty() {
attributes.insert(
"extra_body_keys".to_string(),
Value::Array(
self.request_profile
.extra_body
.keys()
.cloned()
.map(Value::String)
.collect(),
),
);
}
attributes
}
fn error_attributes(&self, request: &MessageRequest, error: &ApiError) -> Map<String, Value> {
let mut attributes = self.request_attributes(request);
match error {
ApiError::Api {
status,
error_type,
message,
..
} => {
attributes.insert("status".to_string(), Value::from(status.as_u16()));
if let Some(error_type) = error_type {
attributes.insert("error_type".to_string(), Value::String(error_type.clone()));
}
if let Some(message) = message {
attributes.insert("api_message".to_string(), Value::String(message.clone()));
}
}
ApiError::Http(_) => {
attributes.insert("error_type".to_string(), Value::String("http".to_string()));
}
ApiError::Json(_) => {
attributes.insert("error_type".to_string(), Value::String("json".to_string()));
}
_ => {
attributes.insert(
"error_type".to_string(),
Value::String("client".to_string()),
);
}
}
attributes
}
fn backoff_for_attempt(&self, attempt: u32) -> Result<Duration, ApiError> {
let Some(multiplier) = 1_u32.checked_shl(attempt.saturating_sub(1)) else {
return Err(ApiError::BackoffOverflow {

View File

@@ -15,9 +15,3 @@ pub use types::{
MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent,
ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
};
pub use telemetry::{
AnalyticsEvent, AnthropicRequestProfile, ClientIdentity, JsonlTelemetrySink,
MemoryTelemetrySink, SessionTraceRecord, SessionTracer, TelemetryEvent, TelemetrySink,
DEFAULT_ANTHROPIC_VERSION,
};

View File

@@ -216,4 +216,64 @@ mod tests {
))
);
}
#[test]
fn parses_thinking_content_block_start() {
let frame = concat!(
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\",\"signature\":null}}\n\n"
);
let event = parse_frame(frame).expect("frame should parse");
assert_eq!(
event,
Some(StreamEvent::ContentBlockStart(
crate::types::ContentBlockStartEvent {
index: 0,
content_block: OutputContentBlock::Thinking {
thinking: String::new(),
signature: None,
},
},
))
);
}
#[test]
fn parses_thinking_related_deltas() {
let thinking = concat!(
"event: content_block_delta\n",
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"step 1\"}}\n\n"
);
let signature = concat!(
"event: content_block_delta\n",
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"sig_123\"}}\n\n"
);
let thinking_event = parse_frame(thinking).expect("thinking delta should parse");
let signature_event = parse_frame(signature).expect("signature delta should parse");
assert_eq!(
thinking_event,
Some(StreamEvent::ContentBlockDelta(
crate::types::ContentBlockDeltaEvent {
index: 0,
delta: ContentBlockDelta::ThinkingDelta {
thinking: "step 1".to_string(),
},
}
))
);
assert_eq!(
signature_event,
Some(StreamEvent::ContentBlockDelta(
crate::types::ContentBlockDeltaEvent {
index: 0,
delta: ContentBlockDelta::SignatureDelta {
signature: "sig_123".to_string(),
},
}
))
);
}
}

View File

@@ -1,4 +1,3 @@
use runtime::{pricing_for_model, TokenUsage, UsageCostEstimate};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -136,6 +135,15 @@ pub enum OutputContentBlock {
name: String,
input: Value,
},
Thinking {
#[serde(default)]
thinking: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
signature: Option<String>,
},
RedactedThinking {
data: Value,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -151,29 +159,7 @@ pub struct Usage {
impl Usage {
#[must_use]
pub const fn total_tokens(&self) -> u32 {
self.input_tokens
+ self.output_tokens
+ self.cache_creation_input_tokens
+ self.cache_read_input_tokens
}
#[must_use]
pub const fn token_usage(&self) -> TokenUsage {
TokenUsage {
input_tokens: self.input_tokens,
output_tokens: self.output_tokens,
cache_creation_input_tokens: self.cache_creation_input_tokens,
cache_read_input_tokens: self.cache_read_input_tokens,
}
}
#[must_use]
pub fn estimated_cost_usd(&self, model: &str) -> UsageCostEstimate {
let usage = self.token_usage();
pricing_for_model(model).map_or_else(
|| usage.estimate_cost_usd(),
|pricing| usage.estimate_cost_usd_with_pricing(pricing),
)
self.input_tokens + self.output_tokens
}
}
@@ -213,6 +199,8 @@ pub struct ContentBlockDeltaEvent {
pub enum ContentBlockDelta {
TextDelta { text: String },
InputJsonDelta { partial_json: String },
ThinkingDelta { thinking: String },
SignatureDelta { signature: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -233,47 +221,3 @@ pub enum StreamEvent {
ContentBlockStop(ContentBlockStopEvent),
MessageStop(MessageStopEvent),
}
#[cfg(test)]
mod tests {
use runtime::format_usd;
use super::{MessageResponse, Usage};
#[test]
fn usage_total_tokens_includes_cache_tokens() {
let usage = Usage {
input_tokens: 10,
cache_creation_input_tokens: 2,
cache_read_input_tokens: 3,
output_tokens: 4,
};
assert_eq!(usage.total_tokens(), 19);
assert_eq!(usage.token_usage().total_tokens(), 19);
}
#[test]
fn message_response_estimates_cost_from_model_usage() {
let response = MessageResponse {
id: "msg_cost".to_string(),
kind: "message".to_string(),
role: "assistant".to_string(),
content: Vec::new(),
model: "claude-sonnet-4-20250514".to_string(),
stop_reason: Some("end_turn".to_string()),
stop_sequence: None,
usage: Usage {
input_tokens: 1_000_000,
cache_creation_input_tokens: 100_000,
cache_read_input_tokens: 200_000,
output_tokens: 500_000,
},
request_id: None,
};
let cost = response.usage.estimated_cost_usd(&response.model);
assert_eq!(format_usd(cost.total_cost_usd()), "$54.6750");
assert_eq!(response.total_tokens(), 1_800_000);
}
}

View File

@@ -8,7 +8,6 @@ use api::{
StreamEvent, ToolChoice, ToolDefinition,
};
use serde_json::json;
use telemetry::{ClientIdentity, MemoryTelemetrySink, SessionTracer, TelemetryEvent};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
@@ -65,18 +64,6 @@ async fn send_message_posts_json_and_parses_response() {
request.headers.get("authorization").map(String::as_str),
Some("Bearer proxy-token")
);
assert_eq!(
request.headers.get("anthropic-version").map(String::as_str),
Some("2023-06-01")
);
assert_eq!(
request.headers.get("user-agent").map(String::as_str),
Some("claude-code/0.1.0")
);
assert_eq!(
request.headers.get("anthropic-beta").map(String::as_str),
Some("claude-code-20250219,prompt-caching-scope-2026-01-05")
);
let body: serde_json::Value =
serde_json::from_str(&request.body).expect("request body should be json");
assert_eq!(
@@ -86,114 +73,47 @@ async fn send_message_posts_json_and_parses_response() {
assert!(body.get("stream").is_none());
assert_eq!(body["tools"][0]["name"], json!("get_weather"));
assert_eq!(body["tool_choice"]["type"], json!("auto"));
assert_eq!(
body["betas"],
json!(["claude-code-20250219", "prompt-caching-scope-2026-01-05"])
);
}
#[tokio::test]
async fn send_message_applies_request_profile_and_records_telemetry() {
async fn send_message_parses_response_with_thinking_blocks() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let body = concat!(
"{",
"\"id\":\"msg_thinking\",",
"\"type\":\"message\",",
"\"role\":\"assistant\",",
"\"content\":[",
"{\"type\":\"thinking\",\"thinking\":\"step 1\",\"signature\":\"sig_123\"},",
"{\"type\":\"text\",\"text\":\"Final answer\"}",
"],",
"\"model\":\"claude-3-7-sonnet-latest\",",
"\"stop_reason\":\"end_turn\",",
"\"stop_sequence\":null,",
"\"usage\":{\"input_tokens\":12,\"output_tokens\":4}",
"}"
);
let server = spawn_server(
state.clone(),
vec![http_response_with_headers(
"200 OK",
"application/json",
concat!(
"{",
"\"id\":\"msg_profile\",",
"\"type\":\"message\",",
"\"role\":\"assistant\",",
"\"content\":[{\"type\":\"text\",\"text\":\"ok\"}],",
"\"model\":\"claude-3-7-sonnet-latest\",",
"\"stop_reason\":\"end_turn\",",
"\"stop_sequence\":null,",
"\"usage\":{\"input_tokens\":1,\"cache_creation_input_tokens\":2,\"cache_read_input_tokens\":3,\"output_tokens\":1}",
"}"
),
&[("request-id", "req_profile_123")],
)],
state,
vec![http_response("200 OK", "application/json", body)],
)
.await;
let sink = Arc::new(MemoryTelemetrySink::default());
let client = AnthropicClient::new("test-key")
.with_base_url(server.base_url())
.with_client_identity(ClientIdentity::new("claude-code", "9.9.9").with_runtime("rust-cli"))
.with_beta("tools-2026-04-01")
.with_extra_body_param("metadata", json!({"source": "clawd-code"}))
.with_session_tracer(SessionTracer::new("session-telemetry", sink.clone()));
let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
let response = client
.send_message(&sample_request(false))
.await
.expect("request should succeed");
assert_eq!(response.request_id.as_deref(), Some("req_profile_123"));
let captured = state.lock().await;
let request = captured.first().expect("server should capture request");
assert_eq!(
request.headers.get("anthropic-beta").map(String::as_str),
Some("claude-code-20250219,prompt-caching-scope-2026-01-05,tools-2026-04-01")
);
assert_eq!(
request.headers.get("user-agent").map(String::as_str),
Some("claude-code/9.9.9")
);
let body: serde_json::Value =
serde_json::from_str(&request.body).expect("request body should be json");
assert_eq!(body["metadata"]["source"], json!("clawd-code"));
assert_eq!(
body["betas"],
json!([
"claude-code-20250219",
"prompt-caching-scope-2026-01-05",
"tools-2026-04-01"
])
);
let events = sink.events();
assert_eq!(events.len(), 6);
assert_eq!(response.content.len(), 2);
assert!(matches!(
&events[0],
TelemetryEvent::HttpRequestStarted {
session_id,
attempt: 1,
method,
path,
..
} if session_id == "session-telemetry" && method == "POST" && path == "/v1/messages"
&response.content[0],
OutputContentBlock::Thinking { thinking, signature }
if thinking == "step 1" && signature.as_deref() == Some("sig_123")
));
assert!(matches!(
&events[1],
TelemetryEvent::SessionTrace(trace) if trace.name == "http_request_started"
));
assert!(matches!(
&events[2],
TelemetryEvent::HttpRequestSucceeded {
request_id,
status: 200,
..
} if request_id.as_deref() == Some("req_profile_123")
));
assert!(matches!(
&events[3],
TelemetryEvent::SessionTrace(trace) if trace.name == "http_request_succeeded"
));
assert!(matches!(
&events[4],
TelemetryEvent::Analytics(event)
if event.namespace == "api"
&& event.action == "message_usage"
&& event.properties.get("request_id") == Some(&json!("req_profile_123"))
&& event.properties.get("total_tokens") == Some(&json!(7))
&& event.properties.get("estimated_cost_usd") == Some(&json!("$0.0001"))
));
assert!(matches!(
&events[5],
TelemetryEvent::SessionTrace(trace) if trace.name == "analytics"
&response.content[1],
OutputContentBlock::Text { text } if text == "Final answer"
));
}
@@ -284,6 +204,85 @@ async fn stream_message_parses_sse_events_with_tool_use() {
assert!(request.body.contains("\"stream\":true"));
}
#[tokio::test]
async fn stream_message_parses_sse_events_with_thinking_blocks() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let sse = concat!(
"event: message_start\n",
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream_thinking\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":8,\"output_tokens\":0}}}\n\n",
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\n",
"event: content_block_delta\n",
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"step 1\"}}\n\n",
"event: content_block_delta\n",
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"sig_123\"}}\n\n",
"event: content_block_stop\n",
"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"Final answer\"}}\n\n",
"event: content_block_stop\n",
"data: {\"type\":\"content_block_stop\",\"index\":1}\n\n",
"event: message_delta\n",
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":8,\"output_tokens\":1}}\n\n",
"event: message_stop\n",
"data: {\"type\":\"message_stop\"}\n\n",
"data: [DONE]\n\n"
);
let server = spawn_server(
state,
vec![http_response("200 OK", "text/event-stream", sse)],
)
.await;
let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
let mut stream = client
.stream_message(&sample_request(false))
.await
.expect("stream should start");
let mut events = Vec::new();
while let Some(event) = stream
.next_event()
.await
.expect("stream event should parse")
{
events.push(event);
}
assert_eq!(events.len(), 9);
assert!(matches!(
&events[1],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
content_block: OutputContentBlock::Thinking { thinking, signature },
..
}) if thinking.is_empty() && signature.is_none()
));
assert!(matches!(
&events[2],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
delta: ContentBlockDelta::ThinkingDelta { thinking },
..
}) if thinking == "step 1"
));
assert!(matches!(
&events[3],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
delta: ContentBlockDelta::SignatureDelta { signature },
..
}) if signature == "sig_123"
));
assert!(matches!(
&events[5],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
content_block: OutputContentBlock::Text { text },
..
}) if text == "Final answer"
));
assert!(matches!(events[6], StreamEvent::ContentBlockStop(_)));
assert!(matches!(events[7], StreamEvent::MessageDelta(_)));
assert!(matches!(events[8], StreamEvent::MessageStop(_)));
}
#[tokio::test]
async fn retries_retryable_failures_before_succeeding() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));

View File

@@ -9,4 +9,5 @@ publish.workspace = true
workspace = true
[dependencies]
plugins = { path = "../plugins" }
runtime = { path = "../runtime" }

File diff suppressed because it is too large Load Diff

View File

@@ -70,16 +70,12 @@ fn upstream_repo_candidates(primary_repo_root: &Path) -> Vec<PathBuf> {
}
for ancestor in primary_repo_root.ancestors().take(4) {
candidates.push(ancestor.join("claude-code"));
candidates.push(ancestor.join("claw-code"));
candidates.push(ancestor.join("clawd-code"));
}
candidates.push(
primary_repo_root
.join("reference-source")
.join("claude-code"),
);
candidates.push(primary_repo_root.join("vendor").join("claude-code"));
candidates.push(primary_repo_root.join("reference-source").join("claw-code"));
candidates.push(primary_repo_root.join("vendor").join("claw-code"));
let mut deduped = Vec::new();
for candidate in candidates {

View File

@@ -1,5 +1,5 @@
[package]
name = "telemetry"
name = "plugins"
version.workspace = true
edition.workspace = true
license.workspace = true

View File

@@ -0,0 +1,10 @@
{
"name": "example-bundled",
"version": "0.1.0",
"description": "Example bundled plugin scaffold for the Rust plugin system",
"defaultEnabled": false,
"hooks": {
"PreToolUse": ["./hooks/pre.sh"],
"PostToolUse": ["./hooks/post.sh"]
}
}

View File

@@ -0,0 +1,2 @@
#!/bin/sh
printf '%s\n' 'example bundled post hook'

View File

@@ -0,0 +1,2 @@
#!/bin/sh
printf '%s\n' 'example bundled pre hook'

View File

@@ -0,0 +1,10 @@
{
"name": "sample-hooks",
"version": "0.1.0",
"description": "Bundled sample plugin scaffold for hook integration tests.",
"defaultEnabled": false,
"hooks": {
"PreToolUse": ["./hooks/pre.sh"],
"PostToolUse": ["./hooks/post.sh"]
}
}

View File

@@ -0,0 +1,2 @@
#!/bin/sh
printf 'sample bundled post hook'

View File

@@ -0,0 +1,2 @@
#!/bin/sh
printf 'sample bundled pre hook'

View File

@@ -0,0 +1,395 @@
use std::ffi::OsStr;
use std::path::Path;
use std::process::Command;
use serde_json::json;
use crate::{PluginError, PluginHooks, PluginRegistry};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookEvent {
PreToolUse,
PostToolUse,
}
impl HookEvent {
fn as_str(self) -> &'static str {
match self {
Self::PreToolUse => "PreToolUse",
Self::PostToolUse => "PostToolUse",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookRunResult {
denied: bool,
messages: Vec<String>,
}
impl HookRunResult {
#[must_use]
pub fn allow(messages: Vec<String>) -> Self {
Self {
denied: false,
messages,
}
}
#[must_use]
pub fn is_denied(&self) -> bool {
self.denied
}
#[must_use]
pub fn messages(&self) -> &[String] {
&self.messages
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct HookRunner {
hooks: PluginHooks,
}
impl HookRunner {
#[must_use]
pub fn new(hooks: PluginHooks) -> Self {
Self { hooks }
}
pub fn from_registry(plugin_registry: &PluginRegistry) -> Result<Self, PluginError> {
Ok(Self::new(plugin_registry.aggregated_hooks()?))
}
#[must_use]
pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult {
self.run_commands(
HookEvent::PreToolUse,
&self.hooks.pre_tool_use,
tool_name,
tool_input,
None,
false,
)
}
#[must_use]
pub fn run_post_tool_use(
&self,
tool_name: &str,
tool_input: &str,
tool_output: &str,
is_error: bool,
) -> HookRunResult {
self.run_commands(
HookEvent::PostToolUse,
&self.hooks.post_tool_use,
tool_name,
tool_input,
Some(tool_output),
is_error,
)
}
fn run_commands(
&self,
event: HookEvent,
commands: &[String],
tool_name: &str,
tool_input: &str,
tool_output: Option<&str>,
is_error: bool,
) -> HookRunResult {
if commands.is_empty() {
return HookRunResult::allow(Vec::new());
}
let payload = json!({
"hook_event_name": event.as_str(),
"tool_name": tool_name,
"tool_input": parse_tool_input(tool_input),
"tool_input_json": tool_input,
"tool_output": tool_output,
"tool_result_is_error": is_error,
})
.to_string();
let mut messages = Vec::new();
for command in commands {
match self.run_command(
command,
event,
tool_name,
tool_input,
tool_output,
is_error,
&payload,
) {
HookCommandOutcome::Allow { message } => {
if let Some(message) = message {
messages.push(message);
}
}
HookCommandOutcome::Deny { message } => {
messages.push(message.unwrap_or_else(|| {
format!("{} hook denied tool `{tool_name}`", event.as_str())
}));
return HookRunResult {
denied: true,
messages,
};
}
HookCommandOutcome::Warn { message } => messages.push(message),
}
}
HookRunResult::allow(messages)
}
#[allow(clippy::too_many_arguments, clippy::unused_self)]
fn run_command(
&self,
command: &str,
event: HookEvent,
tool_name: &str,
tool_input: &str,
tool_output: Option<&str>,
is_error: bool,
payload: &str,
) -> HookCommandOutcome {
let mut child = shell_command(command);
child.stdin(std::process::Stdio::piped());
child.stdout(std::process::Stdio::piped());
child.stderr(std::process::Stdio::piped());
child.env("HOOK_EVENT", event.as_str());
child.env("HOOK_TOOL_NAME", tool_name);
child.env("HOOK_TOOL_INPUT", tool_input);
child.env("HOOK_TOOL_IS_ERROR", if is_error { "1" } else { "0" });
if let Some(tool_output) = tool_output {
child.env("HOOK_TOOL_OUTPUT", tool_output);
}
match child.output_with_stdin(payload.as_bytes()) {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let message = (!stdout.is_empty()).then_some(stdout);
match output.status.code() {
Some(0) => HookCommandOutcome::Allow { message },
Some(2) => HookCommandOutcome::Deny { message },
Some(code) => HookCommandOutcome::Warn {
message: format_hook_warning(
command,
code,
message.as_deref(),
stderr.as_str(),
),
},
None => HookCommandOutcome::Warn {
message: format!(
"{} hook `{command}` terminated by signal while handling `{tool_name}`",
event.as_str()
),
},
}
}
Err(error) => HookCommandOutcome::Warn {
message: format!(
"{} hook `{command}` failed to start for `{tool_name}`: {error}",
event.as_str()
),
},
}
}
}
enum HookCommandOutcome {
Allow { message: Option<String> },
Deny { message: Option<String> },
Warn { message: String },
}
fn parse_tool_input(tool_input: &str) -> serde_json::Value {
serde_json::from_str(tool_input).unwrap_or_else(|_| json!({ "raw": tool_input }))
}
fn format_hook_warning(command: &str, code: i32, stdout: Option<&str>, stderr: &str) -> String {
let mut message =
format!("Hook `{command}` exited with status {code}; allowing tool execution to continue");
if let Some(stdout) = stdout.filter(|stdout| !stdout.is_empty()) {
message.push_str(": ");
message.push_str(stdout);
} else if !stderr.is_empty() {
message.push_str(": ");
message.push_str(stderr);
}
message
}
fn shell_command(command: &str) -> CommandWithStdin {
#[cfg(windows)]
let command_builder = {
let mut command_builder = Command::new("cmd");
command_builder.arg("/C").arg(command);
CommandWithStdin::new(command_builder)
};
#[cfg(not(windows))]
let command_builder = if Path::new(command).exists() {
let mut command_builder = Command::new("sh");
command_builder.arg(command);
CommandWithStdin::new(command_builder)
} else {
let mut command_builder = Command::new("sh");
command_builder.arg("-lc").arg(command);
CommandWithStdin::new(command_builder)
};
command_builder
}
struct CommandWithStdin {
command: Command,
}
impl CommandWithStdin {
fn new(command: Command) -> Self {
Self { command }
}
fn stdin(&mut self, cfg: std::process::Stdio) -> &mut Self {
self.command.stdin(cfg);
self
}
fn stdout(&mut self, cfg: std::process::Stdio) -> &mut Self {
self.command.stdout(cfg);
self
}
fn stderr(&mut self, cfg: std::process::Stdio) -> &mut Self {
self.command.stderr(cfg);
self
}
fn env<K, V>(&mut self, key: K, value: V) -> &mut Self
where
K: AsRef<OsStr>,
V: AsRef<OsStr>,
{
self.command.env(key, value);
self
}
fn output_with_stdin(&mut self, stdin: &[u8]) -> std::io::Result<std::process::Output> {
let mut child = self.command.spawn()?;
if let Some(mut child_stdin) = child.stdin.take() {
use std::io::Write as _;
child_stdin.write_all(stdin)?;
}
child.wait_with_output()
}
}
#[cfg(test)]
mod tests {
use super::{HookRunResult, HookRunner};
use crate::{PluginManager, PluginManagerConfig};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos();
std::env::temp_dir().join(format!("plugins-hook-runner-{label}-{nanos}"))
}
fn write_hook_plugin(root: &Path, name: &str, pre_message: &str, post_message: &str) {
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
fs::create_dir_all(root.join("hooks")).expect("hooks dir");
fs::write(
root.join("hooks").join("pre.sh"),
format!("#!/bin/sh\nprintf '%s\\n' '{pre_message}'\n"),
)
.expect("write pre hook");
fs::write(
root.join("hooks").join("post.sh"),
format!("#!/bin/sh\nprintf '%s\\n' '{post_message}'\n"),
)
.expect("write post hook");
fs::write(
root.join(".claude-plugin").join("plugin.json"),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"]\n }}\n}}"
),
)
.expect("write plugin manifest");
}
#[test]
fn collects_and_runs_hooks_from_enabled_plugins() {
let config_home = temp_dir("config");
let first_source_root = temp_dir("source-a");
let second_source_root = temp_dir("source-b");
write_hook_plugin(
&first_source_root,
"first",
"plugin pre one",
"plugin post one",
);
write_hook_plugin(
&second_source_root,
"second",
"plugin pre two",
"plugin post two",
);
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
manager
.install(first_source_root.to_str().expect("utf8 path"))
.expect("first plugin install should succeed");
manager
.install(second_source_root.to_str().expect("utf8 path"))
.expect("second plugin install should succeed");
let registry = manager.plugin_registry().expect("registry should build");
let runner = HookRunner::from_registry(&registry).expect("plugin hooks should load");
assert_eq!(
runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#),
HookRunResult::allow(vec![
"plugin pre one".to_string(),
"plugin pre two".to_string(),
])
);
assert_eq!(
runner.run_post_tool_use("Read", r#"{"path":"README.md"}"#, "ok", false),
HookRunResult::allow(vec![
"plugin post one".to_string(),
"plugin post two".to_string(),
])
);
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(first_source_root);
let _ = fs::remove_dir_all(second_source_root);
}
#[test]
fn pre_tool_use_denies_when_plugin_hook_exits_two() {
let runner = HookRunner::new(crate::PluginHooks {
pre_tool_use: vec!["printf 'blocked by plugin'; exit 2".to_string()],
post_tool_use: Vec::new(),
});
let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
assert!(result.is_denied());
assert_eq!(result.messages(), &["blocked by plugin".to_string()]);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,10 +8,10 @@ publish.workspace = true
[dependencies]
sha2 = "0.10"
glob = "0.3"
plugins = { path = "../plugins" }
regex = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
telemetry = { path = "../telemetry" }
tokio = { version = "1", features = ["io-util", "macros", "process", "rt", "rt-multi-thread", "time"] }
walkdir = "2"

View File

@@ -1,5 +1,10 @@
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
const COMPACT_CONTINUATION_PREAMBLE: &str =
"This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n";
const COMPACT_RECENT_MESSAGES_NOTE: &str = "Recent messages are preserved verbatim.";
const COMPACT_DIRECT_RESUME_INSTRUCTION: &str = "Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, and do not preface with continuation text.";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CompactionConfig {
pub preserve_recent_messages: usize,
@@ -30,8 +35,15 @@ pub fn estimate_session_tokens(session: &Session) -> usize {
#[must_use]
pub fn should_compact(session: &Session, config: CompactionConfig) -> bool {
session.messages.len() > config.preserve_recent_messages
&& estimate_session_tokens(session) >= config.max_estimated_tokens
let start = compacted_summary_prefix_len(session);
let compactable = &session.messages[start..];
compactable.len() > config.preserve_recent_messages
&& compactable
.iter()
.map(estimate_message_tokens)
.sum::<usize>()
>= config.max_estimated_tokens
}
#[must_use]
@@ -56,16 +68,18 @@ pub fn get_compact_continuation_message(
recent_messages_preserved: bool,
) -> String {
let mut base = format!(
"This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n{}",
"{COMPACT_CONTINUATION_PREAMBLE}{}",
format_compact_summary(summary)
);
if recent_messages_preserved {
base.push_str("\n\nRecent messages are preserved verbatim.");
base.push_str("\n\n");
base.push_str(COMPACT_RECENT_MESSAGES_NOTE);
}
if suppress_follow_up_questions {
base.push_str("\nContinue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, and do not preface with continuation text.");
base.push('\n');
base.push_str(COMPACT_DIRECT_RESUME_INSTRUCTION);
}
base
@@ -82,13 +96,19 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
};
}
let existing_summary = session
.messages
.first()
.and_then(extract_existing_compacted_summary);
let compacted_prefix_len = usize::from(existing_summary.is_some());
let keep_from = session
.messages
.len()
.saturating_sub(config.preserve_recent_messages);
let removed = &session.messages[..keep_from];
let removed = &session.messages[compacted_prefix_len..keep_from];
let preserved = session.messages[keep_from..].to_vec();
let summary = summarize_messages(removed);
let summary =
merge_compact_summaries(existing_summary.as_deref(), &summarize_messages(removed));
let formatted_summary = format_compact_summary(&summary);
let continuation = get_compact_continuation_message(&summary, true, !preserved.is_empty());
@@ -110,6 +130,16 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
}
}
fn compacted_summary_prefix_len(session: &Session) -> usize {
usize::from(
session
.messages
.first()
.and_then(extract_existing_compacted_summary)
.is_some(),
)
}
fn summarize_messages(messages: &[ConversationMessage]) -> String {
let user_messages = messages
.iter()
@@ -197,6 +227,41 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String {
lines.join("\n")
}
fn merge_compact_summaries(existing_summary: Option<&str>, new_summary: &str) -> String {
let Some(existing_summary) = existing_summary else {
return new_summary.to_string();
};
let previous_highlights = extract_summary_highlights(existing_summary);
let new_formatted_summary = format_compact_summary(new_summary);
let new_highlights = extract_summary_highlights(&new_formatted_summary);
let new_timeline = extract_summary_timeline(&new_formatted_summary);
let mut lines = vec!["<summary>".to_string(), "Conversation summary:".to_string()];
if !previous_highlights.is_empty() {
lines.push("- Previously compacted context:".to_string());
lines.extend(
previous_highlights
.into_iter()
.map(|line| format!(" {line}")),
);
}
if !new_highlights.is_empty() {
lines.push("- Newly compacted context:".to_string());
lines.extend(new_highlights.into_iter().map(|line| format!(" {line}")));
}
if !new_timeline.is_empty() {
lines.push("- Key timeline:".to_string());
lines.extend(new_timeline.into_iter().map(|line| format!(" {line}")));
}
lines.push("</summary>".to_string());
lines.join("\n")
}
fn summarize_block(block: &ContentBlock) -> String {
let raw = match block {
ContentBlock::Text { text } => text.clone(),
@@ -374,11 +439,71 @@ fn collapse_blank_lines(content: &str) -> String {
result
}
fn extract_existing_compacted_summary(message: &ConversationMessage) -> Option<String> {
if message.role != MessageRole::System {
return None;
}
let text = first_text_block(message)?;
let summary = text.strip_prefix(COMPACT_CONTINUATION_PREAMBLE)?;
let summary = summary
.split_once(&format!("\n\n{COMPACT_RECENT_MESSAGES_NOTE}"))
.map_or(summary, |(value, _)| value);
let summary = summary
.split_once(&format!("\n{COMPACT_DIRECT_RESUME_INSTRUCTION}"))
.map_or(summary, |(value, _)| value);
Some(summary.trim().to_string())
}
fn extract_summary_highlights(summary: &str) -> Vec<String> {
let mut lines = Vec::new();
let mut in_timeline = false;
for line in format_compact_summary(summary).lines() {
let trimmed = line.trim_end();
if trimmed.is_empty() || trimmed == "Summary:" || trimmed == "Conversation summary:" {
continue;
}
if trimmed == "- Key timeline:" {
in_timeline = true;
continue;
}
if in_timeline {
continue;
}
lines.push(trimmed.to_string());
}
lines
}
fn extract_summary_timeline(summary: &str) -> Vec<String> {
let mut lines = Vec::new();
let mut in_timeline = false;
for line in format_compact_summary(summary).lines() {
let trimmed = line.trim_end();
if trimmed == "- Key timeline:" {
in_timeline = true;
continue;
}
if !in_timeline {
continue;
}
if trimmed.is_empty() {
break;
}
lines.push(trimmed.to_string());
}
lines
}
#[cfg(test)]
mod tests {
use super::{
collect_key_files, compact_session, estimate_session_tokens, format_compact_summary,
infer_pending_work, should_compact, CompactionConfig,
get_compact_continuation_message, infer_pending_work, should_compact, CompactionConfig,
};
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
@@ -453,6 +578,98 @@ mod tests {
);
}
#[test]
fn keeps_previous_compacted_context_when_compacting_again() {
let initial_session = Session {
version: 1,
messages: vec![
ConversationMessage::user_text("Investigate rust/crates/runtime/src/compact.rs"),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "I will inspect the compact flow.".to_string(),
}]),
ConversationMessage::user_text(
"Also update rust/crates/runtime/src/conversation.rs",
),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "Next: preserve prior summary context during auto compact.".to_string(),
}]),
],
};
let config = CompactionConfig {
preserve_recent_messages: 2,
max_estimated_tokens: 1,
};
let first = compact_session(&initial_session, config);
let mut follow_up_messages = first.compacted_session.messages.clone();
follow_up_messages.extend([
ConversationMessage::user_text("Please add regression tests for compaction."),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "Working on regression coverage now.".to_string(),
}]),
]);
let second = compact_session(
&Session {
version: 1,
messages: follow_up_messages,
},
config,
);
assert!(second
.formatted_summary
.contains("Previously compacted context:"));
assert!(second
.formatted_summary
.contains("Scope: 2 earlier messages compacted"));
assert!(second
.formatted_summary
.contains("Newly compacted context:"));
assert!(second
.formatted_summary
.contains("Also update rust/crates/runtime/src/conversation.rs"));
assert!(matches!(
&second.compacted_session.messages[0].blocks[0],
ContentBlock::Text { text }
if text.contains("Previously compacted context:")
&& text.contains("Newly compacted context:")
));
assert!(matches!(
&second.compacted_session.messages[1].blocks[0],
ContentBlock::Text { text } if text.contains("Please add regression tests for compaction.")
));
}
#[test]
fn ignores_existing_compacted_summary_when_deciding_to_recompact() {
let summary = "<summary>Conversation summary:\n- Scope: earlier work preserved.\n- Key timeline:\n - user: large preserved context\n</summary>";
let session = Session {
version: 1,
messages: vec![
ConversationMessage {
role: MessageRole::System,
blocks: vec![ContentBlock::Text {
text: get_compact_continuation_message(summary, true, true),
}],
usage: None,
},
ConversationMessage::user_text("tiny"),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "recent".to_string(),
}]),
],
};
assert!(!should_compact(
&session,
CompactionConfig {
preserve_recent_messages: 2,
max_estimated_tokens: 1,
}
));
}
#[test]
fn truncates_long_blocks_in_summary() {
let summary = super::summarize_block(&ContentBlock::Text {

View File

@@ -35,13 +35,24 @@ pub struct RuntimeConfig {
feature_config: RuntimeFeatureConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimePluginConfig {
enabled_plugins: BTreeMap<String, bool>,
external_directories: Vec<String>,
install_root: Option<String>,
registry_path: Option<String>,
bundled_root: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimeFeatureConfig {
hooks: RuntimeHookConfig,
plugins: RuntimePluginConfig,
mcp: McpConfigCollection,
oauth: Option<OAuthConfig>,
model: Option<String>,
permission_mode: Option<ResolvedPermissionMode>,
permission_rules: RuntimePermissionRuleConfig,
sandbox: SandboxConfig,
}
@@ -49,6 +60,14 @@ pub struct RuntimeFeatureConfig {
pub struct RuntimeHookConfig {
pre_tool_use: Vec<String>,
post_tool_use: Vec<String>,
post_tool_use_failure: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimePermissionRuleConfig {
allow: Vec<String>,
deny: Vec<String>,
ask: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
@@ -174,13 +193,15 @@ impl ConfigLoader {
#[must_use]
pub fn default_for(cwd: impl Into<PathBuf>) -> Self {
let cwd = cwd.into();
let config_home = std::env::var_os("CLAUDE_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude")))
.unwrap_or_else(|| PathBuf::from(".claude"));
let config_home = default_config_home();
Self { cwd, config_home }
}
#[must_use]
pub fn config_home(&self) -> &Path {
&self.config_home
}
#[must_use]
pub fn discover(&self) -> Vec<ConfigEntry> {
let user_legacy_path = self.config_home.parent().map_or_else(
@@ -229,12 +250,14 @@ impl ConfigLoader {
let feature_config = RuntimeFeatureConfig {
hooks: parse_optional_hooks_config(&merged_value)?,
plugins: parse_optional_plugin_config(&merged_value)?,
mcp: McpConfigCollection {
servers: mcp_servers,
},
oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
model: parse_optional_model(&merged_value),
permission_mode: parse_optional_permission_mode(&merged_value)?,
permission_rules: parse_optional_permission_rules(&merged_value)?,
sandbox: parse_optional_sandbox_config(&merged_value)?,
};
@@ -291,6 +314,11 @@ impl RuntimeConfig {
&self.feature_config.hooks
}
#[must_use]
pub fn plugins(&self) -> &RuntimePluginConfig {
&self.feature_config.plugins
}
#[must_use]
pub fn oauth(&self) -> Option<&OAuthConfig> {
self.feature_config.oauth.as_ref()
@@ -306,6 +334,11 @@ impl RuntimeConfig {
self.feature_config.permission_mode
}
#[must_use]
pub fn permission_rules(&self) -> &RuntimePermissionRuleConfig {
&self.feature_config.permission_rules
}
#[must_use]
pub fn sandbox(&self) -> &SandboxConfig {
&self.feature_config.sandbox
@@ -319,11 +352,22 @@ impl RuntimeFeatureConfig {
self
}
#[must_use]
pub fn with_plugins(mut self, plugins: RuntimePluginConfig) -> Self {
self.plugins = plugins;
self
}
#[must_use]
pub fn hooks(&self) -> &RuntimeHookConfig {
&self.hooks
}
#[must_use]
pub fn plugins(&self) -> &RuntimePluginConfig {
&self.plugins
}
#[must_use]
pub fn mcp(&self) -> &McpConfigCollection {
&self.mcp
@@ -344,18 +388,75 @@ impl RuntimeFeatureConfig {
self.permission_mode
}
#[must_use]
pub fn permission_rules(&self) -> &RuntimePermissionRuleConfig {
&self.permission_rules
}
#[must_use]
pub fn sandbox(&self) -> &SandboxConfig {
&self.sandbox
}
}
impl RuntimePluginConfig {
#[must_use]
pub fn enabled_plugins(&self) -> &BTreeMap<String, bool> {
&self.enabled_plugins
}
#[must_use]
pub fn external_directories(&self) -> &[String] {
&self.external_directories
}
#[must_use]
pub fn install_root(&self) -> Option<&str> {
self.install_root.as_deref()
}
#[must_use]
pub fn registry_path(&self) -> Option<&str> {
self.registry_path.as_deref()
}
#[must_use]
pub fn bundled_root(&self) -> Option<&str> {
self.bundled_root.as_deref()
}
pub fn set_plugin_state(&mut self, plugin_id: String, enabled: bool) {
self.enabled_plugins.insert(plugin_id, enabled);
}
#[must_use]
pub fn state_for(&self, plugin_id: &str, default_enabled: bool) -> bool {
self.enabled_plugins
.get(plugin_id)
.copied()
.unwrap_or(default_enabled)
}
}
#[must_use]
pub fn default_config_home() -> PathBuf {
std::env::var_os("CLAUDE_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude")))
.unwrap_or_else(|| PathBuf::from(".claude"))
}
impl RuntimeHookConfig {
#[must_use]
pub fn new(pre_tool_use: Vec<String>, post_tool_use: Vec<String>) -> Self {
pub fn new(
pre_tool_use: Vec<String>,
post_tool_use: Vec<String>,
post_tool_use_failure: Vec<String>,
) -> Self {
Self {
pre_tool_use,
post_tool_use,
post_tool_use_failure,
}
}
@@ -368,6 +469,49 @@ impl RuntimeHookConfig {
pub fn post_tool_use(&self) -> &[String] {
&self.post_tool_use
}
#[must_use]
pub fn post_tool_use_failure(&self) -> &[String] {
&self.post_tool_use_failure
}
#[must_use]
pub fn merged(&self, other: &Self) -> Self {
let mut merged = self.clone();
merged.extend(other);
merged
}
pub fn extend(&mut self, other: &Self) {
extend_unique(&mut self.pre_tool_use, other.pre_tool_use());
extend_unique(&mut self.post_tool_use, other.post_tool_use());
extend_unique(
&mut self.post_tool_use_failure,
other.post_tool_use_failure(),
);
}
}
impl RuntimePermissionRuleConfig {
#[must_use]
pub fn new(allow: Vec<String>, deny: Vec<String>, ask: Vec<String>) -> Self {
Self { allow, deny, ask }
}
#[must_use]
pub fn allow(&self) -> &[String] {
&self.allow
}
#[must_use]
pub fn deny(&self) -> &[String] {
&self.deny
}
#[must_use]
pub fn ask(&self) -> &[String] {
&self.ask
}
}
impl McpConfigCollection {
@@ -481,9 +625,65 @@ fn parse_optional_hooks_config(root: &JsonValue) -> Result<RuntimeHookConfig, Co
.unwrap_or_default(),
post_tool_use: optional_string_array(hooks, "PostToolUse", "merged settings.hooks")?
.unwrap_or_default(),
post_tool_use_failure: optional_string_array(
hooks,
"PostToolUseFailure",
"merged settings.hooks",
)?
.unwrap_or_default(),
})
}
fn parse_optional_permission_rules(
root: &JsonValue,
) -> Result<RuntimePermissionRuleConfig, ConfigError> {
let Some(object) = root.as_object() else {
return Ok(RuntimePermissionRuleConfig::default());
};
let Some(permissions) = object.get("permissions").and_then(JsonValue::as_object) else {
return Ok(RuntimePermissionRuleConfig::default());
};
Ok(RuntimePermissionRuleConfig {
allow: optional_string_array(permissions, "allow", "merged settings.permissions")?
.unwrap_or_default(),
deny: optional_string_array(permissions, "deny", "merged settings.permissions")?
.unwrap_or_default(),
ask: optional_string_array(permissions, "ask", "merged settings.permissions")?
.unwrap_or_default(),
})
}
fn parse_optional_plugin_config(root: &JsonValue) -> Result<RuntimePluginConfig, ConfigError> {
let Some(object) = root.as_object() else {
return Ok(RuntimePluginConfig::default());
};
let mut config = RuntimePluginConfig::default();
if let Some(enabled_plugins) = object.get("enabledPlugins") {
config.enabled_plugins = parse_bool_map(enabled_plugins, "merged settings.enabledPlugins")?;
}
let Some(plugins_value) = object.get("plugins") else {
return Ok(config);
};
let plugins = expect_object(plugins_value, "merged settings.plugins")?;
if let Some(enabled_value) = plugins.get("enabled") {
config.enabled_plugins = parse_bool_map(enabled_value, "merged settings.plugins.enabled")?;
}
config.external_directories =
optional_string_array(plugins, "externalDirectories", "merged settings.plugins")?
.unwrap_or_default();
config.install_root =
optional_string(plugins, "installRoot", "merged settings.plugins")?.map(str::to_string);
config.registry_path =
optional_string(plugins, "registryPath", "merged settings.plugins")?.map(str::to_string);
config.bundled_root =
optional_string(plugins, "bundledRoot", "merged settings.plugins")?.map(str::to_string);
Ok(config)
}
fn parse_optional_permission_mode(
root: &JsonValue,
) -> Result<Option<ResolvedPermissionMode>, ConfigError> {
@@ -716,6 +916,24 @@ fn optional_u16(
}
}
fn parse_bool_map(value: &JsonValue, context: &str) -> Result<BTreeMap<String, bool>, ConfigError> {
let Some(map) = value.as_object() else {
return Err(ConfigError::Parse(format!(
"{context}: expected JSON object"
)));
};
map.iter()
.map(|(key, value)| {
value
.as_bool()
.map(|enabled| (key.clone(), enabled))
.ok_or_else(|| {
ConfigError::Parse(format!("{context}: field {key} must be a boolean"))
})
})
.collect()
}
fn optional_string_array(
object: &BTreeMap<String, JsonValue>,
key: &str,
@@ -790,6 +1008,18 @@ fn deep_merge_objects(
}
}
fn extend_unique(target: &mut Vec<String>, values: &[String]) {
for value in values {
push_unique(target, value.clone());
}
}
fn push_unique(target: &mut Vec<String>, value: String) {
if !target.iter().any(|existing| existing == &value) {
target.push(value);
}
}
#[cfg(test)]
mod tests {
use super::{
@@ -843,7 +1073,7 @@ mod tests {
.expect("write user compat config");
fs::write(
home.join("settings.json"),
r#"{"model":"sonnet","env":{"A2":"1"},"hooks":{"PreToolUse":["base"]},"permissions":{"defaultMode":"plan"}}"#,
r#"{"model":"sonnet","env":{"A2":"1"},"hooks":{"PreToolUse":["base"]},"permissions":{"defaultMode":"plan","allow":["Read"],"deny":["Bash(rm -rf)"]}}"#,
)
.expect("write user settings");
fs::write(
@@ -853,7 +1083,7 @@ mod tests {
.expect("write project compat config");
fs::write(
cwd.join(".claude").join("settings.json"),
r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#,
r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"],"PostToolUseFailure":["project-failure"]},"permissions":{"ask":["Edit"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#,
)
.expect("write project settings");
fs::write(
@@ -898,6 +1128,16 @@ mod tests {
.contains_key("PostToolUse"));
assert_eq!(loaded.hooks().pre_tool_use(), &["base".to_string()]);
assert_eq!(loaded.hooks().post_tool_use(), &["project".to_string()]);
assert_eq!(
loaded.hooks().post_tool_use_failure(),
&["project-failure".to_string()]
);
assert_eq!(loaded.permission_rules().allow(), &["Read".to_string()]);
assert_eq!(
loaded.permission_rules().deny(),
&["Bash(rm -rf)".to_string()]
);
assert_eq!(loaded.permission_rules().ask(), &["Edit".to_string()]);
assert!(loaded.mcp().get("home").is_some());
assert!(loaded.mcp().get("project").is_some());
@@ -1033,6 +1273,96 @@ mod tests {
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn parses_plugin_config_from_enabled_plugins() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claude");
fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
fs::create_dir_all(&home).expect("home config dir");
fs::write(
home.join("settings.json"),
r#"{
"enabledPlugins": {
"tool-guard@builtin": true,
"sample-plugin@external": false
}
}"#,
)
.expect("write user settings");
let loaded = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load");
assert_eq!(
loaded.plugins().enabled_plugins().get("tool-guard@builtin"),
Some(&true)
);
assert_eq!(
loaded
.plugins()
.enabled_plugins()
.get("sample-plugin@external"),
Some(&false)
);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn parses_plugin_config() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claude");
fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
fs::create_dir_all(&home).expect("home config dir");
fs::write(
home.join("settings.json"),
r#"{
"enabledPlugins": {
"core-helpers@builtin": true
},
"plugins": {
"externalDirectories": ["./external-plugins"],
"installRoot": "plugin-cache/installed",
"registryPath": "plugin-cache/installed.json",
"bundledRoot": "./bundled-plugins"
}
}"#,
)
.expect("write plugin settings");
let loaded = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load");
assert_eq!(
loaded
.plugins()
.enabled_plugins()
.get("core-helpers@builtin"),
Some(&true)
);
assert_eq!(
loaded.plugins().external_directories(),
&["./external-plugins".to_string()]
);
assert_eq!(
loaded.plugins().install_root(),
Some("plugin-cache/installed")
);
assert_eq!(
loaded.plugins().registry_path(),
Some("plugin-cache/installed.json")
);
assert_eq!(loaded.plugins().bundled_root(), Some("./bundled-plugins"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn rejects_invalid_mcp_server_shapes() {
let root = temp_dir();

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +1,91 @@
use std::ffi::OsStr;
use std::process::Command;
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use std::thread;
use std::time::Duration;
use serde_json::json;
use serde_json::{json, Value};
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
use crate::permissions::PermissionOverride;
pub type HookPermissionDecision = PermissionOverride;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookEvent {
PreToolUse,
PostToolUse,
PostToolUseFailure,
}
impl HookEvent {
fn as_str(self) -> &'static str {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::PreToolUse => "PreToolUse",
Self::PostToolUse => "PostToolUse",
Self::PostToolUseFailure => "PostToolUseFailure",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookProgressEvent {
Started {
event: HookEvent,
tool_name: String,
command: String,
},
Completed {
event: HookEvent,
tool_name: String,
command: String,
},
Cancelled {
event: HookEvent,
tool_name: String,
command: String,
},
}
pub trait HookProgressReporter {
fn on_event(&mut self, event: &HookProgressEvent);
}
#[derive(Debug, Clone, Default)]
pub struct HookAbortSignal {
aborted: Arc<AtomicBool>,
}
impl HookAbortSignal {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn abort(&self) {
self.aborted.store(true, Ordering::SeqCst);
}
#[must_use]
pub fn is_aborted(&self) -> bool {
self.aborted.load(Ordering::SeqCst)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookRunResult {
denied: bool,
cancelled: bool,
messages: Vec<String>,
permission_override: Option<PermissionOverride>,
permission_reason: Option<String>,
updated_input: Option<String>,
}
impl HookRunResult {
@@ -31,7 +93,11 @@ impl HookRunResult {
pub fn allow(messages: Vec<String>) -> Self {
Self {
denied: false,
cancelled: false,
messages,
permission_override: None,
permission_reason: None,
updated_input: None,
}
}
@@ -40,10 +106,40 @@ impl HookRunResult {
self.denied
}
#[must_use]
pub fn is_cancelled(&self) -> bool {
self.cancelled
}
#[must_use]
pub fn messages(&self) -> &[String] {
&self.messages
}
#[must_use]
pub fn permission_override(&self) -> Option<PermissionOverride> {
self.permission_override
}
#[must_use]
pub fn permission_decision(&self) -> Option<HookPermissionDecision> {
self.permission_override
}
#[must_use]
pub fn permission_reason(&self) -> Option<&str> {
self.permission_reason.as_deref()
}
#[must_use]
pub fn updated_input(&self) -> Option<&str> {
self.updated_input.as_deref()
}
#[must_use]
pub fn updated_input_json(&self) -> Option<&str> {
self.updated_input()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
@@ -64,6 +160,17 @@ impl HookRunner {
#[must_use]
pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult {
self.run_pre_tool_use_with_context(tool_name, tool_input, None, None)
}
#[must_use]
pub fn run_pre_tool_use_with_context(
&self,
tool_name: &str,
tool_input: &str,
abort_signal: Option<&HookAbortSignal>,
reporter: Option<&mut dyn HookProgressReporter>,
) -> HookRunResult {
Self::run_commands(
HookEvent::PreToolUse,
self.config.pre_tool_use(),
@@ -71,9 +178,21 @@ impl HookRunner {
tool_input,
None,
false,
abort_signal,
reporter,
)
}
#[must_use]
pub fn run_pre_tool_use_with_signal(
&self,
tool_name: &str,
tool_input: &str,
abort_signal: Option<&HookAbortSignal>,
) -> HookRunResult {
self.run_pre_tool_use_with_context(tool_name, tool_input, abort_signal, None)
}
#[must_use]
pub fn run_post_tool_use(
&self,
@@ -81,6 +200,26 @@ impl HookRunner {
tool_input: &str,
tool_output: &str,
is_error: bool,
) -> HookRunResult {
self.run_post_tool_use_with_context(
tool_name,
tool_input,
tool_output,
is_error,
None,
None,
)
}
#[must_use]
pub fn run_post_tool_use_with_context(
&self,
tool_name: &str,
tool_input: &str,
tool_output: &str,
is_error: bool,
abort_signal: Option<&HookAbortSignal>,
reporter: Option<&mut dyn HookProgressReporter>,
) -> HookRunResult {
Self::run_commands(
HookEvent::PostToolUse,
@@ -89,9 +228,79 @@ impl HookRunner {
tool_input,
Some(tool_output),
is_error,
abort_signal,
reporter,
)
}
#[must_use]
pub fn run_post_tool_use_with_signal(
&self,
tool_name: &str,
tool_input: &str,
tool_output: &str,
is_error: bool,
abort_signal: Option<&HookAbortSignal>,
) -> HookRunResult {
self.run_post_tool_use_with_context(
tool_name,
tool_input,
tool_output,
is_error,
abort_signal,
None,
)
}
#[must_use]
pub fn run_post_tool_use_failure(
&self,
tool_name: &str,
tool_input: &str,
tool_error: &str,
) -> HookRunResult {
self.run_post_tool_use_failure_with_context(tool_name, tool_input, tool_error, None, None)
}
#[must_use]
pub fn run_post_tool_use_failure_with_context(
&self,
tool_name: &str,
tool_input: &str,
tool_error: &str,
abort_signal: Option<&HookAbortSignal>,
reporter: Option<&mut dyn HookProgressReporter>,
) -> HookRunResult {
Self::run_commands(
HookEvent::PostToolUseFailure,
self.config.post_tool_use_failure(),
tool_name,
tool_input,
Some(tool_error),
true,
abort_signal,
reporter,
)
}
#[must_use]
pub fn run_post_tool_use_failure_with_signal(
&self,
tool_name: &str,
tool_input: &str,
tool_error: &str,
abort_signal: Option<&HookAbortSignal>,
) -> HookRunResult {
self.run_post_tool_use_failure_with_context(
tool_name,
tool_input,
tool_error,
abort_signal,
None,
)
}
#[allow(clippy::too_many_arguments)]
fn run_commands(
event: HookEvent,
commands: &[String],
@@ -99,123 +308,299 @@ impl HookRunner {
tool_input: &str,
tool_output: Option<&str>,
is_error: bool,
abort_signal: Option<&HookAbortSignal>,
mut reporter: Option<&mut dyn HookProgressReporter>,
) -> HookRunResult {
if commands.is_empty() {
return HookRunResult::allow(Vec::new());
}
let payload = json!({
"hook_event_name": event.as_str(),
"tool_name": tool_name,
"tool_input": parse_tool_input(tool_input),
"tool_input_json": tool_input,
"tool_output": tool_output,
"tool_result_is_error": is_error,
})
.to_string();
let invocation = HookInvocation {
event,
tool_name,
tool_input,
tool_output,
is_error,
payload: &payload,
};
if abort_signal.is_some_and(HookAbortSignal::is_aborted) {
return HookRunResult {
denied: false,
cancelled: true,
messages: vec![format!(
"{} hook cancelled before execution",
event.as_str()
)],
permission_override: None,
permission_reason: None,
updated_input: None,
};
}
let mut messages = Vec::new();
let payload = hook_payload(event, tool_name, tool_input, tool_output, is_error).to_string();
let mut result = HookRunResult::allow(Vec::new());
for command in commands {
match Self::run_command(command, &invocation) {
HookCommandOutcome::Allow { message } => {
if let Some(message) = message {
messages.push(message);
if let Some(reporter) = reporter.as_deref_mut() {
reporter.on_event(&HookProgressEvent::Started {
event,
tool_name: tool_name.to_string(),
command: command.clone(),
});
}
match Self::run_command(
command,
event,
tool_name,
tool_input,
tool_output,
is_error,
&payload,
abort_signal,
) {
HookCommandOutcome::Allow { parsed } => {
if let Some(reporter) = reporter.as_deref_mut() {
reporter.on_event(&HookProgressEvent::Completed {
event,
tool_name: tool_name.to_string(),
command: command.clone(),
});
}
merge_parsed_hook_output(&mut result, parsed);
}
HookCommandOutcome::Deny { message } => {
let message = message.unwrap_or_else(|| {
format!("{} hook denied tool `{tool_name}`", event.as_str())
});
messages.push(message);
return HookRunResult {
denied: true,
messages,
};
HookCommandOutcome::Deny { parsed } => {
if let Some(reporter) = reporter.as_deref_mut() {
reporter.on_event(&HookProgressEvent::Completed {
event,
tool_name: tool_name.to_string(),
command: command.clone(),
});
}
merge_parsed_hook_output(&mut result, parsed);
result.denied = true;
return result;
}
HookCommandOutcome::Warn { message } => {
if let Some(reporter) = reporter.as_deref_mut() {
reporter.on_event(&HookProgressEvent::Completed {
event,
tool_name: tool_name.to_string(),
command: command.clone(),
});
}
result.messages.push(message);
}
HookCommandOutcome::Cancelled { message } => {
if let Some(reporter) = reporter.as_deref_mut() {
reporter.on_event(&HookProgressEvent::Cancelled {
event,
tool_name: tool_name.to_string(),
command: command.clone(),
});
}
result.cancelled = true;
result.messages.push(message);
return result;
}
HookCommandOutcome::Warn { message } => messages.push(message),
}
}
HookRunResult::allow(messages)
result
}
fn run_command(command: &str, invocation: &HookInvocation<'_>) -> HookCommandOutcome {
#[allow(clippy::too_many_arguments)]
fn run_command(
command: &str,
event: HookEvent,
tool_name: &str,
tool_input: &str,
tool_output: Option<&str>,
is_error: bool,
payload: &str,
abort_signal: Option<&HookAbortSignal>,
) -> HookCommandOutcome {
let mut child = shell_command(command);
child.stdin(std::process::Stdio::piped());
child.stdout(std::process::Stdio::piped());
child.stderr(std::process::Stdio::piped());
child.env("HOOK_EVENT", invocation.event.as_str());
child.env("HOOK_TOOL_NAME", invocation.tool_name);
child.env("HOOK_TOOL_INPUT", invocation.tool_input);
child.env(
"HOOK_TOOL_IS_ERROR",
if invocation.is_error { "1" } else { "0" },
);
if let Some(tool_output) = invocation.tool_output {
child.stdin(Stdio::piped());
child.stdout(Stdio::piped());
child.stderr(Stdio::piped());
child.env("HOOK_EVENT", event.as_str());
child.env("HOOK_TOOL_NAME", tool_name);
child.env("HOOK_TOOL_INPUT", tool_input);
child.env("HOOK_TOOL_IS_ERROR", if is_error { "1" } else { "0" });
if let Some(tool_output) = tool_output {
child.env("HOOK_TOOL_OUTPUT", tool_output);
}
match child.output_with_stdin(invocation.payload.as_bytes()) {
Ok(output) => {
match child.output_with_stdin(payload.as_bytes(), abort_signal) {
Ok(CommandExecution::Finished(output)) => {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let message = (!stdout.is_empty()).then_some(stdout);
let parsed = parse_hook_output(&stdout);
match output.status.code() {
Some(0) => HookCommandOutcome::Allow { message },
Some(2) => HookCommandOutcome::Deny { message },
Some(0) => {
if parsed.deny {
HookCommandOutcome::Deny { parsed }
} else {
HookCommandOutcome::Allow { parsed }
}
}
Some(2) => HookCommandOutcome::Deny {
parsed: parsed.with_fallback_message(format!(
"{} hook denied tool `{tool_name}`",
event.as_str()
)),
},
Some(code) => HookCommandOutcome::Warn {
message: format_hook_warning(
command,
code,
message.as_deref(),
parsed.primary_message(),
stderr.as_str(),
),
},
None => HookCommandOutcome::Warn {
message: format!(
"{} hook `{command}` terminated by signal while handling `{}`",
invocation.event.as_str(),
invocation.tool_name
"{} hook `{command}` terminated by signal while handling `{tool_name}`",
event.as_str()
),
},
}
}
Ok(CommandExecution::Cancelled) => HookCommandOutcome::Cancelled {
message: format!(
"{} hook `{command}` cancelled while handling `{tool_name}`",
event.as_str()
),
},
Err(error) => HookCommandOutcome::Warn {
message: format!(
"{} hook `{command}` failed to start for `{tool_name}`: {error}",
invocation.event.as_str(),
tool_name = invocation.tool_name
event.as_str()
),
},
}
}
}
struct HookInvocation<'a> {
event: HookEvent,
tool_name: &'a str,
tool_input: &'a str,
tool_output: Option<&'a str>,
is_error: bool,
payload: &'a str,
}
enum HookCommandOutcome {
Allow { message: Option<String> },
Deny { message: Option<String> },
Allow { parsed: ParsedHookOutput },
Deny { parsed: ParsedHookOutput },
Warn { message: String },
Cancelled { message: String },
}
fn parse_tool_input(tool_input: &str) -> serde_json::Value {
#[derive(Debug, Clone, PartialEq, Eq, Default)]
struct ParsedHookOutput {
messages: Vec<String>,
deny: bool,
permission_override: Option<PermissionOverride>,
permission_reason: Option<String>,
updated_input: Option<String>,
}
impl ParsedHookOutput {
fn with_fallback_message(mut self, fallback: String) -> Self {
if self.messages.is_empty() {
self.messages.push(fallback);
}
self
}
fn primary_message(&self) -> Option<&str> {
self.messages.first().map(String::as_str)
}
}
fn merge_parsed_hook_output(target: &mut HookRunResult, parsed: ParsedHookOutput) {
target.messages.extend(parsed.messages);
if parsed.permission_override.is_some() {
target.permission_override = parsed.permission_override;
}
if parsed.permission_reason.is_some() {
target.permission_reason = parsed.permission_reason;
}
if parsed.updated_input.is_some() {
target.updated_input = parsed.updated_input;
}
}
fn parse_hook_output(stdout: &str) -> ParsedHookOutput {
if stdout.is_empty() {
return ParsedHookOutput::default();
}
let Ok(Value::Object(root)) = serde_json::from_str::<Value>(stdout) else {
return ParsedHookOutput {
messages: vec![stdout.to_string()],
..ParsedHookOutput::default()
};
};
let mut parsed = ParsedHookOutput::default();
if let Some(message) = root.get("systemMessage").and_then(Value::as_str) {
parsed.messages.push(message.to_string());
}
if let Some(message) = root.get("reason").and_then(Value::as_str) {
parsed.messages.push(message.to_string());
}
if root.get("continue").and_then(Value::as_bool) == Some(false)
|| root.get("decision").and_then(Value::as_str) == Some("block")
{
parsed.deny = true;
}
if let Some(Value::Object(specific)) = root.get("hookSpecificOutput") {
if let Some(Value::String(additional_context)) = specific.get("additionalContext") {
parsed.messages.push(additional_context.clone());
}
if let Some(decision) = specific.get("permissionDecision").and_then(Value::as_str) {
parsed.permission_override = match decision {
"allow" => Some(PermissionOverride::Allow),
"deny" => Some(PermissionOverride::Deny),
"ask" => Some(PermissionOverride::Ask),
_ => None,
};
}
if let Some(reason) = specific
.get("permissionDecisionReason")
.and_then(Value::as_str)
{
parsed.permission_reason = Some(reason.to_string());
}
if let Some(updated_input) = specific.get("updatedInput") {
parsed.updated_input = serde_json::to_string(updated_input).ok();
}
}
if parsed.messages.is_empty() {
parsed.messages.push(stdout.to_string());
}
parsed
}
fn hook_payload(
event: HookEvent,
tool_name: &str,
tool_input: &str,
tool_output: Option<&str>,
is_error: bool,
) -> Value {
match event {
HookEvent::PostToolUseFailure => json!({
"hook_event_name": event.as_str(),
"tool_name": tool_name,
"tool_input": parse_tool_input(tool_input),
"tool_input_json": tool_input,
"tool_error": tool_output,
"tool_result_is_error": true,
}),
_ => json!({
"hook_event_name": event.as_str(),
"tool_name": tool_name,
"tool_input": parse_tool_input(tool_input),
"tool_input_json": tool_input,
"tool_output": tool_output,
"tool_result_is_error": is_error,
}),
}
}
fn parse_tool_input(tool_input: &str) -> Value {
serde_json::from_str(tool_input).unwrap_or_else(|_| json!({ "raw": tool_input }))
}
@@ -241,7 +626,11 @@ fn shell_command(command: &str) -> CommandWithStdin {
};
#[cfg(not(windows))]
let command_builder = {
let command_builder = if Path::new(command).exists() {
let mut command_builder = Command::new("sh");
command_builder.arg(command);
CommandWithStdin::new(command_builder)
} else {
let mut command_builder = Command::new("sh");
command_builder.arg("-lc").arg(command);
CommandWithStdin::new(command_builder)
@@ -259,17 +648,17 @@ impl CommandWithStdin {
Self { command }
}
fn stdin(&mut self, cfg: std::process::Stdio) -> &mut Self {
fn stdin(&mut self, cfg: Stdio) -> &mut Self {
self.command.stdin(cfg);
self
}
fn stdout(&mut self, cfg: std::process::Stdio) -> &mut Self {
fn stdout(&mut self, cfg: Stdio) -> &mut Self {
self.command.stdout(cfg);
self
}
fn stderr(&mut self, cfg: std::process::Stdio) -> &mut Self {
fn stderr(&mut self, cfg: Stdio) -> &mut Self {
self.command.stderr(cfg);
self
}
@@ -283,26 +672,64 @@ impl CommandWithStdin {
self
}
fn output_with_stdin(&mut self, stdin: &[u8]) -> std::io::Result<std::process::Output> {
fn output_with_stdin(
&mut self,
stdin: &[u8],
abort_signal: Option<&HookAbortSignal>,
) -> std::io::Result<CommandExecution> {
let mut child = self.command.spawn()?;
if let Some(mut child_stdin) = child.stdin.take() {
use std::io::Write;
child_stdin.write_all(stdin)?;
}
child.wait_with_output()
loop {
if abort_signal.is_some_and(HookAbortSignal::is_aborted) {
let _ = child.kill();
let _ = child.wait_with_output();
return Ok(CommandExecution::Cancelled);
}
match child.try_wait()? {
Some(_) => return child.wait_with_output().map(CommandExecution::Finished),
None => thread::sleep(Duration::from_millis(20)),
}
}
}
}
enum CommandExecution {
Finished(std::process::Output),
Cancelled,
}
#[cfg(test)]
mod tests {
use super::{HookRunResult, HookRunner};
use std::thread;
use std::time::Duration;
use super::{
HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult,
HookRunner,
};
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
use crate::permissions::PermissionOverride;
struct RecordingReporter {
events: Vec<HookProgressEvent>,
}
impl HookProgressReporter for RecordingReporter {
fn on_event(&mut self, event: &HookProgressEvent) {
self.events.push(event.clone());
}
}
#[test]
fn allows_exit_code_zero_and_captures_stdout() {
let runner = HookRunner::new(RuntimeHookConfig::new(
vec![shell_snippet("printf 'pre ok'")],
Vec::new(),
Vec::new(),
));
let result = runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#);
@@ -315,6 +742,7 @@ mod tests {
let runner = HookRunner::new(RuntimeHookConfig::new(
vec![shell_snippet("printf 'blocked by hook'; exit 2")],
Vec::new(),
Vec::new(),
));
let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
@@ -329,6 +757,7 @@ mod tests {
RuntimeHookConfig::new(
vec![shell_snippet("printf 'warning hook'; exit 1")],
Vec::new(),
Vec::new(),
),
));
@@ -341,6 +770,82 @@ mod tests {
.any(|message| message.contains("allowing tool execution to continue")));
}
#[test]
fn parses_pre_hook_permission_override_and_updated_input() {
let runner = HookRunner::new(RuntimeHookConfig::new(
vec![shell_snippet(
r#"printf '%s' '{"systemMessage":"updated","hookSpecificOutput":{"permissionDecision":"allow","permissionDecisionReason":"hook ok","updatedInput":{"command":"git status"}}}'"#,
)],
Vec::new(),
Vec::new(),
));
let result = runner.run_pre_tool_use("bash", r#"{"command":"pwd"}"#);
assert_eq!(
result.permission_override(),
Some(PermissionOverride::Allow)
);
assert_eq!(result.permission_reason(), Some("hook ok"));
assert_eq!(result.updated_input(), Some(r#"{"command":"git status"}"#));
assert!(result.messages().iter().any(|message| message == "updated"));
}
#[test]
fn runs_post_tool_use_failure_hooks() {
let runner = HookRunner::new(RuntimeHookConfig::new(
Vec::new(),
Vec::new(),
vec![shell_snippet("printf 'failure hook ran'")],
));
let result =
runner.run_post_tool_use_failure("bash", r#"{"command":"false"}"#, "command failed");
assert!(!result.is_denied());
assert_eq!(result.messages(), &["failure hook ran".to_string()]);
}
#[test]
fn abort_signal_cancels_long_running_hook_and_reports_progress() {
let runner = HookRunner::new(RuntimeHookConfig::new(
vec![shell_snippet("sleep 5")],
Vec::new(),
Vec::new(),
));
let abort_signal = HookAbortSignal::new();
let abort_signal_for_thread = abort_signal.clone();
let mut reporter = RecordingReporter { events: Vec::new() };
thread::spawn(move || {
thread::sleep(Duration::from_millis(100));
abort_signal_for_thread.abort();
});
let result = runner.run_pre_tool_use_with_context(
"bash",
r#"{"command":"sleep 5"}"#,
Some(&abort_signal),
Some(&mut reporter),
);
assert!(result.is_cancelled());
assert!(reporter.events.iter().any(|event| matches!(
event,
HookProgressEvent::Started {
event: HookEvent::PreToolUse,
..
}
)));
assert!(reporter.events.iter().any(|event| matches!(
event,
HookProgressEvent::Cancelled {
event: HookEvent::PreToolUse,
..
}
)));
}
#[cfg(windows)]
fn shell_snippet(script: &str) -> String {
script.replace('\'', "\"")

View File

@@ -28,18 +28,21 @@ pub use config::{
McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig,
ScopedMcpServerConfig, CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig,
CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
};
pub use conversation::{
ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor,
ToolError, ToolExecutor, TurnSummary,
auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent,
ConversationRuntime, RuntimeError, StaticToolExecutor, ToolError, ToolExecutor, TurnSummary,
};
pub use file_ops::{
edit_file, glob_search, grep_search, read_file, write_file, EditFileOutput, GlobSearchOutput,
GrepSearchInput, GrepSearchOutput, ReadFileOutput, StructuredPatchHunk, TextFilePayload,
WriteFileOutput,
};
pub use hooks::{HookEvent, HookRunResult, HookRunner};
pub use hooks::{
HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult, HookRunner,
};
pub use mcp::{
mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp,
scoped_mcp_config_hash, unwrap_ccr_proxy_url,
@@ -64,8 +67,8 @@ pub use oauth::{
PkceChallengeMethod, PkceCodePair,
};
pub use permissions::{
PermissionMode, PermissionOutcome, PermissionPolicy, PermissionPromptDecision,
PermissionPrompter, PermissionRequest,
PermissionContext, PermissionMode, PermissionOutcome, PermissionOverride, PermissionPolicy,
PermissionPromptDecision, PermissionPrompter, PermissionRequest,
};
pub use prompt::{
load_system_prompt, prepend_bullets, ContextFile, ProjectContext, PromptBuildError,

View File

@@ -1144,20 +1144,8 @@ mod tests {
}
fn cleanup_script(script_path: &Path) {
if let Err(error) = fs::remove_file(script_path) {
assert_eq!(
error.kind(),
std::io::ErrorKind::NotFound,
"cleanup script: {error}"
);
}
if let Err(error) = fs::remove_dir_all(script_path.parent().expect("script parent")) {
assert_eq!(
error.kind(),
std::io::ErrorKind::NotFound,
"cleanup dir: {error}"
);
}
fs::remove_file(script_path).expect("cleanup script");
fs::remove_dir_all(script_path.parent().expect("script parent")).expect("cleanup dir");
}
fn manager_server_config(

View File

@@ -1,5 +1,9 @@
use std::collections::BTreeMap;
use serde_json::Value;
use crate::config::RuntimePermissionRuleConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum PermissionMode {
ReadOnly,
@@ -22,12 +26,49 @@ impl PermissionMode {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionOverride {
Allow,
Deny,
Ask,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PermissionContext {
override_decision: Option<PermissionOverride>,
override_reason: Option<String>,
}
impl PermissionContext {
#[must_use]
pub fn new(
override_decision: Option<PermissionOverride>,
override_reason: Option<String>,
) -> Self {
Self {
override_decision,
override_reason,
}
}
#[must_use]
pub fn override_decision(&self) -> Option<PermissionOverride> {
self.override_decision
}
#[must_use]
pub fn override_reason(&self) -> Option<&str> {
self.override_reason.as_deref()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PermissionRequest {
pub tool_name: String,
pub input: String,
pub current_mode: PermissionMode,
pub required_mode: PermissionMode,
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -50,6 +91,9 @@ pub enum PermissionOutcome {
pub struct PermissionPolicy {
active_mode: PermissionMode,
tool_requirements: BTreeMap<String, PermissionMode>,
allow_rules: Vec<PermissionRule>,
deny_rules: Vec<PermissionRule>,
ask_rules: Vec<PermissionRule>,
}
impl PermissionPolicy {
@@ -58,6 +102,9 @@ impl PermissionPolicy {
Self {
active_mode,
tool_requirements: BTreeMap::new(),
allow_rules: Vec::new(),
deny_rules: Vec::new(),
ask_rules: Vec::new(),
}
}
@@ -72,6 +119,26 @@ impl PermissionPolicy {
self
}
#[must_use]
pub fn with_permission_rules(mut self, config: &RuntimePermissionRuleConfig) -> Self {
self.allow_rules = config
.allow()
.iter()
.map(|rule| PermissionRule::parse(rule))
.collect();
self.deny_rules = config
.deny()
.iter()
.map(|rule| PermissionRule::parse(rule))
.collect();
self.ask_rules = config
.ask()
.iter()
.map(|rule| PermissionRule::parse(rule))
.collect();
self
}
#[must_use]
pub fn active_mode(&self) -> PermissionMode {
self.active_mode
@@ -90,38 +157,121 @@ impl PermissionPolicy {
&self,
tool_name: &str,
input: &str,
mut prompter: Option<&mut dyn PermissionPrompter>,
prompter: Option<&mut dyn PermissionPrompter>,
) -> PermissionOutcome {
let current_mode = self.active_mode();
let required_mode = self.required_mode_for(tool_name);
if current_mode == PermissionMode::Allow || current_mode >= required_mode {
return PermissionOutcome::Allow;
self.authorize_with_context(tool_name, input, &PermissionContext::default(), prompter)
}
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn authorize_with_context(
&self,
tool_name: &str,
input: &str,
context: &PermissionContext,
prompter: Option<&mut dyn PermissionPrompter>,
) -> PermissionOutcome {
if let Some(rule) = Self::find_matching_rule(&self.deny_rules, tool_name, input) {
return PermissionOutcome::Deny {
reason: format!(
"Permission to use {tool_name} has been denied by rule '{}'",
rule.raw
),
};
}
let request = PermissionRequest {
tool_name: tool_name.to_string(),
input: input.to_string(),
current_mode,
required_mode,
};
let current_mode = self.active_mode();
let required_mode = self.required_mode_for(tool_name);
let ask_rule = Self::find_matching_rule(&self.ask_rules, tool_name, input);
let allow_rule = Self::find_matching_rule(&self.allow_rules, tool_name, input);
match context.override_decision() {
Some(PermissionOverride::Deny) => {
return PermissionOutcome::Deny {
reason: context.override_reason().map_or_else(
|| format!("tool '{tool_name}' denied by hook"),
ToOwned::to_owned,
),
};
}
Some(PermissionOverride::Ask) => {
let reason = context.override_reason().map_or_else(
|| format!("tool '{tool_name}' requires approval due to hook guidance"),
ToOwned::to_owned,
);
return Self::prompt_or_deny(
tool_name,
input,
current_mode,
required_mode,
Some(reason),
prompter,
);
}
Some(PermissionOverride::Allow) => {
if let Some(rule) = ask_rule {
let reason = format!(
"tool '{tool_name}' requires approval due to ask rule '{}'",
rule.raw
);
return Self::prompt_or_deny(
tool_name,
input,
current_mode,
required_mode,
Some(reason),
prompter,
);
}
if allow_rule.is_some()
|| current_mode == PermissionMode::Allow
|| current_mode >= required_mode
{
return PermissionOutcome::Allow;
}
}
None => {}
}
if let Some(rule) = ask_rule {
let reason = format!(
"tool '{tool_name}' requires approval due to ask rule '{}'",
rule.raw
);
return Self::prompt_or_deny(
tool_name,
input,
current_mode,
required_mode,
Some(reason),
prompter,
);
}
if allow_rule.is_some()
|| current_mode == PermissionMode::Allow
|| current_mode >= required_mode
{
return PermissionOutcome::Allow;
}
if current_mode == PermissionMode::Prompt
|| (current_mode == PermissionMode::WorkspaceWrite
&& required_mode == PermissionMode::DangerFullAccess)
{
return match prompter.as_mut() {
Some(prompter) => match prompter.decide(&request) {
PermissionPromptDecision::Allow => PermissionOutcome::Allow,
PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason },
},
None => PermissionOutcome::Deny {
reason: format!(
"tool '{tool_name}' requires approval to escalate from {} to {}",
current_mode.as_str(),
required_mode.as_str()
),
},
};
let reason = Some(format!(
"tool '{tool_name}' requires approval to escalate from {} to {}",
current_mode.as_str(),
required_mode.as_str()
));
return Self::prompt_or_deny(
tool_name,
input,
current_mode,
required_mode,
reason,
prompter,
);
}
PermissionOutcome::Deny {
@@ -132,14 +282,191 @@ impl PermissionPolicy {
),
}
}
fn prompt_or_deny(
tool_name: &str,
input: &str,
current_mode: PermissionMode,
required_mode: PermissionMode,
reason: Option<String>,
mut prompter: Option<&mut dyn PermissionPrompter>,
) -> PermissionOutcome {
let request = PermissionRequest {
tool_name: tool_name.to_string(),
input: input.to_string(),
current_mode,
required_mode,
reason: reason.clone(),
};
match prompter.as_mut() {
Some(prompter) => match prompter.decide(&request) {
PermissionPromptDecision::Allow => PermissionOutcome::Allow,
PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason },
},
None => PermissionOutcome::Deny {
reason: reason.unwrap_or_else(|| {
format!(
"tool '{tool_name}' requires approval to run while mode is {}",
current_mode.as_str()
)
}),
},
}
}
fn find_matching_rule<'a>(
rules: &'a [PermissionRule],
tool_name: &str,
input: &str,
) -> Option<&'a PermissionRule> {
rules.iter().find(|rule| rule.matches(tool_name, input))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PermissionRule {
raw: String,
tool_name: String,
matcher: PermissionRuleMatcher,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum PermissionRuleMatcher {
Any,
Exact(String),
Prefix(String),
}
impl PermissionRule {
fn parse(raw: &str) -> Self {
let trimmed = raw.trim();
let open = find_first_unescaped(trimmed, '(');
let close = find_last_unescaped(trimmed, ')');
if let (Some(open), Some(close)) = (open, close) {
if close == trimmed.len() - 1 && open < close {
let tool_name = trimmed[..open].trim();
let content = &trimmed[open + 1..close];
if !tool_name.is_empty() {
let matcher = parse_rule_matcher(content);
return Self {
raw: trimmed.to_string(),
tool_name: tool_name.to_string(),
matcher,
};
}
}
}
Self {
raw: trimmed.to_string(),
tool_name: trimmed.to_string(),
matcher: PermissionRuleMatcher::Any,
}
}
fn matches(&self, tool_name: &str, input: &str) -> bool {
if self.tool_name != tool_name {
return false;
}
match &self.matcher {
PermissionRuleMatcher::Any => true,
PermissionRuleMatcher::Exact(expected) => {
extract_permission_subject(input).is_some_and(|candidate| candidate == *expected)
}
PermissionRuleMatcher::Prefix(prefix) => extract_permission_subject(input)
.is_some_and(|candidate| candidate.starts_with(prefix)),
}
}
}
fn parse_rule_matcher(content: &str) -> PermissionRuleMatcher {
let unescaped = unescape_rule_content(content.trim());
if unescaped.is_empty() || unescaped == "*" {
PermissionRuleMatcher::Any
} else if let Some(prefix) = unescaped.strip_suffix(":*") {
PermissionRuleMatcher::Prefix(prefix.to_string())
} else {
PermissionRuleMatcher::Exact(unescaped)
}
}
fn unescape_rule_content(content: &str) -> String {
content
.replace(r"\(", "(")
.replace(r"\)", ")")
.replace(r"\\", r"\")
}
fn find_first_unescaped(value: &str, needle: char) -> Option<usize> {
let mut escaped = false;
for (idx, ch) in value.char_indices() {
if ch == '\\' {
escaped = !escaped;
continue;
}
if ch == needle && !escaped {
return Some(idx);
}
escaped = false;
}
None
}
fn find_last_unescaped(value: &str, needle: char) -> Option<usize> {
let chars = value.char_indices().collect::<Vec<_>>();
for (pos, (idx, ch)) in chars.iter().enumerate().rev() {
if *ch != needle {
continue;
}
let mut backslashes = 0;
for (_, prev) in chars[..pos].iter().rev() {
if *prev == '\\' {
backslashes += 1;
} else {
break;
}
}
if backslashes % 2 == 0 {
return Some(*idx);
}
}
None
}
fn extract_permission_subject(input: &str) -> Option<String> {
let parsed = serde_json::from_str::<Value>(input).ok();
if let Some(Value::Object(object)) = parsed {
for key in [
"command",
"path",
"file_path",
"filePath",
"notebook_path",
"notebookPath",
"url",
"pattern",
"code",
"message",
] {
if let Some(value) = object.get(key).and_then(Value::as_str) {
return Some(value.to_string());
}
}
}
(!input.trim().is_empty()).then(|| input.to_string())
}
#[cfg(test)]
mod tests {
use super::{
PermissionMode, PermissionOutcome, PermissionPolicy, PermissionPromptDecision,
PermissionPrompter, PermissionRequest,
PermissionContext, PermissionMode, PermissionOutcome, PermissionOverride, PermissionPolicy,
PermissionPromptDecision, PermissionPrompter, PermissionRequest,
};
use crate::config::RuntimePermissionRuleConfig;
struct RecordingPrompter {
seen: Vec<PermissionRequest>,
@@ -229,4 +556,120 @@ mod tests {
PermissionOutcome::Deny { reason } if reason == "not now"
));
}
#[test]
fn applies_rule_based_denials_and_allows() {
let rules = RuntimePermissionRuleConfig::new(
vec!["bash(git:*)".to_string()],
vec!["bash(rm -rf:*)".to_string()],
Vec::new(),
);
let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess)
.with_permission_rules(&rules);
assert_eq!(
policy.authorize("bash", r#"{"command":"git status"}"#, None),
PermissionOutcome::Allow
);
assert!(matches!(
policy.authorize("bash", r#"{"command":"rm -rf /tmp/x"}"#, None),
PermissionOutcome::Deny { reason } if reason.contains("denied by rule")
));
}
#[test]
fn ask_rules_force_prompt_even_when_mode_allows() {
let rules = RuntimePermissionRuleConfig::new(
Vec::new(),
Vec::new(),
vec!["bash(git:*)".to_string()],
);
let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess)
.with_permission_rules(&rules);
let mut prompter = RecordingPrompter {
seen: Vec::new(),
allow: true,
};
let outcome = policy.authorize("bash", r#"{"command":"git status"}"#, Some(&mut prompter));
assert_eq!(outcome, PermissionOutcome::Allow);
assert_eq!(prompter.seen.len(), 1);
assert!(prompter.seen[0]
.reason
.as_deref()
.is_some_and(|reason| reason.contains("ask rule")));
}
#[test]
fn hook_allow_still_respects_ask_rules() {
let rules = RuntimePermissionRuleConfig::new(
Vec::new(),
Vec::new(),
vec!["bash(git:*)".to_string()],
);
let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess)
.with_permission_rules(&rules);
let context = PermissionContext::new(
Some(PermissionOverride::Allow),
Some("hook approved".to_string()),
);
let mut prompter = RecordingPrompter {
seen: Vec::new(),
allow: true,
};
let outcome = policy.authorize_with_context(
"bash",
r#"{"command":"git status"}"#,
&context,
Some(&mut prompter),
);
assert_eq!(outcome, PermissionOutcome::Allow);
assert_eq!(prompter.seen.len(), 1);
}
#[test]
fn hook_deny_short_circuits_permission_flow() {
let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess);
let context = PermissionContext::new(
Some(PermissionOverride::Deny),
Some("blocked by hook".to_string()),
);
assert_eq!(
policy.authorize_with_context("bash", "{}", &context, None),
PermissionOutcome::Deny {
reason: "blocked by hook".to_string(),
}
);
}
#[test]
fn hook_ask_forces_prompt() {
let policy = PermissionPolicy::new(PermissionMode::DangerFullAccess)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess);
let context = PermissionContext::new(
Some(PermissionOverride::Ask),
Some("hook requested confirmation".to_string()),
);
let mut prompter = RecordingPrompter {
seen: Vec::new(),
allow: true,
};
let outcome = policy.authorize_with_context("bash", "{}", &context, Some(&mut prompter));
assert_eq!(outcome, PermissionOutcome::Allow);
assert_eq!(prompter.seen.len(), 1);
assert_eq!(
prompter.seen[0].reason.as_deref(),
Some("hook requested confirmation")
);
}
}

View File

@@ -421,7 +421,7 @@ fn render_config_section(config: &RuntimeConfig) -> String {
let mut lines = vec!["# Runtime config".to_string()];
if config.loaded_entries().is_empty() {
lines.extend(prepend_bullets(vec![
"No Claude Code settings files loaded.".to_string(),
"No Claw Code settings files loaded.".to_string()
]));
return lines.join("\n");
}

View File

@@ -17,9 +17,10 @@ crossterm = "0.28"
pulldown-cmark = "0.13"
rustyline = "15"
runtime = { path = "../runtime" }
plugins = { path = "../plugins" }
serde_json = "1"
syntect = "5"
tokio = { version = "1", features = ["rt-multi-thread", "time"] }
tokio = { version = "1", features = ["rt-multi-thread", "signal", "time"] }
tools = { path = "../tools" }
[lints]

View File

@@ -8,7 +8,7 @@ const STARTER_CLAUDE_JSON: &str = concat!(
" }\n",
"}\n",
);
const GITIGNORE_COMMENT: &str = "# Claude Code local artifacts";
const GITIGNORE_COMMENT: &str = "# Claw Code local artifacts";
const GITIGNORE_ENTRIES: [&str; 2] = [".claude/settings.local.json", ".claude/sessions/"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -164,7 +164,7 @@ pub(crate) fn render_init_claude_md(cwd: &Path) -> String {
let mut lines = vec![
"# CLAUDE.md".to_string(),
String::new(),
"This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.".to_string(),
"This file provides guidance to Claw Code (clawcode.dev) when working with code in this repository.".to_string(),
String::new(),
];

File diff suppressed because it is too large Load Diff

View File

@@ -1,526 +0,0 @@
use std::fmt::{Debug, Formatter};
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
pub const DEFAULT_ANTHROPIC_VERSION: &str = "2023-06-01";
pub const DEFAULT_APP_NAME: &str = "claude-code";
pub const DEFAULT_RUNTIME: &str = "rust";
pub const DEFAULT_AGENTIC_BETA: &str = "claude-code-20250219";
pub const DEFAULT_PROMPT_CACHING_SCOPE_BETA: &str = "prompt-caching-scope-2026-01-05";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientIdentity {
pub app_name: String,
pub app_version: String,
pub runtime: String,
}
impl ClientIdentity {
#[must_use]
pub fn new(app_name: impl Into<String>, app_version: impl Into<String>) -> Self {
Self {
app_name: app_name.into(),
app_version: app_version.into(),
runtime: DEFAULT_RUNTIME.to_string(),
}
}
#[must_use]
pub fn with_runtime(mut self, runtime: impl Into<String>) -> Self {
self.runtime = runtime.into();
self
}
#[must_use]
pub fn user_agent(&self) -> String {
format!("{}/{}", self.app_name, self.app_version)
}
}
impl Default for ClientIdentity {
fn default() -> Self {
Self::new(DEFAULT_APP_NAME, env!("CARGO_PKG_VERSION"))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AnthropicRequestProfile {
pub anthropic_version: String,
pub client_identity: ClientIdentity,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub betas: Vec<String>,
#[serde(default, skip_serializing_if = "Map::is_empty")]
pub extra_body: Map<String, Value>,
}
impl AnthropicRequestProfile {
#[must_use]
pub fn new(client_identity: ClientIdentity) -> Self {
Self {
anthropic_version: DEFAULT_ANTHROPIC_VERSION.to_string(),
client_identity,
betas: vec![
DEFAULT_AGENTIC_BETA.to_string(),
DEFAULT_PROMPT_CACHING_SCOPE_BETA.to_string(),
],
extra_body: Map::new(),
}
}
#[must_use]
pub fn with_beta(mut self, beta: impl Into<String>) -> Self {
let beta = beta.into();
if !self.betas.contains(&beta) {
self.betas.push(beta);
}
self
}
#[must_use]
pub fn with_extra_body(mut self, key: impl Into<String>, value: Value) -> Self {
self.extra_body.insert(key.into(), value);
self
}
#[must_use]
pub fn header_pairs(&self) -> Vec<(String, String)> {
let mut headers = vec![
(
"anthropic-version".to_string(),
self.anthropic_version.clone(),
),
("user-agent".to_string(), self.client_identity.user_agent()),
];
if !self.betas.is_empty() {
headers.push(("anthropic-beta".to_string(), self.betas.join(",")));
}
headers
}
pub fn render_json_body<T: Serialize>(&self, request: &T) -> Result<Value, serde_json::Error> {
let mut body = serde_json::to_value(request)?;
let object = body.as_object_mut().ok_or_else(|| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"request body must serialize to a JSON object",
))
})?;
for (key, value) in &self.extra_body {
object.insert(key.clone(), value.clone());
}
if !self.betas.is_empty() {
object.insert(
"betas".to_string(),
Value::Array(self.betas.iter().cloned().map(Value::String).collect()),
);
}
Ok(body)
}
}
impl Default for AnthropicRequestProfile {
fn default() -> Self {
Self::new(ClientIdentity::default())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AnalyticsEvent {
pub namespace: String,
pub action: String,
#[serde(default, skip_serializing_if = "Map::is_empty")]
pub properties: Map<String, Value>,
}
impl AnalyticsEvent {
#[must_use]
pub fn new(namespace: impl Into<String>, action: impl Into<String>) -> Self {
Self {
namespace: namespace.into(),
action: action.into(),
properties: Map::new(),
}
}
#[must_use]
pub fn with_property(mut self, key: impl Into<String>, value: Value) -> Self {
self.properties.insert(key.into(), value);
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SessionTraceRecord {
pub session_id: String,
pub sequence: u64,
pub name: String,
pub timestamp_ms: u64,
#[serde(default, skip_serializing_if = "Map::is_empty")]
pub attributes: Map<String, Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TelemetryEvent {
HttpRequestStarted {
session_id: String,
attempt: u32,
method: String,
path: String,
#[serde(default, skip_serializing_if = "Map::is_empty")]
attributes: Map<String, Value>,
},
HttpRequestSucceeded {
session_id: String,
attempt: u32,
method: String,
path: String,
status: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
request_id: Option<String>,
#[serde(default, skip_serializing_if = "Map::is_empty")]
attributes: Map<String, Value>,
},
HttpRequestFailed {
session_id: String,
attempt: u32,
method: String,
path: String,
error: String,
retryable: bool,
#[serde(default, skip_serializing_if = "Map::is_empty")]
attributes: Map<String, Value>,
},
Analytics(AnalyticsEvent),
SessionTrace(SessionTraceRecord),
}
pub trait TelemetrySink: Send + Sync {
fn record(&self, event: TelemetryEvent);
}
#[derive(Default)]
pub struct MemoryTelemetrySink {
events: Mutex<Vec<TelemetryEvent>>,
}
impl MemoryTelemetrySink {
#[must_use]
pub fn events(&self) -> Vec<TelemetryEvent> {
self.events
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
}
impl TelemetrySink for MemoryTelemetrySink {
fn record(&self, event: TelemetryEvent) {
self.events
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(event);
}
}
pub struct JsonlTelemetrySink {
path: PathBuf,
file: Mutex<File>,
}
impl Debug for JsonlTelemetrySink {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JsonlTelemetrySink")
.field("path", &self.path)
.finish_non_exhaustive()
}
}
impl JsonlTelemetrySink {
pub fn new(path: impl AsRef<Path>) -> Result<Self, std::io::Error> {
let path = path.as_ref().to_path_buf();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = OpenOptions::new().create(true).append(true).open(&path)?;
Ok(Self {
path,
file: Mutex::new(file),
})
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
}
impl TelemetrySink for JsonlTelemetrySink {
fn record(&self, event: TelemetryEvent) {
let Ok(line) = serde_json::to_string(&event) else {
return;
};
let mut file = self
.file
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _ = writeln!(file, "{line}");
let _ = file.flush();
}
}
#[derive(Clone)]
pub struct SessionTracer {
session_id: String,
sequence: Arc<AtomicU64>,
sink: Arc<dyn TelemetrySink>,
}
impl Debug for SessionTracer {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SessionTracer")
.field("session_id", &self.session_id)
.finish_non_exhaustive()
}
}
impl SessionTracer {
#[must_use]
pub fn new(session_id: impl Into<String>, sink: Arc<dyn TelemetrySink>) -> Self {
Self {
session_id: session_id.into(),
sequence: Arc::new(AtomicU64::new(0)),
sink,
}
}
#[must_use]
pub fn session_id(&self) -> &str {
&self.session_id
}
pub fn record(&self, name: impl Into<String>, attributes: Map<String, Value>) {
let record = SessionTraceRecord {
session_id: self.session_id.clone(),
sequence: self.sequence.fetch_add(1, Ordering::Relaxed),
name: name.into(),
timestamp_ms: current_timestamp_ms(),
attributes,
};
self.sink.record(TelemetryEvent::SessionTrace(record));
}
pub fn record_http_request_started(
&self,
attempt: u32,
method: impl Into<String>,
path: impl Into<String>,
attributes: Map<String, Value>,
) {
let method = method.into();
let path = path.into();
self.sink.record(TelemetryEvent::HttpRequestStarted {
session_id: self.session_id.clone(),
attempt,
method: method.clone(),
path: path.clone(),
attributes: attributes.clone(),
});
self.record(
"http_request_started",
merge_trace_fields(method, path, attempt, attributes),
);
}
pub fn record_http_request_succeeded(
&self,
attempt: u32,
method: impl Into<String>,
path: impl Into<String>,
status: u16,
request_id: Option<String>,
attributes: Map<String, Value>,
) {
let method = method.into();
let path = path.into();
self.sink.record(TelemetryEvent::HttpRequestSucceeded {
session_id: self.session_id.clone(),
attempt,
method: method.clone(),
path: path.clone(),
status,
request_id: request_id.clone(),
attributes: attributes.clone(),
});
let mut trace_attributes = merge_trace_fields(method, path, attempt, attributes);
trace_attributes.insert("status".to_string(), Value::from(status));
if let Some(request_id) = request_id {
trace_attributes.insert("request_id".to_string(), Value::String(request_id));
}
self.record("http_request_succeeded", trace_attributes);
}
pub fn record_http_request_failed(
&self,
attempt: u32,
method: impl Into<String>,
path: impl Into<String>,
error: impl Into<String>,
retryable: bool,
attributes: Map<String, Value>,
) {
let method = method.into();
let path = path.into();
let error = error.into();
self.sink.record(TelemetryEvent::HttpRequestFailed {
session_id: self.session_id.clone(),
attempt,
method: method.clone(),
path: path.clone(),
error: error.clone(),
retryable,
attributes: attributes.clone(),
});
let mut trace_attributes = merge_trace_fields(method, path, attempt, attributes);
trace_attributes.insert("error".to_string(), Value::String(error));
trace_attributes.insert("retryable".to_string(), Value::Bool(retryable));
self.record("http_request_failed", trace_attributes);
}
pub fn record_analytics(&self, event: AnalyticsEvent) {
let mut attributes = event.properties.clone();
attributes.insert(
"namespace".to_string(),
Value::String(event.namespace.clone()),
);
attributes.insert("action".to_string(), Value::String(event.action.clone()));
self.sink.record(TelemetryEvent::Analytics(event));
self.record("analytics", attributes);
}
}
fn merge_trace_fields(
method: String,
path: String,
attempt: u32,
mut attributes: Map<String, Value>,
) -> Map<String, Value> {
attributes.insert("method".to_string(), Value::String(method));
attributes.insert("path".to_string(), Value::String(path));
attributes.insert("attempt".to_string(), Value::from(attempt));
attributes
}
fn current_timestamp_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.try_into()
.unwrap_or(u64::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_profile_emits_headers_and_merges_body() {
let profile = AnthropicRequestProfile::new(
ClientIdentity::new("claude-code", "1.2.3").with_runtime("rust-cli"),
)
.with_beta("tools-2026-04-01")
.with_extra_body("metadata", serde_json::json!({"source": "test"}));
assert_eq!(
profile.header_pairs(),
vec![
(
"anthropic-version".to_string(),
DEFAULT_ANTHROPIC_VERSION.to_string()
),
("user-agent".to_string(), "claude-code/1.2.3".to_string()),
(
"anthropic-beta".to_string(),
"claude-code-20250219,prompt-caching-scope-2026-01-05,tools-2026-04-01"
.to_string(),
),
]
);
let body = profile
.render_json_body(&serde_json::json!({"model": "claude-sonnet"}))
.expect("body should serialize");
assert_eq!(
body["metadata"]["source"],
Value::String("test".to_string())
);
assert_eq!(
body["betas"],
serde_json::json!([
"claude-code-20250219",
"prompt-caching-scope-2026-01-05",
"tools-2026-04-01"
])
);
}
#[test]
fn session_tracer_records_structured_events_and_trace_sequence() {
let sink = Arc::new(MemoryTelemetrySink::default());
let tracer = SessionTracer::new("session-123", sink.clone());
tracer.record_http_request_started(1, "POST", "/v1/messages", Map::new());
tracer.record_analytics(
AnalyticsEvent::new("cli", "prompt_sent")
.with_property("model", Value::String("claude-opus".to_string())),
);
let events = sink.events();
assert!(matches!(
&events[0],
TelemetryEvent::HttpRequestStarted {
session_id,
attempt: 1,
method,
path,
..
} if session_id == "session-123" && method == "POST" && path == "/v1/messages"
));
assert!(matches!(
&events[1],
TelemetryEvent::SessionTrace(SessionTraceRecord { sequence: 0, name, .. })
if name == "http_request_started"
));
assert!(matches!(&events[2], TelemetryEvent::Analytics(_)));
assert!(matches!(
&events[3],
TelemetryEvent::SessionTrace(SessionTraceRecord { sequence: 1, name, .. })
if name == "analytics"
));
}
#[test]
fn jsonl_sink_persists_events() {
let path =
std::env::temp_dir().join(format!("telemetry-jsonl-{}.log", current_timestamp_ms()));
let sink = JsonlTelemetrySink::new(&path).expect("sink should create file");
sink.record(TelemetryEvent::Analytics(
AnalyticsEvent::new("cli", "turn_completed").with_property("ok", Value::Bool(true)),
));
let contents = std::fs::read_to_string(&path).expect("telemetry log should be readable");
assert!(contents.contains("\"type\":\"analytics\""));
assert!(contents.contains("\"action\":\"turn_completed\""));
let _ = std::fs::remove_file(path);
}
}

View File

@@ -7,6 +7,7 @@ publish.workspace = true
[dependencies]
api = { path = "../api" }
plugins = { path = "../plugins" }
runtime = { path = "../runtime" }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }

View File

@@ -8,12 +8,13 @@ use api::{
MessageRequest, MessageResponse, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice,
ToolDefinition, ToolResultContentBlock,
};
use plugins::{PluginManager, PluginManagerConfig, PluginTool};
use reqwest::blocking::Client;
use runtime::{
edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file,
ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ContentBlock, ConversationMessage,
ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode, PermissionPolicy,
RuntimeError, Session, ToolError, ToolExecutor,
ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ConfigLoader, ContentBlock,
ConversationMessage, ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode,
PermissionPolicy, RuntimeConfig, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
@@ -55,6 +56,239 @@ pub struct ToolSpec {
pub required_permission: PermissionMode,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RegisteredTool {
pub definition: ToolDefinition,
pub required_permission: PermissionMode,
handler: RegisteredToolHandler,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq)]
enum RegisteredToolHandler {
Builtin,
Plugin(PluginTool),
}
#[derive(Debug, Clone, PartialEq)]
pub struct GlobalToolRegistry {
entries: Vec<RegisteredTool>,
}
impl GlobalToolRegistry {
#[must_use]
pub fn builtin() -> Self {
Self {
entries: mvp_tool_specs()
.into_iter()
.map(|spec| RegisteredTool {
definition: ToolDefinition {
name: spec.name.to_string(),
description: Some(spec.description.to_string()),
input_schema: spec.input_schema,
},
required_permission: spec.required_permission,
handler: RegisteredToolHandler::Builtin,
})
.collect(),
}
}
pub fn with_plugin_tools(plugin_tools: Vec<PluginTool>) -> Result<Self, String> {
let mut registry = Self::builtin();
let mut seen = registry
.entries
.iter()
.map(|entry| {
(
normalize_registry_tool_name(&entry.definition.name),
entry.definition.name.clone(),
)
})
.collect::<BTreeMap<_, _>>();
for tool in plugin_tools {
let normalized = normalize_registry_tool_name(&tool.definition().name);
if let Some(existing) = seen.get(&normalized) {
return Err(format!(
"plugin tool `{}` from `{}` conflicts with already-registered tool `{existing}`",
tool.definition().name,
tool.plugin_id()
));
}
seen.insert(normalized, tool.definition().name.clone());
registry.entries.push(RegisteredTool {
definition: ToolDefinition {
name: tool.definition().name.clone(),
description: tool.definition().description.clone(),
input_schema: tool.definition().input_schema.clone(),
},
required_permission: permission_mode_from_plugin_tool(tool.required_permission())?,
handler: RegisteredToolHandler::Plugin(tool),
});
}
Ok(registry)
}
#[must_use]
pub fn entries(&self) -> &[RegisteredTool] {
&self.entries
}
fn find_entry(&self, name: &str) -> Option<&RegisteredTool> {
let normalized = normalize_registry_tool_name(name);
self.entries.iter().find(|entry| {
normalize_registry_tool_name(entry.definition.name.as_str()) == normalized
})
}
#[must_use]
pub fn definitions(&self, allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolDefinition> {
self.entries
.iter()
.filter(|entry| {
allowed_tools.is_none_or(|allowed| allowed.contains(entry.definition.name.as_str()))
})
.map(|entry| entry.definition.clone())
.collect()
}
#[must_use]
pub fn permission_specs(
&self,
allowed_tools: Option<&BTreeSet<String>>,
) -> Vec<(String, PermissionMode)> {
self.entries
.iter()
.filter(|entry| {
allowed_tools.is_none_or(|allowed| allowed.contains(entry.definition.name.as_str()))
})
.map(|entry| (entry.definition.name.clone(), entry.required_permission))
.collect()
}
pub fn normalize_allowed_tools(
&self,
values: &[String],
) -> Result<Option<BTreeSet<String>>, String> {
if values.is_empty() {
return Ok(None);
}
let canonical_names = self
.entries
.iter()
.map(|entry| entry.definition.name.clone())
.collect::<Vec<_>>();
let mut name_map = canonical_names
.iter()
.map(|name| (normalize_registry_tool_name(name), name.clone()))
.collect::<BTreeMap<_, _>>();
for (alias, canonical) in [
("read", "read_file"),
("write", "write_file"),
("edit", "edit_file"),
("glob", "glob_search"),
("grep", "grep_search"),
] {
if canonical_names.iter().any(|name| name == canonical) {
name_map.insert(alias.to_string(), canonical.to_string());
}
}
let mut allowed = BTreeSet::new();
for value in values {
for token in value
.split(|ch: char| ch == ',' || ch.is_whitespace())
.filter(|token| !token.is_empty())
{
let normalized = normalize_registry_tool_name(token);
let canonical = name_map.get(&normalized).ok_or_else(|| {
format!(
"unsupported tool in --allowedTools: {token} (expected one of: {})",
canonical_names.join(", ")
)
})?;
allowed.insert(canonical.clone());
}
}
Ok(Some(allowed))
}
pub fn execute(&self, name: &str, input: &Value) -> Result<String, String> {
let entry = self
.find_entry(name)
.ok_or_else(|| format!("unsupported tool: {name}"))?;
match &entry.handler {
RegisteredToolHandler::Builtin => execute_tool(&entry.definition.name, input),
RegisteredToolHandler::Plugin(tool) => {
tool.execute(input).map_err(|error| error.to_string())
}
}
}
}
impl Default for GlobalToolRegistry {
fn default() -> Self {
Self::builtin()
}
}
fn normalize_registry_tool_name(value: &str) -> String {
let trimmed = value.trim();
let chars = trimmed.chars().collect::<Vec<_>>();
let mut normalized = String::new();
for (index, ch) in chars.iter().copied().enumerate() {
if matches!(ch, '-' | ' ' | '\t' | '\n') {
if !normalized.ends_with('_') {
normalized.push('_');
}
continue;
}
if ch == '_' {
if !normalized.ends_with('_') {
normalized.push('_');
}
continue;
}
if ch.is_uppercase() {
let prev = chars.get(index.wrapping_sub(1)).copied();
let next = chars.get(index + 1).copied();
let needs_separator = index > 0
&& !normalized.ends_with('_')
&& (prev.is_some_and(|prev| prev.is_lowercase() || prev.is_ascii_digit())
|| (prev.is_some_and(char::is_uppercase)
&& next.is_some_and(char::is_lowercase)));
if needs_separator {
normalized.push('_');
}
normalized.extend(ch.to_lowercase());
continue;
}
normalized.push(ch.to_ascii_lowercase());
}
normalized.trim_matches('_').to_string()
}
fn permission_mode_from_plugin_tool(value: &str) -> Result<PermissionMode, String> {
match value {
"read-only" => Ok(PermissionMode::ReadOnly),
"workspace-write" => Ok(PermissionMode::WorkspaceWrite),
"danger-full-access" => Ok(PermissionMode::DangerFullAccess),
other => Err(format!(
"unsupported plugin tool permission `{other}` (expected read-only, workspace-write, or danger-full-access)"
)),
}
}
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn mvp_tool_specs() -> Vec<ToolSpec> {
@@ -323,7 +557,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
},
ToolSpec {
name: "Config",
description: "Get or set Claude Code settings.",
description: "Get or set Claw Code settings.",
input_schema: json!({
"type": "object",
"properties": {
@@ -1466,13 +1700,15 @@ fn build_agent_runtime(
.clone()
.unwrap_or_else(|| DEFAULT_AGENT_MODEL.to_string());
let allowed_tools = job.allowed_tools.clone();
let api_client = AnthropicRuntimeClient::new(model, allowed_tools.clone())?;
let tool_executor = SubagentToolExecutor::new(allowed_tools);
let tool_registry = current_tool_registry()?;
let api_client =
AnthropicRuntimeClient::new(model, allowed_tools.clone(), tool_registry.clone())?;
let tool_executor = SubagentToolExecutor::new(allowed_tools, tool_registry.clone());
Ok(ConversationRuntime::new(
Session::new(),
api_client,
tool_executor,
agent_permission_policy(),
agent_permission_policy(&tool_registry),
job.system_prompt.clone(),
))
}
@@ -1537,7 +1773,7 @@ fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet<String> {
"SendUserMessage",
"PowerShell",
],
"claude-code-guide" => vec![
"claw-code-guide" => vec![
"read_file",
"glob_search",
"grep_search",
@@ -1581,10 +1817,12 @@ fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet<String> {
tools.into_iter().map(str::to_string).collect()
}
fn agent_permission_policy() -> PermissionPolicy {
mvp_tool_specs().into_iter().fold(
fn agent_permission_policy(tool_registry: &GlobalToolRegistry) -> PermissionPolicy {
tool_registry.permission_specs(None).into_iter().fold(
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|policy, spec| policy.with_tool_requirement(spec.name, spec.required_permission),
|policy, (name, required_permission)| {
policy.with_tool_requirement(name, required_permission)
},
)
}
@@ -1640,10 +1878,15 @@ struct AnthropicRuntimeClient {
client: AnthropicClient,
model: String,
allowed_tools: BTreeSet<String>,
tool_registry: GlobalToolRegistry,
}
impl AnthropicRuntimeClient {
fn new(model: String, allowed_tools: BTreeSet<String>) -> Result<Self, String> {
fn new(
model: String,
allowed_tools: BTreeSet<String>,
tool_registry: GlobalToolRegistry,
) -> Result<Self, String> {
let client = AnthropicClient::from_env()
.map_err(|error| error.to_string())?
.with_base_url(read_base_url());
@@ -1652,20 +1895,14 @@ impl AnthropicRuntimeClient {
client,
model,
allowed_tools,
tool_registry,
})
}
}
impl ApiClient for AnthropicRuntimeClient {
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
let tools = tool_specs_for_allowed_tools(Some(&self.allowed_tools))
.into_iter()
.map(|spec| ToolDefinition {
name: spec.name.to_string(),
description: Some(spec.description.to_string()),
input_schema: spec.input_schema,
})
.collect::<Vec<_>>();
let tools = self.tool_registry.definitions(Some(&self.allowed_tools));
let message_request = MessageRequest {
model: self.model.clone(),
max_tokens: 32_000,
@@ -1716,6 +1953,8 @@ impl ApiClient for AnthropicRuntimeClient {
input.push_str(&partial_json);
}
}
ContentBlockDelta::ThinkingDelta { .. }
| ContentBlockDelta::SignatureDelta { .. } => {}
},
ApiStreamEvent::ContentBlockStop(_) => {
if let Some((id, name, input)) = pending_tool.take() {
@@ -1723,7 +1962,12 @@ impl ApiClient for AnthropicRuntimeClient {
}
}
ApiStreamEvent::MessageDelta(delta) => {
events.push(AssistantEvent::Usage(delta.usage.token_usage()));
events.push(AssistantEvent::Usage(TokenUsage {
input_tokens: delta.usage.input_tokens,
output_tokens: delta.usage.output_tokens,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
}));
}
ApiStreamEvent::MessageStop(_) => {
saw_stop = true;
@@ -1763,32 +2007,82 @@ impl ApiClient for AnthropicRuntimeClient {
struct SubagentToolExecutor {
allowed_tools: BTreeSet<String>,
tool_registry: GlobalToolRegistry,
}
impl SubagentToolExecutor {
fn new(allowed_tools: BTreeSet<String>) -> Self {
Self { allowed_tools }
fn new(allowed_tools: BTreeSet<String>, tool_registry: GlobalToolRegistry) -> Self {
Self {
allowed_tools,
tool_registry,
}
}
}
impl ToolExecutor for SubagentToolExecutor {
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
if !self.allowed_tools.contains(tool_name) {
let entry = self
.tool_registry
.find_entry(tool_name)
.ok_or_else(|| ToolError::new(format!("unsupported tool: {tool_name}")))?;
if !self.allowed_tools.contains(entry.definition.name.as_str()) {
return Err(ToolError::new(format!(
"tool `{tool_name}` is not enabled for this sub-agent"
)));
}
let value = serde_json::from_str(input)
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
execute_tool(tool_name, &value).map_err(ToolError::new)
self.tool_registry
.execute(tool_name, &value)
.map_err(ToolError::new)
}
}
fn tool_specs_for_allowed_tools(allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolSpec> {
mvp_tool_specs()
.into_iter()
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
.collect()
fn current_tool_registry() -> Result<GlobalToolRegistry, String> {
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
let loader = ConfigLoader::default_for(&cwd);
let runtime_config = loader.load().map_err(|error| error.to_string())?;
let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config);
let plugin_tools = plugin_manager
.aggregated_tools()
.map_err(|error| error.to_string())?;
GlobalToolRegistry::with_plugin_tools(plugin_tools)
}
fn build_plugin_manager(
cwd: &Path,
loader: &ConfigLoader,
runtime_config: &RuntimeConfig,
) -> PluginManager {
let plugin_settings = runtime_config.plugins();
let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf());
plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone();
plugin_config.external_dirs = plugin_settings
.external_directories()
.iter()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path))
.collect();
plugin_config.install_root = plugin_settings
.install_root()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
plugin_config.registry_path = plugin_settings
.registry_path()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
plugin_config.bundled_root = plugin_settings
.bundled_root()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
PluginManager::new(plugin_config)
}
fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf {
let path = PathBuf::from(value);
if path.is_absolute() {
path
} else if value.starts_with('.') {
cwd.join(path)
} else {
config_home.join(path)
}
}
fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
@@ -1855,6 +2149,7 @@ fn push_output_block(
};
*pending_tool = Some((id, name, initial_input));
}
OutputContentBlock::Thinking { .. } | OutputContentBlock::RedactedThinking { .. } => {}
}
}
@@ -1869,7 +2164,12 @@ fn response_to_events(response: MessageResponse) -> Vec<AssistantEvent> {
}
}
events.push(AssistantEvent::Usage(response.usage.token_usage()));
events.push(AssistantEvent::Usage(TokenUsage {
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
cache_creation_input_tokens: response.usage.cache_creation_input_tokens,
cache_read_input_tokens: response.usage.cache_read_input_tokens,
}));
events.push(AssistantEvent::MessageStop);
events
}
@@ -2077,7 +2377,7 @@ fn normalize_subagent_type(subagent_type: Option<&str>) -> String {
"verification" | "verificationagent" | "verify" | "verifier" => {
String::from("Verification")
}
"claudecodeguide" | "claudecodeguideagent" | "guide" => String::from("claude-code-guide"),
"claudecodeguide" | "claudecodeguideagent" | "guide" => String::from("claw-code-guide"),
"statusline" | "statuslinesetup" => String::from("statusline-setup"),
_ => trimmed.to_string(),
}
@@ -2895,9 +3195,13 @@ mod tests {
use super::{
agent_permission_policy, allowed_tools_for_subagent, execute_agent_with_spawn,
execute_tool, final_assistant_text, mvp_tool_specs, persist_agent_terminal_state,
AgentInput, AgentJob, SubagentToolExecutor,
response_to_events, AgentInput, AgentJob, GlobalToolRegistry, SubagentToolExecutor,
};
use api::{MessageResponse, OutputContentBlock, Usage};
use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission};
use runtime::{
ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, Session, ToolExecutor,
};
use runtime::{ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, Session};
use serde_json::json;
fn env_lock() -> &'static Mutex<()> {
@@ -2913,6 +3217,17 @@ mod tests {
std::env::temp_dir().join(format!("clawd-tools-{unique}-{name}"))
}
fn make_executable(path: &PathBuf) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = std::fs::metadata(path).expect("metadata").permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(path, permissions).expect("chmod");
}
}
#[test]
fn exposes_mvp_tools() {
let names = mvp_tool_specs()
@@ -2942,6 +3257,170 @@ mod tests {
assert!(error.contains("unsupported tool"));
}
#[test]
fn global_registry_registers_and_executes_plugin_tools() {
let script = temp_path("plugin-tool.sh");
std::fs::write(
&script,
"#!/bin/sh\nINPUT=$(cat)\nprintf '{\"plugin\":\"%s\",\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_PLUGIN_ID\" \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
)
.expect("write script");
make_executable(&script);
let registry = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new(
"demo@external",
"demo",
PluginToolDefinition {
name: "plugin_echo".to_string(),
description: Some("Echo plugin input".to_string()),
input_schema: json!({
"type": "object",
"properties": { "message": { "type": "string" } },
"required": ["message"],
"additionalProperties": false
}),
},
"sh".to_string(),
vec![script.display().to_string()],
PluginToolPermission::WorkspaceWrite,
script.parent().map(PathBuf::from),
)])
.expect("registry should build");
let names = registry
.definitions(None)
.into_iter()
.map(|definition| definition.name)
.collect::<Vec<_>>();
assert!(names.contains(&"bash".to_string()));
assert!(names.contains(&"plugin_echo".to_string()));
let output = registry
.execute("plugin_echo", &json!({ "message": "hello" }))
.expect("plugin tool should execute");
let payload: serde_json::Value = serde_json::from_str(&output).expect("valid json");
assert_eq!(payload["plugin"], "demo@external");
assert_eq!(payload["tool"], "plugin_echo");
assert_eq!(payload["input"]["message"], "hello");
let _ = std::fs::remove_file(script);
}
#[test]
fn global_registry_normalizes_plugin_tool_names_for_allowlists_and_execution() {
let script = temp_path("plugin-tool-normalized.sh");
std::fs::write(
&script,
"#!/bin/sh\nINPUT=$(cat)\nprintf '{\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
)
.expect("write script");
make_executable(&script);
let registry = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new(
"demo@external",
"demo",
PluginToolDefinition {
name: "plugin_echo".to_string(),
description: Some("Echo plugin input".to_string()),
input_schema: json!({
"type": "object",
"properties": { "message": { "type": "string" } },
"required": ["message"],
"additionalProperties": false
}),
},
script.display().to_string(),
Vec::new(),
PluginToolPermission::WorkspaceWrite,
script.parent().map(PathBuf::from),
)])
.expect("registry should build");
let allowed = registry
.normalize_allowed_tools(&[String::from("PLUGIN-ECHO")])
.expect("plugin tool allowlist should normalize")
.expect("allowlist should be present");
assert!(allowed.contains("plugin_echo"));
let output = registry
.execute("plugin-echo", &json!({ "message": "hello" }))
.expect("normalized plugin tool name should execute");
let payload: serde_json::Value = serde_json::from_str(&output).expect("valid json");
assert_eq!(payload["tool"], "plugin_echo");
assert_eq!(payload["input"]["message"], "hello");
let builtin_output = GlobalToolRegistry::builtin()
.execute("structured-output", &json!({ "ok": true }))
.expect("normalized builtin tool name should execute");
let builtin_payload: serde_json::Value =
serde_json::from_str(&builtin_output).expect("valid json");
assert_eq!(builtin_payload["structured_output"]["ok"], true);
let _ = std::fs::remove_file(script);
}
#[test]
fn subagent_executor_executes_allowed_plugin_tools() {
let script = temp_path("subagent-plugin-tool.sh");
std::fs::write(
&script,
"#!/bin/sh\nINPUT=$(cat)\nprintf '{\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
)
.expect("write script");
make_executable(&script);
let registry = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new(
"demo@external",
"demo",
PluginToolDefinition {
name: "plugin_echo".to_string(),
description: Some("Echo plugin input".to_string()),
input_schema: json!({
"type": "object",
"properties": { "message": { "type": "string" } },
"required": ["message"],
"additionalProperties": false
}),
},
script.display().to_string(),
Vec::new(),
PluginToolPermission::WorkspaceWrite,
script.parent().map(PathBuf::from),
)])
.expect("registry should build");
let mut executor =
SubagentToolExecutor::new(BTreeSet::from([String::from("plugin_echo")]), registry);
let output = executor
.execute("plugin-echo", r#"{"message":"hello"}"#)
.expect("plugin tool should execute for subagent");
let payload: serde_json::Value = serde_json::from_str(&output).expect("valid json");
assert_eq!(payload["tool"], "plugin_echo");
assert_eq!(payload["input"]["message"], "hello");
let _ = std::fs::remove_file(script);
}
#[test]
fn global_registry_rejects_conflicting_plugin_tool_names() {
let error = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new(
"demo@external",
"demo",
PluginToolDefinition {
name: "read-file".to_string(),
description: Some("Conflicts with builtin".to_string()),
input_schema: json!({ "type": "object" }),
},
"echo".to_string(),
Vec::new(),
PluginToolPermission::ReadOnly,
None,
)])
.expect_err("conflicting plugin tool should fail");
assert!(error.contains("conflicts with already-registered tool `read_file`"));
}
#[test]
fn web_fetch_returns_prompt_aware_summary() {
let server = TestServer::spawn(Arc::new(|request_line: &str| {
@@ -3521,8 +4000,11 @@ mod tests {
calls: 0,
input_path: path.display().to_string(),
},
SubagentToolExecutor::new(BTreeSet::from([String::from("read_file")])),
agent_permission_policy(),
SubagentToolExecutor::new(
BTreeSet::from([String::from("read_file")]),
GlobalToolRegistry::builtin(),
),
agent_permission_policy(&GlobalToolRegistry::builtin()),
vec![String::from("system prompt")],
);
@@ -3548,6 +4030,42 @@ mod tests {
let _ = std::fs::remove_file(path);
}
#[test]
fn response_to_events_ignores_thinking_blocks() {
let events = response_to_events(MessageResponse {
id: "msg-1".to_string(),
kind: "message".to_string(),
model: "claude-opus-4-6".to_string(),
role: "assistant".to_string(),
content: vec![
OutputContentBlock::Thinking {
thinking: "step 1".to_string(),
signature: Some("sig_123".to_string()),
},
OutputContentBlock::Text {
text: "Final answer".to_string(),
},
],
stop_reason: Some("end_turn".to_string()),
stop_sequence: None,
usage: Usage {
input_tokens: 1,
output_tokens: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
request_id: None,
});
assert!(matches!(
&events[0],
AssistantEvent::TextDelta(text) if text == "Final answer"
));
assert!(!events
.iter()
.any(|event| matches!(event, AssistantEvent::ToolUse { .. })));
}
#[test]
fn agent_rejects_blank_required_fields() {
let missing_description = execute_tool(