The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok' crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party license archives, README provenance, and the required 'Based on Grok Build Open Source' attribution, now sourced from version_attribution.txt). Wire-visible renames (both sides in this repo, changed in lockstep): - Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode). - Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* / _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps a read-side alias for the legacy '_x.ai/session/update' method so existing updates.jsonl histories load; writes emit only the new name (both directions test-pinned). - Agent types grok-build* → kigi* with a documented legacy-prefix alias at resolution time so persisted sessions keep resolving. - ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build implementation dirs renamed to kigi*. - x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes grokday/groknight → kigiday/kiginight (old persisted values fall back to the default theme), web_fetch allowlist xAI hosts → kimi.com + moonshot platforms, changelog CDN → this repo, grok-build changelog archives deleted. - BYOK default endpoint removed: [endpoints] api_base_url is now truly optional with NO default — consumers fail fast with the flag name when unset (no silent x.ai egress). Mock harnesses inject it explicitly. - System-prompt identity fixed: 'released by xAI' → 'an unofficial community CLI for Kimi' (template + regenerated encrypted form). Also repaired pre-existing grok-era test debt found by the sweep: the stale trace_classify default-model pin, the grok-pager UA label test, pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY test could previously reach the real api.moonshot.cn), and the outdated oauth fixture scope key. Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings); FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed; deny advisories ok.
466 lines
19 KiB
Rust
466 lines
19 KiB
Rust
//! Client-registered hooks for [`SessionActor`].
|
|
//!
|
|
//! Hooks registered at `session/new` (`_meta["kigi/hooks"]`) come in two flavors,
|
|
//! both matched by the agent ([`kigi_hooks::matcher::HookMatcher`], shared with
|
|
//! file hooks):
|
|
//! - **`PreToolUse` gate**: an awaited reverse *request* `kigi/hooks/run`; a `deny`
|
|
//! blocks the tool.
|
|
//! - **All other events**: fire-and-forget *notifications* `kigi/hooks/event`,
|
|
//! observe-only (the callback's return is ignored). Sent per matching callback.
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use agent_client_protocol as acp;
|
|
use agent_client_protocol::Client as _;
|
|
use futures::stream::{FuturesUnordered, StreamExt as _};
|
|
use kigi_hooks::event::{HookEventEnvelope, HookEventName, HookPayload};
|
|
use serde_json::value::RawValue;
|
|
|
|
use super::{SessionActor, ToolLoop};
|
|
use crate::extensions::hooks::{
|
|
ClientHookDecision, ClientHookDispatch, ClientHookGroup, ClientHookResponse,
|
|
};
|
|
use crate::sampling::types::ToolCallResponse;
|
|
|
|
const HOOK_EVENT_METHOD: &str = "kigi/hooks/event";
|
|
const HOOK_RUN_METHOD: &str = "kigi/hooks/run";
|
|
|
|
/// Default per-callback bound for a client's `kigi/hooks/run` reply; on timeout the gate
|
|
/// fails open (the tool proceeds).
|
|
///
|
|
/// Some external hosts default to 600s per hook; we default to 30s because our gate sits
|
|
/// in the interactive tool hot path (a hung hook would otherwise stall a tool call for
|
|
/// minutes). Hosts can override per group up to `MAX_HOOK_TIMEOUT_SECS` (300s). To match
|
|
/// a longer external default, change this value (and raise/remove the cap in
|
|
/// `extensions::hooks`).
|
|
const CLIENT_HOOK_TIMEOUT: Duration = Duration::from_secs(30);
|
|
|
|
/// How the client-hook gate concluded, for tests and diagnostics.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum ClientHookGateOutcome {
|
|
Denied,
|
|
Proceeded,
|
|
TimedOut,
|
|
TransportError,
|
|
Malformed,
|
|
UnknownDecision,
|
|
}
|
|
|
|
/// Outcome of the `kigi/hooks/run` reverse request, before interpreting it as a
|
|
/// decision. Separate so [`classify`] stays pure and unit-testable.
|
|
enum ReverseOutcome {
|
|
Responded(Arc<RawValue>),
|
|
Transport(acp::Error),
|
|
Timeout,
|
|
}
|
|
|
|
/// Map a reverse-request outcome to a decision. Malformed / transport / timeout
|
|
/// all fail open (the `Default` response = proceed).
|
|
fn classify(outcome: ReverseOutcome) -> (ClientHookResponse, ClientHookGateOutcome) {
|
|
match outcome {
|
|
ReverseOutcome::Responded(raw) => {
|
|
match serde_json::from_str::<ClientHookResponse>(raw.get()) {
|
|
Ok(resp) => {
|
|
// An unknown `decision` fails open like Continue, but is almost always a
|
|
// client bug (typo / version skew), so surface it rather than allow silently.
|
|
let label = match resp.decision {
|
|
ClientHookDecision::Deny => ClientHookGateOutcome::Denied,
|
|
ClientHookDecision::Other => {
|
|
tracing::warn!(
|
|
"kigi/hooks/run returned an unknown decision value; failing open"
|
|
);
|
|
ClientHookGateOutcome::UnknownDecision
|
|
}
|
|
ClientHookDecision::Continue => ClientHookGateOutcome::Proceeded,
|
|
};
|
|
(resp, label)
|
|
}
|
|
Err(err) => {
|
|
tracing::warn!(%err, "malformed kigi/hooks/run response; failing open");
|
|
(
|
|
ClientHookResponse::default(),
|
|
ClientHookGateOutcome::Malformed,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
ReverseOutcome::Transport(err) => {
|
|
tracing::warn!(%err, "kigi/hooks/run transport error (no client wired?); failing open");
|
|
(
|
|
ClientHookResponse::default(),
|
|
ClientHookGateOutcome::TransportError,
|
|
)
|
|
}
|
|
ReverseOutcome::Timeout => {
|
|
tracing::warn!("kigi/hooks/run timed out; failing open");
|
|
(
|
|
ClientHookResponse::default(),
|
|
ClientHookGateOutcome::TimedOut,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether `group` fires for an event on `tool_name`. Mirrors the file-hook matcher rule
|
|
/// (dispatcher::dispatch_non_blocking): a group is skipped only when it has a matcher AND
|
|
/// there is a tool name AND the matcher doesn't match, so non-tool events
|
|
/// (`tool_name == None`) and matcher-less groups always fire.
|
|
fn group_matches(group: &ClientHookGroup, tool_name: Option<&str>) -> bool {
|
|
match (group.matcher.as_ref(), tool_name) {
|
|
(Some(matcher), Some(name)) => matcher.is_match(name),
|
|
_ => true,
|
|
}
|
|
}
|
|
|
|
/// Callback ids that fire for an event, in registration order.
|
|
fn matching_callback_ids<'a>(
|
|
groups: &'a [ClientHookGroup],
|
|
tool_name: Option<&str>,
|
|
) -> Vec<&'a str> {
|
|
groups
|
|
.iter()
|
|
.filter(|group| group_matches(group, tool_name))
|
|
.flat_map(|group| group.callback_ids.iter().map(String::as_str))
|
|
.collect()
|
|
}
|
|
|
|
/// Serialize a [`ClientHookDispatch`] to reverse-RPC params, or `None` (logged) on the
|
|
/// should-never-happen serialization failure; callers then skip that callback (fail open)
|
|
/// rather than panic the session actor.
|
|
fn dispatch_params(dispatch: &ClientHookDispatch<'_>) -> Option<Arc<RawValue>> {
|
|
serde_json::value::to_raw_value(dispatch)
|
|
.inspect_err(|err| tracing::warn!(%err, "failed to serialize client hook dispatch"))
|
|
.ok()
|
|
.map(Into::into)
|
|
}
|
|
|
|
impl SessionActor {
|
|
/// Build a [`HookEventEnvelope`] with this session's common fields filled (session id,
|
|
/// cwd, workspace root, timestamp). Single source of truth for envelope shape; every
|
|
/// fire site goes through here.
|
|
pub(super) fn make_hook_envelope(
|
|
&self,
|
|
hook_event_name: HookEventName,
|
|
prompt_id: Option<String>,
|
|
payload: HookPayload,
|
|
) -> HookEventEnvelope {
|
|
HookEventEnvelope {
|
|
hook_event_name,
|
|
session_id: self.session_id_string(),
|
|
cwd: self.session_info.cwd.clone(),
|
|
workspace_root: self.hook_workspace_root(),
|
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
|
transcript_path: self.get_transcript_path(),
|
|
client_identifier: None,
|
|
prompt_id,
|
|
payload,
|
|
}
|
|
}
|
|
|
|
/// Whether any hook would consume `event`: the on-disk file registry, or a registered
|
|
/// client hook. Lets the hot path skip building/serializing a payload (e.g. a large tool
|
|
/// output) when nothing is listening, so the feature stays inert when unused.
|
|
pub(super) fn hook_event_active(&self, event: HookEventName) -> bool {
|
|
self.hook_registry.borrow().is_some()
|
|
|| self.client_hooks.borrow().contains_key(&event.canonical())
|
|
}
|
|
|
|
/// Build the envelope for an observe-only event, fire observe client hooks for it, and
|
|
/// return the envelope for any subsequent file-hook dispatch. One call so a fire site
|
|
/// can't build the envelope but forget to notify.
|
|
pub(super) fn fire_hook(
|
|
&self,
|
|
hook_event_name: HookEventName,
|
|
prompt_id: Option<String>,
|
|
payload: HookPayload,
|
|
) -> HookEventEnvelope {
|
|
let envelope = self.make_hook_envelope(hook_event_name, prompt_id, payload);
|
|
self.notify_client_hooks(&envelope);
|
|
envelope
|
|
}
|
|
|
|
/// Block a tool call denied by a `PreToolUse` hook (file- or client-side),
|
|
/// emitting the shared telemetry + UI side-effects and returning the
|
|
/// [`ToolLoop::HookDenied`] the caller should propagate.
|
|
pub(super) async fn deny_tool(
|
|
&self,
|
|
model_call_id: &str,
|
|
tool_call_id: &acp::ToolCallId,
|
|
tool_name: String,
|
|
hook_name: String,
|
|
reason: String,
|
|
) -> Result<ToolLoop, acp::Error> {
|
|
tracing::info!(%tool_name, %hook_name, %reason, "tool call denied by pre_tool_use hook");
|
|
self.handle_tool_not_executed(
|
|
model_call_id,
|
|
tool_call_id,
|
|
format!("Hook denied: {reason}"),
|
|
)
|
|
.await?;
|
|
self.send_hook_annotation(&format!(
|
|
"\u{26a0} `{tool_name}` blocked by hook `{hook_name}`: {reason}"
|
|
))
|
|
.await;
|
|
Ok(ToolLoop::HookDenied { hook_name })
|
|
}
|
|
|
|
/// Run the client-registered `PreToolUse` hooks for `call`, firing
|
|
/// `kigi/hooks/run` once per matching callback with the shared `envelope` (the
|
|
/// same payload file hooks and observe events receive).
|
|
///
|
|
/// Returns `Some(ToolLoop::HookDenied)` on the first deny, else `None`.
|
|
/// Timeout, transport error, and malformed replies all fail open.
|
|
pub(super) async fn run_pre_tool_use_client_hook(
|
|
&self,
|
|
call: &ToolCallResponse,
|
|
tool_call_id: &acp::ToolCallId,
|
|
envelope: &HookEventEnvelope,
|
|
) -> Result<Option<ToolLoop>, acp::Error> {
|
|
// Clone the matched groups so we don't hold the `client_hooks` borrow across the
|
|
// dispatch awaits below.
|
|
let Some(groups) = self
|
|
.client_hooks
|
|
.borrow()
|
|
.get(&HookEventName::PreToolUse)
|
|
.cloned()
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
// Match on the resolved target (in the envelope) so a client deny matcher
|
|
// keyed on the real MCP tool gates a meta-dispatch call, matching the
|
|
// observe path (`notify_client_hooks`). Equals `function.name` otherwise.
|
|
let tool_name = kigi_hooks::dispatcher::extract_tool_name(envelope)
|
|
.unwrap_or_else(|| call.function.name.clone());
|
|
let tool_name = tool_name.as_str();
|
|
|
|
// Dispatch every matching callback concurrently, each bounded by its group's
|
|
// timeout (else `CLIENT_HOOK_TIMEOUT`), and act on the first deny. Independent
|
|
// timeouts mean a slow or hung callback can't erode another's budget (so a later
|
|
// deny can't be starved into a fail-open), and concurrency keeps total gate latency
|
|
// bounded to ~one timeout regardless of count.
|
|
let mut pending: FuturesUnordered<_> = groups
|
|
.iter()
|
|
.filter(|group| group_matches(group, Some(tool_name)))
|
|
.flat_map(|group| {
|
|
let timeout = group.timeout.unwrap_or(CLIENT_HOOK_TIMEOUT);
|
|
group
|
|
.callback_ids
|
|
.iter()
|
|
.map(move |callback_id| (callback_id.as_str(), timeout))
|
|
})
|
|
.map(|(callback_id, timeout)| {
|
|
let dispatch = ClientHookDispatch {
|
|
hook_callback_id: callback_id,
|
|
envelope,
|
|
};
|
|
async move {
|
|
let started = tokio::time::Instant::now();
|
|
let (response, _gate_outcome) =
|
|
classify(self.send_hook_run(&dispatch, timeout).await);
|
|
(callback_id, response)
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
while let Some((callback_id, response)) = pending.next().await {
|
|
if response.decision == ClientHookDecision::Deny {
|
|
let reason = response
|
|
.system_message
|
|
.unwrap_or_else(|| "blocked by client hook".to_string());
|
|
return Ok(Some(
|
|
self.deny_tool(
|
|
&call.id,
|
|
tool_call_id,
|
|
tool_name.to_owned(),
|
|
// Name the specific callback so telemetry / the UI annotation can
|
|
// attribute the block, not collapse every client hook to "client".
|
|
format!("client:{callback_id}"),
|
|
reason,
|
|
)
|
|
.await?,
|
|
));
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
/// Issue one `kigi/hooks/run` reverse request, bounded by a per-callback `timeout`.
|
|
async fn send_hook_run(
|
|
&self,
|
|
dispatch: &ClientHookDispatch<'_>,
|
|
timeout: Duration,
|
|
) -> ReverseOutcome {
|
|
let Some(params) = dispatch_params(dispatch) else {
|
|
return ReverseOutcome::Transport(acp::Error::internal_error());
|
|
};
|
|
let ext_request = acp::ExtRequest::new(HOOK_RUN_METHOD, params);
|
|
match tokio::time::timeout(timeout, self.notifications.gateway.ext_method(ext_request))
|
|
.await
|
|
{
|
|
Ok(Ok(raw)) => ReverseOutcome::Responded(raw.0),
|
|
Ok(Err(err)) => ReverseOutcome::Transport(err),
|
|
Err(_) => ReverseOutcome::Timeout,
|
|
}
|
|
}
|
|
|
|
/// Fire observe-only client hooks for `envelope`'s event: send an
|
|
/// `kigi/hooks/event` notification to each matching registered callback.
|
|
/// Fire-and-forget (no decision is consumed); independent of file hooks, so it
|
|
/// runs even when no on-disk hook registry exists. No-op when nothing is registered.
|
|
pub(super) fn notify_client_hooks(&self, envelope: &HookEventEnvelope) {
|
|
let hooks = self.client_hooks.borrow();
|
|
let Some(groups) = hooks.get(&envelope.hook_event_name.canonical()) else {
|
|
return;
|
|
};
|
|
let tool_name = kigi_hooks::dispatcher::extract_tool_name(envelope);
|
|
for callback_id in matching_callback_ids(groups, tool_name.as_deref()) {
|
|
let dispatch = ClientHookDispatch {
|
|
hook_callback_id: callback_id,
|
|
envelope,
|
|
};
|
|
if let Some(params) = dispatch_params(&dispatch) {
|
|
self.notifications
|
|
.gateway
|
|
.forward_fire_and_forget(acp::ExtNotification::new(HOOK_EVENT_METHOD, params));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn raw(value: serde_json::Value) -> Arc<RawValue> {
|
|
serde_json::value::to_raw_value(&value).unwrap().into()
|
|
}
|
|
|
|
/// Only an explicit `deny` blocks; malformed/transport/timeout all proceed. The second
|
|
/// tuple element is the telemetry outcome, distinct per fail-open mode.
|
|
#[test]
|
|
fn classify_only_deny_blocks() {
|
|
let (denied, outcome) = classify(ReverseOutcome::Responded(raw(
|
|
serde_json::json!({ "decision": "deny" }),
|
|
)));
|
|
assert_eq!(denied.decision, ClientHookDecision::Deny);
|
|
assert!(matches!(outcome, ClientHookGateOutcome::Denied));
|
|
|
|
let (cont, outcome) = classify(ReverseOutcome::Responded(raw(
|
|
serde_json::json!({ "decision": "continue" }),
|
|
)));
|
|
assert_eq!(cont.decision, ClientHookDecision::Continue);
|
|
assert!(matches!(outcome, ClientHookGateOutcome::Proceeded));
|
|
|
|
// An unrecognized decision string fails open (proceeds) but is reported distinctly
|
|
// from a normal proceed so client bugs (typo / version skew) stay observable.
|
|
let (unknown, outcome) = classify(ReverseOutcome::Responded(raw(
|
|
serde_json::json!({ "decision": "maybe_later" }),
|
|
)));
|
|
assert_ne!(unknown.decision, ClientHookDecision::Deny);
|
|
assert!(matches!(outcome, ClientHookGateOutcome::UnknownDecision));
|
|
|
|
// Every failure mode falls open to Continue, but reports a distinct outcome.
|
|
let (malformed, outcome) = classify(ReverseOutcome::Responded(raw(
|
|
serde_json::json!({ "decision": 123 }),
|
|
)));
|
|
assert_eq!(malformed.decision, ClientHookDecision::Continue);
|
|
assert!(matches!(outcome, ClientHookGateOutcome::Malformed));
|
|
|
|
let (transport, outcome) =
|
|
classify(ReverseOutcome::Transport(acp::Error::internal_error()));
|
|
assert_eq!(transport.decision, ClientHookDecision::Continue);
|
|
assert!(matches!(outcome, ClientHookGateOutcome::TransportError));
|
|
|
|
let (timeout, outcome) = classify(ReverseOutcome::Timeout);
|
|
assert_eq!(timeout.decision, ClientHookDecision::Continue);
|
|
assert!(matches!(outcome, ClientHookGateOutcome::TimedOut));
|
|
}
|
|
|
|
/// `hook_event_active` is the inert-when-unused guard: `false` with no file registry and
|
|
/// no client hook for the event; `true` once a client hook for that event is registered;
|
|
/// and `true` for any event whenever a file registry is present.
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn hook_event_active_inert_vs_active() {
|
|
use kigi_hooks::event::HookEventName;
|
|
|
|
let local = tokio::task::LocalSet::new();
|
|
local
|
|
.run_until(async {
|
|
let (gateway_tx, _gateway_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let (persistence_tx, _persistence_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let actor = crate::session::acp_session::support::create_test_actor(
|
|
0,
|
|
256_000,
|
|
85,
|
|
gateway_tx,
|
|
persistence_tx,
|
|
)
|
|
.await;
|
|
|
|
// Inert: no file registry, no client hooks.
|
|
assert!(actor.hook_registry.borrow().is_none());
|
|
assert!(actor.client_hooks.borrow().is_empty());
|
|
assert!(!actor.hook_event_active(HookEventName::PreToolUse));
|
|
|
|
// A registered client hook activates exactly its event.
|
|
actor.client_hooks.borrow_mut().insert(
|
|
HookEventName::PreToolUse,
|
|
vec![ClientHookGroup {
|
|
matcher: None,
|
|
callback_ids: vec!["cb_0".to_string()],
|
|
timeout: None,
|
|
}],
|
|
);
|
|
assert!(actor.hook_event_active(HookEventName::PreToolUse));
|
|
assert!(!actor.hook_event_active(HookEventName::Stop));
|
|
|
|
// A present file registry activates every event, even ones with no client hook.
|
|
*actor.hook_registry.borrow_mut() = Some(std::sync::Arc::new(
|
|
kigi_hooks::discovery::HookRegistry::default(),
|
|
));
|
|
assert!(actor.hook_event_active(HookEventName::Stop));
|
|
assert!(actor.hook_event_active(HookEventName::PostCompact));
|
|
})
|
|
.await;
|
|
}
|
|
|
|
/// Tool events filter by matcher (matcher-less groups always fire); a non-tool
|
|
/// event (`None`) fires every group regardless of its matcher.
|
|
#[test]
|
|
fn matching_callback_ids_filters_by_matcher() {
|
|
use kigi_hooks::matcher::HookMatcher;
|
|
|
|
let groups = vec![
|
|
ClientHookGroup {
|
|
matcher: Some(HookMatcher::new("run_terminal_command").unwrap()),
|
|
callback_ids: vec!["bash_only".to_string()],
|
|
timeout: None,
|
|
},
|
|
ClientHookGroup {
|
|
matcher: None,
|
|
callback_ids: vec!["all_a".to_string(), "all_b".to_string()],
|
|
timeout: None,
|
|
},
|
|
ClientHookGroup {
|
|
matcher: Some(HookMatcher::new("read_file").unwrap()),
|
|
callback_ids: vec!["read_only".to_string()],
|
|
timeout: None,
|
|
},
|
|
];
|
|
|
|
assert_eq!(
|
|
matching_callback_ids(&groups, Some("run_terminal_command")),
|
|
["bash_only", "all_a", "all_b"]
|
|
);
|
|
assert_eq!(
|
|
matching_callback_ids(&groups, Some("list_dir")),
|
|
["all_a", "all_b"]
|
|
);
|
|
assert_eq!(
|
|
matching_callback_ids(&groups, None),
|
|
["bash_only", "all_a", "all_b", "read_only"]
|
|
);
|
|
}
|
|
}
|