M0: compilable skeleton — Kigi 0.1.0 fork surgery

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).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
+895
View File
@@ -0,0 +1,895 @@
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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(&registry, &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(
&registry,
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(
&registry,
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(
&registry,
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(
&registry,
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:?}"
);
}
}
}