Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
896 lines
34 KiB
Rust
896 lines
34 KiB
Rust
use crate::discovery::HookRegistry;
|
|
use crate::event::{HookEventEnvelope, HookEventName};
|
|
use crate::result::{HookDecision, HookRunResult};
|
|
use crate::runner::{self, HookRunnerResult, RunContext};
|
|
|
|
/// Result of a `pre_tool_use` dispatch: the final decision plus per-hook
|
|
/// execution details (for scrollback enrichment).
|
|
pub struct PreToolUseResult {
|
|
/// Final blocking decision (Allow or Deny).
|
|
pub decision: HookDecision,
|
|
/// Per-hook run results (includes HTTP info when applicable).
|
|
pub results: Vec<HookRunResult>,
|
|
}
|
|
|
|
/// Dispatch a `pre_tool_use` event against all matching hooks.
|
|
///
|
|
/// Runs hooks sequentially in config order. Only an explicit `deny`
|
|
/// decision from a hook stops the chain and blocks the tool call.
|
|
///
|
|
/// Hook failures (timeouts, crashes, command-not-found, env-var
|
|
/// pre-spawn refusals, malformed output) are **fail-open**: the failure
|
|
/// is logged and surfaced in the per-hook results for the UI scrollback,
|
|
/// but the tool call continues as if the hook had allowed it. Grok
|
|
/// runs in protected environments where induced-failure bypass of
|
|
/// security hooks is not part of the threat model; the previous
|
|
/// fail-closed posture over-blocked innocent tool calls when
|
|
/// hooks timed out or had unrelated configuration errors.
|
|
///
|
|
/// Returns `Allow` if no hooks match, all hooks allow, or all failing
|
|
/// hooks are non-blocking by virtue of this fail-open policy.
|
|
pub async fn dispatch_pre_tool_use(
|
|
registry: &HookRegistry,
|
|
envelope: &HookEventEnvelope,
|
|
ctx: &RunContext<'_>,
|
|
) -> PreToolUseResult {
|
|
let hooks = registry.hooks_for(HookEventName::PreToolUse);
|
|
if hooks.is_empty() {
|
|
return PreToolUseResult {
|
|
decision: HookDecision::Allow,
|
|
results: Vec::new(),
|
|
};
|
|
}
|
|
|
|
let span = tracing::info_span!(
|
|
"hooks.dispatch",
|
|
hook_event = %HookEventName::PreToolUse,
|
|
hook_count = hooks.len() as i64,
|
|
num_success = tracing::field::Empty,
|
|
num_failed = tracing::field::Empty,
|
|
num_blocking = tracing::field::Empty,
|
|
num_skipped = tracing::field::Empty,
|
|
total_duration_ms = tracing::field::Empty,
|
|
);
|
|
let _enter = span.enter();
|
|
|
|
let tool_name = extract_tool_name(envelope);
|
|
let mut run_results = Vec::new();
|
|
|
|
for spec in hooks {
|
|
if !spec.enabled || crate::trust::is_hook_disabled(&spec.name) {
|
|
tracing::info!(hook_name = %spec.name, "hook skipped (disabled)");
|
|
run_results.push(HookRunResult::Skipped {
|
|
hook_name: spec.name.clone(),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Check matcher against tool name.
|
|
if let Some(ref matcher) = spec.matcher
|
|
&& let Some(ref name) = tool_name
|
|
&& !matcher.is_match(name)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let _hook_span = tracing::info_span!(
|
|
"hook.run",
|
|
hook_name = %spec.name,
|
|
hook_event = %HookEventName::PreToolUse,
|
|
)
|
|
.entered();
|
|
|
|
let (result, elapsed, http_info) = runner::run_hook(spec, envelope, ctx, true).await;
|
|
|
|
match result {
|
|
HookRunnerResult::Decision(HookDecision::Deny { reason, .. }) => {
|
|
tracing::info!(
|
|
hook_name = %spec.name,
|
|
elapsed_ms = elapsed.as_millis() as u64,
|
|
reason = %reason,
|
|
"hook denied"
|
|
);
|
|
run_results.push(HookRunResult::Failed {
|
|
hook_name: spec.name.clone(),
|
|
error: format!("denied: {reason}"),
|
|
elapsed,
|
|
http_info,
|
|
});
|
|
record_dispatch_counts(&span, &run_results, 1);
|
|
return PreToolUseResult {
|
|
decision: HookDecision::Deny {
|
|
reason,
|
|
hook_name: spec.name.clone(),
|
|
},
|
|
results: run_results,
|
|
};
|
|
}
|
|
HookRunnerResult::Decision(HookDecision::Allow) => {
|
|
tracing::info!(
|
|
hook_name = %spec.name,
|
|
elapsed_ms = elapsed.as_millis() as u64,
|
|
"hook allowed"
|
|
);
|
|
run_results.push(HookRunResult::Success {
|
|
hook_name: spec.name.clone(),
|
|
elapsed,
|
|
http_info,
|
|
});
|
|
}
|
|
// Fail-open: hook failures (timeouts, crashes, refusals to
|
|
// spawn, malformed output) are logged and recorded for the UI
|
|
// but do not deny the tool call. Only an explicit `deny`
|
|
// decision blocks. See module docs on dispatch_pre_tool_use
|
|
// for the rationale (protected-environment threat model).
|
|
HookRunnerResult::Failed(err) => {
|
|
tracing::warn!(
|
|
hook_name = %spec.name,
|
|
elapsed_ms = elapsed.as_millis() as u64,
|
|
error = %err,
|
|
"hook failed; ignoring (fail-open)"
|
|
);
|
|
run_results.push(HookRunResult::Failed {
|
|
hook_name: spec.name.clone(),
|
|
error: err.clone(),
|
|
elapsed,
|
|
http_info,
|
|
});
|
|
}
|
|
HookRunnerResult::Success => {
|
|
// Shouldn't happen for blocking hooks, but treat as allow.
|
|
tracing::info!(
|
|
hook_name = %spec.name,
|
|
elapsed_ms = elapsed.as_millis() as u64,
|
|
"hook completed"
|
|
);
|
|
run_results.push(HookRunResult::Success {
|
|
hook_name: spec.name.clone(),
|
|
elapsed,
|
|
http_info,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
record_dispatch_counts(&span, &run_results, 0);
|
|
PreToolUseResult {
|
|
decision: HookDecision::Allow,
|
|
results: run_results,
|
|
}
|
|
}
|
|
|
|
/// Dispatch a non-blocking event (`session_start`, `post_tool_use`, `session_end`)
|
|
/// against all matching hooks.
|
|
///
|
|
/// Runs hooks sequentially, collects results. Never denies — callers log
|
|
/// results and continue.
|
|
pub async fn dispatch_non_blocking(
|
|
registry: &HookRegistry,
|
|
event: HookEventName,
|
|
envelope: &HookEventEnvelope,
|
|
ctx: &RunContext<'_>,
|
|
) -> Vec<HookRunResult> {
|
|
let hooks = registry.hooks_for(event);
|
|
if hooks.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
|
|
let span = tracing::info_span!(
|
|
"hooks.dispatch",
|
|
hook_event = %event,
|
|
hook_count = hooks.len() as i64,
|
|
num_success = tracing::field::Empty,
|
|
num_failed = tracing::field::Empty,
|
|
num_blocking = tracing::field::Empty,
|
|
num_skipped = tracing::field::Empty,
|
|
total_duration_ms = tracing::field::Empty,
|
|
);
|
|
let _enter = span.enter();
|
|
|
|
let tool_name = extract_tool_name(envelope);
|
|
let mut results = Vec::with_capacity(hooks.len());
|
|
|
|
for spec in hooks {
|
|
if !spec.enabled || crate::trust::is_hook_disabled(&spec.name) {
|
|
tracing::info!(hook_name = %spec.name, "hook skipped (disabled)");
|
|
results.push(HookRunResult::Skipped {
|
|
hook_name: spec.name.clone(),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Check matcher against tool name (only for tool events).
|
|
if let Some(ref matcher) = spec.matcher
|
|
&& let Some(ref name) = tool_name
|
|
&& !matcher.is_match(name)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let _hook_span = tracing::info_span!(
|
|
"hook.run",
|
|
hook_name = %spec.name,
|
|
hook_event = %event,
|
|
)
|
|
.entered();
|
|
|
|
let (result, elapsed, http_info) = runner::run_hook(spec, envelope, ctx, false).await;
|
|
|
|
match result {
|
|
HookRunnerResult::Success => {
|
|
tracing::info!(
|
|
hook_name = %spec.name,
|
|
elapsed_ms = elapsed.as_millis() as u64,
|
|
"hook completed"
|
|
);
|
|
results.push(HookRunResult::Success {
|
|
hook_name: spec.name.clone(),
|
|
elapsed,
|
|
http_info,
|
|
});
|
|
}
|
|
HookRunnerResult::Failed(err) => {
|
|
tracing::warn!(
|
|
hook_name = %spec.name,
|
|
elapsed_ms = elapsed.as_millis() as u64,
|
|
error = %err,
|
|
"hook failed"
|
|
);
|
|
results.push(HookRunResult::Failed {
|
|
hook_name: spec.name.clone(),
|
|
error: err,
|
|
elapsed,
|
|
http_info,
|
|
});
|
|
}
|
|
HookRunnerResult::Decision(_) => {
|
|
// Shouldn't happen for non-blocking hooks.
|
|
tracing::info!(
|
|
hook_name = %spec.name,
|
|
elapsed_ms = elapsed.as_millis() as u64,
|
|
"hook completed"
|
|
);
|
|
results.push(HookRunResult::Success {
|
|
hook_name: spec.name.clone(),
|
|
elapsed,
|
|
http_info,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
record_dispatch_counts(&span, &results, 0);
|
|
|
|
results
|
|
}
|
|
|
|
/// Record hook outcome counts on the `hooks.dispatch` span. A blocking deny is
|
|
/// stored as a `Failed` result, so `num_blocking` is passed in and subtracted
|
|
/// from `num_failed` to avoid double-counting.
|
|
fn record_dispatch_counts(span: &tracing::Span, results: &[HookRunResult], num_blocking: i64) {
|
|
let mut num_success = 0i64;
|
|
let mut num_failed = 0i64;
|
|
let mut num_skipped = 0i64;
|
|
let mut total_duration_ms = 0i64;
|
|
for r in results {
|
|
match r {
|
|
HookRunResult::Success { elapsed, .. } => {
|
|
num_success += 1;
|
|
total_duration_ms += elapsed.as_millis() as i64;
|
|
}
|
|
HookRunResult::Failed { elapsed, .. } => {
|
|
num_failed += 1;
|
|
total_duration_ms += elapsed.as_millis() as i64;
|
|
}
|
|
HookRunResult::Skipped { .. } => num_skipped += 1,
|
|
}
|
|
}
|
|
span.record("num_success", num_success);
|
|
span.record("num_failed", num_failed - num_blocking);
|
|
span.record("num_blocking", num_blocking);
|
|
span.record("num_skipped", num_skipped);
|
|
span.record("total_duration_ms", total_duration_ms);
|
|
}
|
|
|
|
/// Build the hub custom hook `kind` string for a non-blocking hook event.
|
|
///
|
|
/// Returns `None` for `PreToolUse` (blocking, local-only). For all other
|
|
/// events the kind is `"hook.<snake_case_event_name>"`, derived from the
|
|
/// `Display` impl of `HookEventName`.
|
|
pub fn hub_hook_kind(event: HookEventName) -> Option<String> {
|
|
if event.is_blocking() {
|
|
return None;
|
|
}
|
|
Some(format!("hook.{event}"))
|
|
}
|
|
|
|
/// The tool name a matcher is tested against, or `None` for events with no tool
|
|
/// (lifecycle, prompt, compaction). `Notification` matches on its `notification_type`.
|
|
///
|
|
/// `tool_name` is the resolved underlying tool for meta-dispatch tools (`use_tool`
|
|
/// and the external MCP-call tool), so a matcher keyed on the real tool fires directly.
|
|
pub fn extract_tool_name(envelope: &HookEventEnvelope) -> Option<String> {
|
|
use crate::event::HookPayload;
|
|
match &envelope.payload {
|
|
HookPayload::PreToolUse { tool_name, .. } => Some(tool_name.clone()),
|
|
HookPayload::PostToolUse { tool_name, .. } => Some(tool_name.clone()),
|
|
HookPayload::PostToolUseFailure { tool_name, .. } => Some(tool_name.clone()),
|
|
HookPayload::PermissionDenied { tool_name, .. } => Some(tool_name.clone()),
|
|
HookPayload::Notification {
|
|
notification_type, ..
|
|
} => Some(notification_type.clone()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::config::HookSpec;
|
|
use crate::event::{HookEventEnvelope, HookEventName, HookPayload};
|
|
use crate::matcher::HookMatcher;
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
|
|
/// Helper: build a pre_tool_use envelope for the given tool name.
|
|
fn pre_tool_use_envelope(tool_name: &str) -> HookEventEnvelope {
|
|
HookEventEnvelope {
|
|
hook_event_name: HookEventName::PreToolUse,
|
|
session_id: "test-session".into(),
|
|
cwd: "/tmp".into(),
|
|
workspace_root: "/tmp".into(),
|
|
timestamp: "2025-01-01T00:00:00Z".into(),
|
|
transcript_path: None,
|
|
client_identifier: None,
|
|
prompt_id: None,
|
|
payload: HookPayload::PreToolUse {
|
|
tool_name: tool_name.into(),
|
|
tool_use_id: "tu-1".into(),
|
|
tool_input: serde_json::json!({"command": "ls"}),
|
|
tool_input_truncated: false,
|
|
permission_mode: None,
|
|
subagent_type: None,
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Helper: build a session_start envelope.
|
|
fn session_start_envelope() -> HookEventEnvelope {
|
|
HookEventEnvelope {
|
|
hook_event_name: HookEventName::SessionStart,
|
|
session_id: "test-session".into(),
|
|
cwd: "/tmp".into(),
|
|
workspace_root: "/tmp".into(),
|
|
timestamp: "2025-01-01T00:00:00Z".into(),
|
|
transcript_path: None,
|
|
client_identifier: None,
|
|
prompt_id: None,
|
|
payload: HookPayload::SessionStart {
|
|
source: "new".into(),
|
|
model_id: None,
|
|
agent_type: None,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn run_ctx() -> RunContext<'static> {
|
|
RunContext {
|
|
session_id: "test-session",
|
|
workspace_root: "/tmp",
|
|
}
|
|
}
|
|
|
|
/// Helper: create a HookSpec pointing at `sh -c '<script>'` that prints
|
|
/// the given JSON and exits with the given code.
|
|
fn make_command_spec(
|
|
name: &str,
|
|
matcher: Option<&str>,
|
|
enabled: bool,
|
|
script: &str,
|
|
) -> HookSpec {
|
|
HookSpec {
|
|
name: name.into(),
|
|
event: HookEventName::PreToolUse,
|
|
handler_type: "command".into(),
|
|
configured_matcher: matcher.map(|s| s.to_string()),
|
|
matcher: matcher.map(|s| HookMatcher::new(s).unwrap()),
|
|
enabled,
|
|
command: Some(PathBuf::from(script)),
|
|
command_raw: Some(script.to_string()),
|
|
url: None,
|
|
url_raw: None,
|
|
timeout_ms: 5000,
|
|
source_dir: PathBuf::from("/tmp"),
|
|
extra_env: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Build a registry from a list of specs using the public API.
|
|
fn registry_from_specs(specs: Vec<HookSpec>) -> HookRegistry {
|
|
let (mut registry, _) = crate::discovery::load_hooks(None, None);
|
|
registry.append_specs(specs);
|
|
registry
|
|
}
|
|
|
|
// ── extract_tool_name tests ──────────────────────────────────
|
|
|
|
#[test]
|
|
fn extract_tool_name_from_pre_tool_use() {
|
|
let envelope = pre_tool_use_envelope("run_terminal_cmd");
|
|
assert_eq!(
|
|
extract_tool_name(&envelope),
|
|
Some("run_terminal_cmd".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn extract_tool_name_from_session_start_is_none() {
|
|
let envelope = session_start_envelope();
|
|
assert_eq!(extract_tool_name(&envelope), None);
|
|
}
|
|
|
|
#[test]
|
|
fn extract_tool_name_from_notification() {
|
|
let envelope = HookEventEnvelope {
|
|
hook_event_name: HookEventName::Notification,
|
|
session_id: "s".into(),
|
|
cwd: "/tmp".into(),
|
|
workspace_root: "/tmp".into(),
|
|
timestamp: "t".into(),
|
|
transcript_path: None,
|
|
client_identifier: None,
|
|
prompt_id: None,
|
|
payload: HookPayload::Notification {
|
|
notification_type: "permission_prompt".into(),
|
|
message: None,
|
|
title: None,
|
|
level: None,
|
|
},
|
|
};
|
|
assert_eq!(
|
|
extract_tool_name(&envelope),
|
|
Some("permission_prompt".into())
|
|
);
|
|
}
|
|
|
|
// ── dispatch_pre_tool_use tests ──────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn empty_registry_allows() {
|
|
let registry = registry_from_specs(vec![]);
|
|
let envelope = pre_tool_use_envelope("run_terminal_cmd");
|
|
let result = dispatch_pre_tool_use(®istry, &envelope, &run_ctx()).await;
|
|
assert_eq!(result.decision, HookDecision::Allow);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn single_allow_hook() {
|
|
let spec = make_command_spec("allow-hook", None, true, "echo '{\"decision\":\"allow\"}'");
|
|
let registry = registry_from_specs(vec![spec]);
|
|
let envelope = pre_tool_use_envelope("run_terminal_cmd");
|
|
let result = dispatch_pre_tool_use(®istry, &envelope, &run_ctx()).await;
|
|
assert_eq!(result.decision, HookDecision::Allow);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn single_deny_hook() {
|
|
let spec = make_command_spec(
|
|
"deny-hook",
|
|
None,
|
|
true,
|
|
"echo '{\"decision\":\"deny\",\"reason\":\"blocked\"}'; exit 2",
|
|
);
|
|
let registry = registry_from_specs(vec![spec]);
|
|
let envelope = pre_tool_use_envelope("run_terminal_cmd");
|
|
let result = dispatch_pre_tool_use(®istry, &envelope, &run_ctx()).await;
|
|
match result.decision {
|
|
HookDecision::Deny {
|
|
ref reason,
|
|
ref hook_name,
|
|
} => {
|
|
assert_eq!(reason, "blocked");
|
|
assert_eq!(hook_name, "deny-hook");
|
|
}
|
|
ref other => panic!("expected Deny, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn disabled_hook_is_skipped_allows() {
|
|
// A deny hook that is disabled should be skipped entirely.
|
|
let spec = make_command_spec(
|
|
"disabled-deny",
|
|
None,
|
|
false, // disabled!
|
|
"echo '{\"decision\":\"deny\",\"reason\":\"should not run\"}'; exit 2",
|
|
);
|
|
let registry = registry_from_specs(vec![spec]);
|
|
let envelope = pre_tool_use_envelope("run_terminal_cmd");
|
|
let result = dispatch_pre_tool_use(®istry, &envelope, &run_ctx()).await;
|
|
assert_eq!(result.decision, HookDecision::Allow);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn matcher_skips_non_matching_tool() {
|
|
// Deny hook with matcher for "read_file" should not fire for "run_terminal_cmd".
|
|
let spec = make_command_spec(
|
|
"read-only-deny",
|
|
Some("read_file"),
|
|
true,
|
|
"echo '{\"decision\":\"deny\",\"reason\":\"blocked\"}'; exit 2",
|
|
);
|
|
let registry = registry_from_specs(vec![spec]);
|
|
let envelope = pre_tool_use_envelope("run_terminal_cmd");
|
|
let result = dispatch_pre_tool_use(®istry, &envelope, &run_ctx()).await;
|
|
assert_eq!(result.decision, HookDecision::Allow);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn matcher_fires_on_matching_tool() {
|
|
// Deny hook with matcher for "run_terminal_cmd" should fire.
|
|
let spec = make_command_spec(
|
|
"bash-deny",
|
|
Some("run_terminal_cmd"),
|
|
true,
|
|
"echo '{\"decision\":\"deny\",\"reason\":\"bash blocked\"}'; exit 2",
|
|
);
|
|
let registry = registry_from_specs(vec![spec]);
|
|
let envelope = pre_tool_use_envelope("run_terminal_cmd");
|
|
let result = dispatch_pre_tool_use(®istry, &envelope, &run_ctx()).await;
|
|
match result.decision {
|
|
HookDecision::Deny { ref reason, .. } => assert_eq!(reason, "bash blocked"),
|
|
ref other => panic!("expected Deny, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn first_deny_wins_short_circuits() {
|
|
// Two hooks: first denies, second allows. First deny should win.
|
|
let deny_spec = make_command_spec(
|
|
"first-deny",
|
|
None,
|
|
true,
|
|
"echo '{\"decision\":\"deny\",\"reason\":\"first says no\"}'; exit 2",
|
|
);
|
|
let allow_spec = make_command_spec(
|
|
"second-allow",
|
|
None,
|
|
true,
|
|
"echo '{\"decision\":\"allow\"}'",
|
|
);
|
|
let registry = registry_from_specs(vec![deny_spec, allow_spec]);
|
|
let envelope = pre_tool_use_envelope("run_terminal_cmd");
|
|
let result = dispatch_pre_tool_use(®istry, &envelope, &run_ctx()).await;
|
|
match result.decision {
|
|
HookDecision::Deny {
|
|
ref reason,
|
|
ref hook_name,
|
|
..
|
|
} => {
|
|
assert_eq!(reason, "first says no");
|
|
assert_eq!(hook_name, "first-deny");
|
|
}
|
|
ref other => panic!("expected Deny, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn allow_then_deny_denies() {
|
|
// First hook allows, second hook denies. The deny should win.
|
|
// This is the key "stricter deny filter takes precedence" scenario.
|
|
let allow_spec =
|
|
make_command_spec("broad-allow", None, true, "echo '{\"decision\":\"allow\"}'");
|
|
let deny_spec = make_command_spec(
|
|
"strict-deny",
|
|
None,
|
|
true,
|
|
"echo '{\"decision\":\"deny\",\"reason\":\"strict policy\"}'; exit 2",
|
|
);
|
|
let registry = registry_from_specs(vec![allow_spec, deny_spec]);
|
|
let envelope = pre_tool_use_envelope("run_terminal_cmd");
|
|
let result = dispatch_pre_tool_use(®istry, &envelope, &run_ctx()).await;
|
|
match result.decision {
|
|
HookDecision::Deny {
|
|
ref reason,
|
|
ref hook_name,
|
|
..
|
|
} => {
|
|
assert_eq!(reason, "strict policy");
|
|
assert_eq!(hook_name, "strict-deny");
|
|
}
|
|
ref other => panic!("expected Deny from strict filter, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn allow_broad_deny_specific_tool_match() {
|
|
// Broad allow hook (no matcher), specific deny hook for "run_terminal_cmd".
|
|
// The deny should fire for matching tool even though allow came first.
|
|
let allow_spec =
|
|
make_command_spec("allow-all", None, true, "echo '{\"decision\":\"allow\"}'");
|
|
let deny_spec = make_command_spec(
|
|
"deny-bash",
|
|
Some("run_terminal_cmd"),
|
|
true,
|
|
"echo '{\"decision\":\"deny\",\"reason\":\"bash not allowed\"}'; exit 2",
|
|
);
|
|
let registry = registry_from_specs(vec![allow_spec, deny_spec]);
|
|
let envelope = pre_tool_use_envelope("run_terminal_cmd");
|
|
let result = dispatch_pre_tool_use(®istry, &envelope, &run_ctx()).await;
|
|
match result.decision {
|
|
HookDecision::Deny { ref reason, .. } => assert_eq!(reason, "bash not allowed"),
|
|
ref other => panic!("expected Deny, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn allow_broad_deny_specific_non_matching_allows() {
|
|
// Broad allow hook, specific deny for "read_file" only.
|
|
// Calling with "run_terminal_cmd" should allow (deny doesn't match).
|
|
let allow_spec =
|
|
make_command_spec("allow-all", None, true, "echo '{\"decision\":\"allow\"}'");
|
|
let deny_spec = make_command_spec(
|
|
"deny-read",
|
|
Some("read_file"),
|
|
true,
|
|
"echo '{\"decision\":\"deny\",\"reason\":\"no read\"}'; exit 2",
|
|
);
|
|
let registry = registry_from_specs(vec![allow_spec, deny_spec]);
|
|
let envelope = pre_tool_use_envelope("run_terminal_cmd");
|
|
let result = dispatch_pre_tool_use(®istry, &envelope, &run_ctx()).await;
|
|
assert_eq!(result.decision, HookDecision::Allow);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn fail_open_on_hook_crash() {
|
|
// Hook exits with code 1 (crash). Under fail-open the tool call
|
|
// should still be allowed; the failure is recorded for the UI.
|
|
let spec = make_command_spec("crasher", None, true, "exit 1");
|
|
let registry = registry_from_specs(vec![spec]);
|
|
let envelope = pre_tool_use_envelope("run_terminal_cmd");
|
|
let result = dispatch_pre_tool_use(®istry, &envelope, &run_ctx()).await;
|
|
assert_eq!(
|
|
result.decision,
|
|
HookDecision::Allow,
|
|
"fail-open: a crashing hook must not block the tool call"
|
|
);
|
|
assert_eq!(result.results.len(), 1);
|
|
assert!(
|
|
matches!(&result.results[0], HookRunResult::Failed { hook_name, .. } if hook_name == "crasher"),
|
|
"the failure must still appear in run_results for UI scrollback, got {:?}",
|
|
result.results
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn fail_open_then_deny_lets_deny_win() {
|
|
// First hook crashes (now fail-open), second denies. Under
|
|
// fail-open the chain continues past the crash and the second
|
|
// hook's explicit deny is what blocks the call.
|
|
let crash_spec = make_command_spec("crasher", None, true, "exit 1");
|
|
let deny_spec = make_command_spec(
|
|
"denier",
|
|
None,
|
|
true,
|
|
"echo '{\"decision\":\"deny\",\"reason\":\"nope\"}'; exit 2",
|
|
);
|
|
let registry = registry_from_specs(vec![crash_spec, deny_spec]);
|
|
let envelope = pre_tool_use_envelope("run_terminal_cmd");
|
|
let result = dispatch_pre_tool_use(®istry, &envelope, &run_ctx()).await;
|
|
match result.decision {
|
|
HookDecision::Deny {
|
|
ref hook_name,
|
|
ref reason,
|
|
} => {
|
|
assert_eq!(hook_name, "denier");
|
|
assert_eq!(reason, "nope");
|
|
}
|
|
ref other => panic!("expected Deny from explicit denier, got {other:?}"),
|
|
}
|
|
// Both hooks ran: the crasher recorded a Failed result, the
|
|
// denier recorded a Failed result with "denied: nope" prefix.
|
|
assert_eq!(result.results.len(), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn all_hooks_allow_results_in_allow() {
|
|
let specs = vec![
|
|
make_command_spec("a1", None, true, "echo '{\"decision\":\"allow\"}'"),
|
|
make_command_spec("a2", None, true, "echo '{\"decision\":\"allow\"}'"),
|
|
make_command_spec("a3", None, true, "echo '{\"decision\":\"allow\"}'"),
|
|
];
|
|
let registry = registry_from_specs(specs);
|
|
let envelope = pre_tool_use_envelope("run_terminal_cmd");
|
|
let result = dispatch_pre_tool_use(®istry, &envelope, &run_ctx()).await;
|
|
assert_eq!(result.decision, HookDecision::Allow);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mixed_disabled_and_deny() {
|
|
// Disabled deny hook followed by enabled allow. Should allow.
|
|
let disabled_deny = make_command_spec(
|
|
"disabled-deny",
|
|
None,
|
|
false,
|
|
"echo '{\"decision\":\"deny\",\"reason\":\"should not run\"}'; exit 2",
|
|
);
|
|
let enabled_allow = make_command_spec(
|
|
"enabled-allow",
|
|
None,
|
|
true,
|
|
"echo '{\"decision\":\"allow\"}'",
|
|
);
|
|
let registry = registry_from_specs(vec![disabled_deny, enabled_allow]);
|
|
let envelope = pre_tool_use_envelope("run_terminal_cmd");
|
|
let result = dispatch_pre_tool_use(®istry, &envelope, &run_ctx()).await;
|
|
assert_eq!(result.decision, HookDecision::Allow);
|
|
}
|
|
|
|
// ── fail-open regression tests ───────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn fail_open_records_error_in_run_results() {
|
|
// A hook that returns malformed output and exits non-zero now
|
|
// results in Allow (fail-open) but the failure detail is still
|
|
// captured in run_results for the UI scrollback.
|
|
let spec = make_command_spec("bad-output", None, true, "echo 'not json'; exit 1");
|
|
let registry = registry_from_specs(vec![spec]);
|
|
let envelope = pre_tool_use_envelope("run_terminal_cmd");
|
|
let result = dispatch_pre_tool_use(®istry, &envelope, &run_ctx()).await;
|
|
assert_eq!(
|
|
result.decision,
|
|
HookDecision::Allow,
|
|
"fail-open: bad output must not block the tool call"
|
|
);
|
|
assert_eq!(result.results.len(), 1);
|
|
match &result.results[0] {
|
|
HookRunResult::Failed {
|
|
hook_name, error, ..
|
|
} => {
|
|
assert_eq!(hook_name, "bad-output");
|
|
assert!(
|
|
error.contains("bad-output") || error.contains("exit code"),
|
|
"error detail should be preserved for UI: {error}"
|
|
);
|
|
}
|
|
other => panic!("expected Failed run result, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
// ── dispatch_non_blocking tests ──────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn non_blocking_empty_registry() {
|
|
let registry = registry_from_specs(vec![]);
|
|
let envelope = session_start_envelope();
|
|
let results = dispatch_non_blocking(
|
|
®istry,
|
|
HookEventName::SessionStart,
|
|
&envelope,
|
|
&run_ctx(),
|
|
)
|
|
.await;
|
|
assert!(results.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn non_blocking_disabled_hook_skipped() {
|
|
let mut spec = make_command_spec("disabled", None, false, "echo ok");
|
|
spec.event = HookEventName::SessionStart;
|
|
let registry = registry_from_specs(vec![spec]);
|
|
let envelope = session_start_envelope();
|
|
let results = dispatch_non_blocking(
|
|
®istry,
|
|
HookEventName::SessionStart,
|
|
&envelope,
|
|
&run_ctx(),
|
|
)
|
|
.await;
|
|
assert_eq!(results.len(), 1);
|
|
assert!(matches!(results[0], HookRunResult::Skipped { .. }));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn non_blocking_success() {
|
|
let mut spec = make_command_spec("starter", None, true, "echo ok");
|
|
spec.event = HookEventName::SessionStart;
|
|
let registry = registry_from_specs(vec![spec]);
|
|
let envelope = session_start_envelope();
|
|
let results = dispatch_non_blocking(
|
|
®istry,
|
|
HookEventName::SessionStart,
|
|
&envelope,
|
|
&run_ctx(),
|
|
)
|
|
.await;
|
|
assert_eq!(results.len(), 1);
|
|
assert!(matches!(results[0], HookRunResult::Success { .. }));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn non_blocking_failure_does_not_stop_chain() {
|
|
let mut spec1 = make_command_spec("crasher", None, true, "exit 1");
|
|
spec1.event = HookEventName::SessionStart;
|
|
let mut spec2 = make_command_spec("ok", None, true, "echo ok");
|
|
spec2.event = HookEventName::SessionStart;
|
|
let registry = registry_from_specs(vec![spec1, spec2]);
|
|
let envelope = session_start_envelope();
|
|
let results = dispatch_non_blocking(
|
|
®istry,
|
|
HookEventName::SessionStart,
|
|
&envelope,
|
|
&run_ctx(),
|
|
)
|
|
.await;
|
|
assert_eq!(results.len(), 2);
|
|
assert!(matches!(results[0], HookRunResult::Failed { .. }));
|
|
assert!(matches!(results[1], HookRunResult::Success { .. }));
|
|
}
|
|
|
|
// ── hub_hook_kind tests ──────────────────────────────────────
|
|
|
|
#[test]
|
|
fn hub_hook_kind_returns_none_for_pre_tool_use() {
|
|
assert_eq!(hub_hook_kind(HookEventName::PreToolUse), None);
|
|
}
|
|
|
|
#[test]
|
|
fn hub_hook_kind_maps_all_non_blocking_events() {
|
|
let cases: &[(HookEventName, &str)] = &[
|
|
(HookEventName::SessionStart, "hook.session_start"),
|
|
(HookEventName::SessionEnd, "hook.session_end"),
|
|
(HookEventName::Stop, "hook.stop"),
|
|
(HookEventName::StopFailure, "hook.stop_failure"),
|
|
(HookEventName::PostToolUse, "hook.post_tool_use"),
|
|
(
|
|
HookEventName::PostToolUseFailure,
|
|
"hook.post_tool_use_failure",
|
|
),
|
|
(HookEventName::PermissionDenied, "hook.permission_denied"),
|
|
(HookEventName::UserPromptSubmit, "hook.user_prompt_submit"),
|
|
(HookEventName::Notification, "hook.notification"),
|
|
(HookEventName::SubagentStart, "hook.subagent_start"),
|
|
(HookEventName::SubagentStop, "hook.subagent_stop"),
|
|
(HookEventName::SubagentEnd, "hook.subagent_stop"),
|
|
(HookEventName::PreCompact, "hook.pre_compact"),
|
|
(HookEventName::PostCompact, "hook.post_compact"),
|
|
];
|
|
|
|
// Exhaustive match — adding a new HookEventName variant causes a
|
|
// compiler error here, forcing this test to be updated.
|
|
let total_variants = |e: HookEventName| -> usize {
|
|
match e {
|
|
HookEventName::SessionStart
|
|
| HookEventName::SessionEnd
|
|
| HookEventName::Stop
|
|
| HookEventName::StopFailure
|
|
| HookEventName::PreToolUse
|
|
| HookEventName::PostToolUse
|
|
| HookEventName::PostToolUseFailure
|
|
| HookEventName::PermissionDenied
|
|
| HookEventName::UserPromptSubmit
|
|
| HookEventName::Notification
|
|
| HookEventName::SubagentStart
|
|
| HookEventName::SubagentStop
|
|
| HookEventName::SubagentEnd
|
|
| HookEventName::PreCompact
|
|
| HookEventName::PostCompact => 15,
|
|
}
|
|
};
|
|
assert_eq!(
|
|
cases.len() + 1, // +1 for PreToolUse (blocking, tested separately)
|
|
total_variants(HookEventName::SessionStart),
|
|
"update hub_hook_kind test when new HookEventName variants are added"
|
|
);
|
|
|
|
for (event, expected) in cases {
|
|
let kind = hub_hook_kind(*event);
|
|
assert_eq!(
|
|
kind.as_deref(),
|
|
Some(*expected),
|
|
"hub_hook_kind wrong for {event:?}"
|
|
);
|
|
}
|
|
}
|
|
}
|