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:
@@ -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(®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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user