Skip to content

Commit ac6c5d0

Browse files
committed
Enable Claude-compatible tool hooks in the Rust runtime
This threads typed hook settings through runtime config, adds a shell-based hook runner, and executes PreToolUse/PostToolUse around each tool call in the conversation loop. The CLI now rebuilds runtimes with settings-derived hook configuration so user-defined Claude hook commands actually run before and after tools. Constraint: Hook behavior needed to match Claude-style settings.json hooks without broad plugin/MCP parity work in this change Rejected: Delay hook loading to the tool executor layer | would miss denied tool calls and duplicate runtime policy plumbing Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep hook execution in the runtime loop so permission decisions and tool results remain wrapped by the same conversation semantics Tested: cargo test; cargo build --release Not-tested: Real user hook scripts outside the test harness; broader plugin/skills parity
1 parent a94ef61 commit ac6c5d0

5 files changed

Lines changed: 653 additions & 13 deletions

File tree

rust/crates/runtime/src/config.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,20 @@ pub struct RuntimeConfig {
3737

3838
#[derive(Debug, Clone, PartialEq, Eq, Default)]
3939
pub struct RuntimeFeatureConfig {
40+
hooks: RuntimeHookConfig,
4041
mcp: McpConfigCollection,
4142
oauth: Option<OAuthConfig>,
4243
model: Option<String>,
4344
permission_mode: Option<ResolvedPermissionMode>,
4445
sandbox: SandboxConfig,
4546
}
4647

48+
#[derive(Debug, Clone, PartialEq, Eq, Default)]
49+
pub struct RuntimeHookConfig {
50+
pre_tool_use: Vec<String>,
51+
post_tool_use: Vec<String>,
52+
}
53+
4754
#[derive(Debug, Clone, PartialEq, Eq, Default)]
4855
pub struct McpConfigCollection {
4956
servers: BTreeMap<String, ScopedMcpServerConfig>,
@@ -221,6 +228,7 @@ impl ConfigLoader {
221228
let merged_value = JsonValue::Object(merged.clone());
222229

223230
let feature_config = RuntimeFeatureConfig {
231+
hooks: parse_optional_hooks_config(&merged_value)?,
224232
mcp: McpConfigCollection {
225233
servers: mcp_servers,
226234
},
@@ -278,6 +286,11 @@ impl RuntimeConfig {
278286
&self.feature_config.mcp
279287
}
280288

289+
#[must_use]
290+
pub fn hooks(&self) -> &RuntimeHookConfig {
291+
&self.feature_config.hooks
292+
}
293+
281294
#[must_use]
282295
pub fn oauth(&self) -> Option<&OAuthConfig> {
283296
self.feature_config.oauth.as_ref()
@@ -300,6 +313,17 @@ impl RuntimeConfig {
300313
}
301314

302315
impl RuntimeFeatureConfig {
316+
#[must_use]
317+
pub fn with_hooks(mut self, hooks: RuntimeHookConfig) -> Self {
318+
self.hooks = hooks;
319+
self
320+
}
321+
322+
#[must_use]
323+
pub fn hooks(&self) -> &RuntimeHookConfig {
324+
&self.hooks
325+
}
326+
303327
#[must_use]
304328
pub fn mcp(&self) -> &McpConfigCollection {
305329
&self.mcp
@@ -326,6 +350,26 @@ impl RuntimeFeatureConfig {
326350
}
327351
}
328352

353+
impl RuntimeHookConfig {
354+
#[must_use]
355+
pub fn new(pre_tool_use: Vec<String>, post_tool_use: Vec<String>) -> Self {
356+
Self {
357+
pre_tool_use,
358+
post_tool_use,
359+
}
360+
}
361+
362+
#[must_use]
363+
pub fn pre_tool_use(&self) -> &[String] {
364+
&self.pre_tool_use
365+
}
366+
367+
#[must_use]
368+
pub fn post_tool_use(&self) -> &[String] {
369+
&self.post_tool_use
370+
}
371+
}
372+
329373
impl McpConfigCollection {
330374
#[must_use]
331375
pub fn servers(&self) -> &BTreeMap<String, ScopedMcpServerConfig> {
@@ -424,6 +468,22 @@ fn parse_optional_model(root: &JsonValue) -> Option<String> {
424468
.map(ToOwned::to_owned)
425469
}
426470

471+
fn parse_optional_hooks_config(root: &JsonValue) -> Result<RuntimeHookConfig, ConfigError> {
472+
let Some(object) = root.as_object() else {
473+
return Ok(RuntimeHookConfig::default());
474+
};
475+
let Some(hooks_value) = object.get("hooks") else {
476+
return Ok(RuntimeHookConfig::default());
477+
};
478+
let hooks = expect_object(hooks_value, "merged settings.hooks")?;
479+
Ok(RuntimeHookConfig {
480+
pre_tool_use: optional_string_array(hooks, "PreToolUse", "merged settings.hooks")?
481+
.unwrap_or_default(),
482+
post_tool_use: optional_string_array(hooks, "PostToolUse", "merged settings.hooks")?
483+
.unwrap_or_default(),
484+
})
485+
}
486+
427487
fn parse_optional_permission_mode(
428488
root: &JsonValue,
429489
) -> Result<Option<ResolvedPermissionMode>, ConfigError> {
@@ -836,6 +896,8 @@ mod tests {
836896
.and_then(JsonValue::as_object)
837897
.expect("hooks object")
838898
.contains_key("PostToolUse"));
899+
assert_eq!(loaded.hooks().pre_tool_use(), &["base".to_string()]);
900+
assert_eq!(loaded.hooks().post_tool_use(), &["project".to_string()]);
839901
assert!(loaded.mcp().get("home").is_some());
840902
assert!(loaded.mcp().get("project").is_some());
841903

0 commit comments

Comments
 (0)