docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
This commit is contained in:
@@ -277,7 +277,8 @@ pub fn parse_hook_file(content: &str, file_path: &Path) -> (Vec<HookSpec>, Vec<H
|
||||
// Step 2: extract only the "hooks" key. If absent, the file has no hooks.
|
||||
let hooks_value = match top_level.get("hooks") {
|
||||
Some(v) => v.clone(),
|
||||
None => return (specs, errors), // No hooks key — not an error, just no hooks.
|
||||
// No hooks key — not an error, just no hooks.
|
||||
None => return (specs, errors),
|
||||
};
|
||||
|
||||
let hooks_map: HooksMap = match HooksMap::from_value(hooks_value) {
|
||||
@@ -517,7 +518,8 @@ mod tests {
|
||||
assert_eq!(s.event, HookEventName::PreToolUse);
|
||||
assert!(s.matcher.is_some());
|
||||
assert!(s.enabled);
|
||||
assert_eq!(s.timeout_ms, 2000); // 2 seconds → 2000 ms
|
||||
// 2 seconds → 2000 ms
|
||||
assert_eq!(s.timeout_ms, 2000);
|
||||
assert_eq!(s.command, Some(PathBuf::from("bin/check.sh")));
|
||||
}
|
||||
|
||||
@@ -554,7 +556,8 @@ mod tests {
|
||||
}"#;
|
||||
let (specs, errors) = parse_hook_file(json, Path::new("/tmp/test.json"));
|
||||
assert!(errors.is_empty());
|
||||
assert!(specs[0].matcher.is_none()); // empty string → None → match all
|
||||
// empty string → None → match all
|
||||
assert!(specs[0].matcher.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1072,7 +1075,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `matcher` is intentionally NOT env-expanded. A
|
||||
/// `matcher` is deliberately NOT env-expanded. A
|
||||
/// matcher with `$VAR` must store the literal `$VAR` (anchored as
|
||||
/// part of the regex by `HookMatcher::new`). A future contributor
|
||||
/// adding "completeness" here would break regex semantics.
|
||||
|
||||
@@ -248,7 +248,8 @@ fn load_hooks_from_settings_file(path: &Path) -> (Vec<HookSpec>, Vec<HookError>)
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
return (Vec::new(), Vec::new()); // Missing file is fine.
|
||||
// Missing file is fine.
|
||||
return (Vec::new(), Vec::new());
|
||||
}
|
||||
return (
|
||||
Vec::new(),
|
||||
@@ -406,7 +407,8 @@ mod tests {
|
||||
#[test]
|
||||
fn load_nonexistent_dir() {
|
||||
let (registry, errors) = load_hooks(Some(Path::new("/nonexistent/path/hooks")), None);
|
||||
assert!(errors.is_empty()); // NotFound is silent
|
||||
// NotFound is silent
|
||||
assert!(errors.is_empty());
|
||||
assert!(registry.is_empty());
|
||||
}
|
||||
|
||||
@@ -613,10 +615,11 @@ mod tests {
|
||||
|
||||
let toml = dir.path().join("hooks.toml");
|
||||
std::fs::write(&toml, "").unwrap();
|
||||
assert!(!is_valid_hook_file(&toml)); // TOML no longer accepted
|
||||
// TOML no longer accepted
|
||||
assert!(!is_valid_hook_file(&toml));
|
||||
}
|
||||
|
||||
// ── Settings file discovery tests ────────────────────────────
|
||||
// Settings file discovery tests
|
||||
|
||||
#[test]
|
||||
fn load_from_settings_file() {
|
||||
@@ -642,7 +645,8 @@ mod tests {
|
||||
))],
|
||||
&[],
|
||||
);
|
||||
assert!(errors.is_empty()); // Missing file is fine, not an error.
|
||||
// Missing file is fine, not an error.
|
||||
assert!(errors.is_empty());
|
||||
assert!(registry.is_empty());
|
||||
}
|
||||
|
||||
|
||||
@@ -412,7 +412,7 @@ mod tests {
|
||||
registry
|
||||
}
|
||||
|
||||
// ── extract_tool_name tests ──────────────────────────────────
|
||||
// extract_tool_name tests
|
||||
|
||||
#[test]
|
||||
fn extract_tool_name_from_pre_tool_use() {
|
||||
@@ -453,7 +453,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── dispatch_pre_tool_use tests ──────────────────────────────
|
||||
// dispatch_pre_tool_use tests
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_registry_allows() {
|
||||
@@ -501,7 +501,8 @@ mod tests {
|
||||
let spec = make_command_spec(
|
||||
"disabled-deny",
|
||||
None,
|
||||
false, // disabled!
|
||||
// disabled!
|
||||
false,
|
||||
"echo '{\"decision\":\"deny\",\"reason\":\"should not run\"}'; exit 2",
|
||||
);
|
||||
let registry = registry_from_specs(vec![spec]);
|
||||
@@ -726,7 +727,7 @@ mod tests {
|
||||
assert_eq!(result.decision, HookDecision::Allow);
|
||||
}
|
||||
|
||||
// ── fail-open regression tests ───────────────────────────────
|
||||
// fail-open regression tests
|
||||
|
||||
#[tokio::test]
|
||||
async fn fail_open_records_error_in_run_results() {
|
||||
@@ -757,7 +758,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── dispatch_non_blocking tests ──────────────────────────────
|
||||
// dispatch_non_blocking tests
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_blocking_empty_registry() {
|
||||
@@ -827,7 +828,7 @@ mod tests {
|
||||
assert!(matches!(results[1], HookRunResult::Success { .. }));
|
||||
}
|
||||
|
||||
// ── hub_hook_kind tests ──────────────────────────────────────
|
||||
// hub_hook_kind tests
|
||||
|
||||
#[test]
|
||||
fn hub_hook_kind_returns_none_for_pre_tool_use() {
|
||||
@@ -878,7 +879,8 @@ mod tests {
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
cases.len() + 1, // +1 for PreToolUse (blocking, tested separately)
|
||||
// +1 for PreToolUse (blocking, tested separately)
|
||||
cases.len() + 1,
|
||||
total_variants(HookEventName::SessionStart),
|
||||
"update hub_hook_kind test when new HookEventName variants are added"
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
//!
|
||||
//! * config-load-time expansion is idempotent (re-running it on an already
|
||||
//! expanded string is a no-op),
|
||||
//! * vars that are intentionally deferred to runtime (set later by the
|
||||
//! * vars that are deliberately deferred to runtime (set later by the
|
||||
//! shell, the dispatcher, or `extra_env`) survive the load-time pass and
|
||||
//! are caught by the runtime pre-flight check in
|
||||
//! [`crate::runner::command`] if they remain unset at execution, and
|
||||
@@ -111,7 +111,7 @@ fn make_sentinel() -> String {
|
||||
///
|
||||
/// Unresolved references are preserved verbatim so this function is safe
|
||||
/// to call repeatedly (idempotent on already-expanded strings) and so
|
||||
/// references that are intentionally resolved at runtime (e.g. by the
|
||||
/// references that are deliberately resolved at runtime (e.g. by the
|
||||
/// dispatcher's always-set `KIGI_HOOK_*` vars) survive the load-time pass.
|
||||
///
|
||||
/// Parameter-expansion-modifier forms (`${VAR:-x}`, `${VAR%pat}`, etc.)
|
||||
@@ -126,7 +126,7 @@ pub(crate) fn expand_env_vars_with_extra(input: &str, extra: &HashMap<String, St
|
||||
// to appear in the input or in any extra-env value (vanishingly
|
||||
// unlikely; would require an adversary to predict our PRNG output),
|
||||
// panic in debug builds and fall through to legacy behaviour in
|
||||
// release. Returning the input unchanged is safer than rewriting a
|
||||
// release. Returning the input `unchanged` is safer than rewriting a
|
||||
// legitimate substring to `${`.
|
||||
debug_assert!(
|
||||
!input.contains(&sentinel) && !extra.values().any(|v| v.contains(&sentinel)),
|
||||
@@ -410,7 +410,7 @@ mod tests {
|
||||
assert_eq!(expand_env_vars_with_extra("", &extra), "");
|
||||
}
|
||||
|
||||
// ── Parameter-expansion-modifier preservation ───────────────
|
||||
// Parameter-expansion-modifier preservation
|
||||
|
||||
/// `${VAR:-default}` must be preserved verbatim, even when `VAR` is
|
||||
/// unset at expand time. Otherwise shellexpand resolves to the
|
||||
@@ -525,7 +525,7 @@ mod tests {
|
||||
assert_eq!(out, "/usr/local/${KIGI_HOOKS_DEFER:-/fallback}");
|
||||
}
|
||||
|
||||
// ── Set-but-empty regression test ────────────────────────────
|
||||
// Set-but-empty regression test
|
||||
|
||||
/// When the var is set in `extra` but to the empty string, the
|
||||
/// no-modifier form `${VAR}` resolves to "" (matching shellexpand's
|
||||
@@ -553,7 +553,7 @@ mod tests {
|
||||
assert_eq!(out, input);
|
||||
}
|
||||
|
||||
// ── Single-pass expansion (no recursion) ────────────────────
|
||||
// Single-pass expansion (no recursion)
|
||||
|
||||
/// A value in `extra` that itself contains a `$VAR` reference must
|
||||
/// NOT be re-expanded. Recursion would be a DoS vector and a
|
||||
@@ -577,7 +577,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── mask_modifier_forms helper unit tests ────────────────────
|
||||
// mask_modifier_forms helper unit tests
|
||||
|
||||
/// A fixed test-only sentinel used to make the masked-output
|
||||
/// assertions deterministic. Production code uses [`make_sentinel`]
|
||||
@@ -623,7 +623,7 @@ mod tests {
|
||||
assert_eq!(masked, expected);
|
||||
}
|
||||
|
||||
// ── Nested / interleaved edge cases ─────────────────────────
|
||||
// Nested / interleaved edge cases
|
||||
|
||||
/// Two consecutive modifier forms with no
|
||||
/// intervening text. Both must be masked independently.
|
||||
@@ -667,7 +667,7 @@ mod tests {
|
||||
assert_eq!(masked, "${A}${B:-");
|
||||
}
|
||||
|
||||
// ── Sentinel collision regression ──────────────────────────
|
||||
// Sentinel collision regression
|
||||
|
||||
/// The previous sentinel was `\x00\x00`. If a
|
||||
/// future change reverted to that sentinel, an `extra_env` value
|
||||
@@ -677,7 +677,7 @@ mod tests {
|
||||
/// vanishingly unlikely to collide. This regression test
|
||||
/// constructs an input containing the OLD `\x00\x00` sequence
|
||||
/// AND a value containing the OLD sequence in `extra_env`, and
|
||||
/// asserts both pass through unchanged.
|
||||
/// asserts both pass through `unchanged`.
|
||||
#[test]
|
||||
fn mask_helper_preserves_pre_existing_old_nul_sentinel() {
|
||||
// The OLD sentinel as a literal in the input.
|
||||
@@ -752,7 +752,7 @@ mod tests {
|
||||
assert_eq!(out, format!("X={exotic}"));
|
||||
}
|
||||
|
||||
// ── iter_env_var_references unit tests ───────────────────────
|
||||
// iter_env_var_references unit tests
|
||||
|
||||
/// Lock down the iterator output for a single braced plain form.
|
||||
#[test]
|
||||
|
||||
@@ -11,7 +11,7 @@ pub const MAX_PAYLOAD_SIZE: usize = 128 * 1024;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HookEventName {
|
||||
// ── Session lifecycle ───────────────────────────────────────
|
||||
// Session lifecycle
|
||||
SessionStart,
|
||||
SessionEnd,
|
||||
/// Fires when an agent turn ends (completed, cancelled, or error).
|
||||
@@ -19,7 +19,7 @@ pub enum HookEventName {
|
||||
/// Fires when the turn ends due to an API error. Output and exit code are ignored.
|
||||
StopFailure,
|
||||
|
||||
// ── Tool events ─────────────────────────────────────────────
|
||||
// Tool events
|
||||
PreToolUse,
|
||||
PostToolUse,
|
||||
/// Fires after a tool call fails (throws an error).
|
||||
@@ -27,13 +27,13 @@ pub enum HookEventName {
|
||||
/// Fires when a tool call is denied by the permission system.
|
||||
PermissionDenied,
|
||||
|
||||
// ── User / notification events ──────────────────────────────
|
||||
// User / notification events
|
||||
/// Fires when the user submits a prompt.
|
||||
UserPromptSubmit,
|
||||
/// Fires when a notification is sent (e.g., permission prompt, idle).
|
||||
Notification,
|
||||
|
||||
// ── Subagent events ─────────────────────────────────────────
|
||||
// Subagent events
|
||||
/// Fires when a subagent is spawned.
|
||||
SubagentStart,
|
||||
/// Fires when a subagent completes.
|
||||
@@ -41,7 +41,7 @@ pub enum HookEventName {
|
||||
/// Alias for SubagentStop (kept for backward compatibility).
|
||||
SubagentEnd,
|
||||
|
||||
// ── Compaction events ───────────────────────────────────────
|
||||
// Compaction events
|
||||
/// Fires before context compaction.
|
||||
PreCompact,
|
||||
/// Fires after context compaction completes.
|
||||
@@ -176,7 +176,7 @@ pub struct HookEventEnvelope {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum HookPayload {
|
||||
// ── Session lifecycle ───────────────────────────────────────
|
||||
// Session lifecycle
|
||||
SessionStart {
|
||||
source: String,
|
||||
#[serde(rename = "modelId", skip_serializing_if = "Option::is_none")]
|
||||
@@ -198,7 +198,7 @@ pub enum HookPayload {
|
||||
error: String,
|
||||
},
|
||||
|
||||
// ── Tool events ─────────────────────────────────────────────
|
||||
// Tool events
|
||||
PreToolUse {
|
||||
/// The tool the model invoked. For the meta-dispatch tools (`use_tool`
|
||||
/// and the external MCP-call tool) this is the resolved underlying tool
|
||||
@@ -265,7 +265,7 @@ pub enum HookPayload {
|
||||
tool_input_truncated: bool,
|
||||
},
|
||||
|
||||
// ── User / notification events ──────────────────────────────
|
||||
// User / notification events
|
||||
/// Fires when the user submits a prompt.
|
||||
UserPromptSubmit {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -284,7 +284,7 @@ pub enum HookPayload {
|
||||
level: Option<String>,
|
||||
},
|
||||
|
||||
// ── Subagent events ─────────────────────────────────────────
|
||||
// Subagent events
|
||||
/// Fires when a subagent is spawned.
|
||||
SubagentStart {
|
||||
#[serde(rename = "subagentId")]
|
||||
@@ -308,7 +308,7 @@ pub enum HookPayload {
|
||||
duration_ms: Option<u64>,
|
||||
},
|
||||
|
||||
// ── Compaction events ───────────────────────────────────────
|
||||
// Compaction events
|
||||
PreCompact {
|
||||
/// "manual" or "auto".
|
||||
source: String,
|
||||
@@ -407,7 +407,8 @@ mod tests {
|
||||
(HookEventName::PermissionDenied, "permission_denied"),
|
||||
(HookEventName::SubagentStart, "subagent_start"),
|
||||
(HookEventName::SubagentStop, "subagent_stop"),
|
||||
(HookEventName::SubagentEnd, "subagent_stop"), // alias collapses
|
||||
// alias collapses
|
||||
(HookEventName::SubagentEnd, "subagent_stop"),
|
||||
(HookEventName::PreCompact, "pre_compact"),
|
||||
(HookEventName::PostCompact, "post_compact"),
|
||||
];
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
//! # kigi-hooks
|
||||
//!
|
||||
//! Runtime hook system for Kigi — file-based discovery, command execution,
|
||||
//! and policy enforcement.
|
||||
//!
|
||||
//! ## Overview
|
||||
//!
|
||||
//! This crate provides a minimal hooks system for Kigi. Hooks are discovered
|
||||
//! from dedicated directories (`~/.kigi/hooks/` and `<git-worktree-root>/.kigi/hooks/`),
|
||||
//! defined in JSON files (compatible settings format), and executed as child processes.
|
||||
//! Hooks are discovered from dedicated directories (`~/.kigi/hooks/` and
|
||||
//! `<git-worktree-root>/.kigi/hooks/`), defined in JSON files (compatible
|
||||
//! settings format), and executed as child processes.
|
||||
//!
|
||||
//! ## v0 scope
|
||||
//!
|
||||
|
||||
@@ -2,7 +2,7 @@ use kigi_tools::types::{claude_names_for, kigi_names_for};
|
||||
use regex::Regex;
|
||||
|
||||
/// A compiled hook matcher for tool names. The pattern semantics are chosen so that
|
||||
/// `matcher` entries in hooks migrated from other agent CLIs keep firing unchanged:
|
||||
/// `matcher` entries in hooks migrated from other agent CLIs keep firing `unchanged`:
|
||||
///
|
||||
/// - an empty pattern or `"*"` matches every tool;
|
||||
/// - a "simple" pattern (only `[A-Za-z0-9_|]`, i.e. a plain name or `|`-list) is an
|
||||
@@ -117,7 +117,8 @@ mod tests {
|
||||
// Contains regex metachars -> regex mode, unanchored.
|
||||
let m = HookMatcher::new("run_.*").unwrap();
|
||||
assert!(m.is_match("run_terminal_command"));
|
||||
assert!(m.is_match("xrun_yyy")); // unanchored: substring match
|
||||
// unanchored: substring match
|
||||
assert!(m.is_match("xrun_yyy"));
|
||||
assert!(!m.is_match("read_file"));
|
||||
}
|
||||
|
||||
@@ -152,13 +153,14 @@ mod tests {
|
||||
assert!(!m.is_match("run_terminal_command"));
|
||||
}
|
||||
|
||||
// ── External tool-name aliases ────────────────────────────────
|
||||
// External tool-name aliases
|
||||
|
||||
#[test]
|
||||
fn claude_bash_matches_kigi_tool() {
|
||||
let m = HookMatcher::new("Bash").unwrap();
|
||||
assert!(m.is_match("Bash")); // external alias name
|
||||
assert!(m.is_match("run_terminal_command")); // Kigi name
|
||||
// external alias name
|
||||
assert!(m.is_match("Bash"));
|
||||
assert!(m.is_match("run_terminal_command"));
|
||||
assert!(!m.is_match("read_file"));
|
||||
// Bug-fix regression: exact, not prefix.
|
||||
assert!(!m.is_match("run_terminal_command_v2"));
|
||||
@@ -169,8 +171,10 @@ mod tests {
|
||||
let m = HookMatcher::new("Edit|Write").unwrap();
|
||||
assert!(m.is_match("Edit"));
|
||||
assert!(m.is_match("Write"));
|
||||
assert!(m.is_match("search_replace")); // Kigi equivalent
|
||||
assert!(m.is_match("hashline_edit")); // second Kigi alias
|
||||
// Kigi equivalent
|
||||
assert!(m.is_match("search_replace"));
|
||||
// second Kigi alias
|
||||
assert!(m.is_match("hashline_edit"));
|
||||
assert!(!m.is_match("read_file"));
|
||||
// The old anchoring bug matched these; the exact-list mode must not.
|
||||
assert!(!m.is_match("Editorial"));
|
||||
|
||||
@@ -11,9 +11,7 @@ pub enum HookDecision {
|
||||
|
||||
/// HTTP-specific execution details for scrollback enrichment.
|
||||
///
|
||||
/// Populated only for `"http"` handler type hooks. Carries the target
|
||||
/// URL, HTTP status, and a short preview of the response body so that
|
||||
/// scrollback annotations can display them.
|
||||
/// Populated only for `"http"` handler type hooks.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HttpInfo {
|
||||
/// The URL that was POSTed to.
|
||||
@@ -39,8 +37,7 @@ pub struct HttpInfo {
|
||||
///
|
||||
/// [`url`]: HttpInfo::url
|
||||
pub raw_url: Option<String>,
|
||||
/// HTTP status code (e.g. 200, 500). `None` if the request never
|
||||
/// completed (timeout, connection error).
|
||||
/// `None` if the request never completed (timeout, connection error).
|
||||
pub status: Option<u16>,
|
||||
/// Short preview of the response body (truncated to ~200 chars).
|
||||
/// `None` if no body was read (e.g. non-blocking hooks, timeouts).
|
||||
@@ -50,11 +47,9 @@ pub struct HttpInfo {
|
||||
/// The outcome of a single hook execution.
|
||||
#[derive(Debug)]
|
||||
pub enum HookRunResult {
|
||||
/// Hook executed successfully.
|
||||
Success {
|
||||
hook_name: String,
|
||||
elapsed: Duration,
|
||||
/// HTTP details, populated only for `"http"` handler type hooks.
|
||||
http_info: Option<HttpInfo>,
|
||||
},
|
||||
/// Hook was skipped because it is disabled.
|
||||
@@ -64,7 +59,6 @@ pub enum HookRunResult {
|
||||
hook_name: String,
|
||||
error: String,
|
||||
elapsed: Duration,
|
||||
/// HTTP details, populated only for `"http"` handler type hooks.
|
||||
http_info: Option<HttpInfo>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ pub async fn run_command_hook(
|
||||
// cannot open /dev/tty and corrupt the TUI display. Delegates to
|
||||
// `kigi_tools::util::detach_command`: Unix uses the same setsid /
|
||||
// EPERM→setpgid pre_exec path as before; Windows sets CREATE_NO_WINDOW only
|
||||
// (DETACHED_PROCESS is intentionally omitted — it breaks stdio inheritance).
|
||||
// (DETACHED_PROCESS is deliberately omitted — it breaks stdio inheritance).
|
||||
kigi_tools::util::detach_command(&mut cmd);
|
||||
|
||||
// Spawn the child process.
|
||||
@@ -649,7 +649,8 @@ mod tests {
|
||||
let large = vec![b'x'; MAX_OUTPUT_BYTES + 1000];
|
||||
let result = truncate_output(&large);
|
||||
assert!(result.ends_with(" [truncated]"));
|
||||
assert!(result.len() > MAX_OUTPUT_BYTES); // marker appended
|
||||
// marker appended
|
||||
assert!(result.len() > MAX_OUTPUT_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -833,11 +834,12 @@ mod tests {
|
||||
#[test]
|
||||
fn shell_command_detection() {
|
||||
// Commands with shell metacharacters should be detected.
|
||||
assert!("echo hello".contains(' ')); // space
|
||||
assert!("a || b".contains('|')); // pipe/or
|
||||
assert!("a && b".contains('&')); // and
|
||||
assert!("a; b".contains(';')); // semicolon
|
||||
assert!("a > out".contains('>')); // redirect
|
||||
assert!("echo hello".contains(' '));
|
||||
// pipe/or
|
||||
assert!("a || b".contains('|'));
|
||||
assert!("a && b".contains('&'));
|
||||
assert!("a; b".contains(';'));
|
||||
assert!("a > out".contains('>'));
|
||||
// Env-var interpolation must also force the sh -c branch so that
|
||||
// commands like `${CLAUDE_PLUGIN_ROOT}/hooks/foo.sh` get expanded
|
||||
// by the shell rather than treated as a literal executable path.
|
||||
@@ -860,7 +862,7 @@ mod tests {
|
||||
/// Regression: a hook command that uses `${VAR}` interpolation
|
||||
/// without any other shell metacharacters must still be invoked via
|
||||
/// `sh -c` so that the env var supplied via `extra_env` is expanded.
|
||||
/// Previously the runner treated `${...}` as part of a literal path
|
||||
/// earlier the runner treated `${...}` as part of a literal path
|
||||
/// and `command_path.exists()` failed; the hook silently never ran.
|
||||
/// Now the env-var pre-spawn check refuses with a clear reason when
|
||||
/// the var is unset (and the dispatcher fail-opens, so the tool call
|
||||
@@ -1104,7 +1106,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_undefined_env_var_refuses_to_spawn() {
|
||||
let mut extra_env = std::collections::HashMap::new();
|
||||
// Intentionally do NOT set NEVER_SET_GB1183 anywhere.
|
||||
// Deliberately do NOT set NEVER_SET_GB1183 anywhere.
|
||||
extra_env.insert("UNRELATED_GB1183".to_string(), "/tmp".to_string());
|
||||
|
||||
let spec = HookSpec {
|
||||
@@ -1150,7 +1152,7 @@ mod tests {
|
||||
|
||||
/// Regression: a hook command starting with `~` must be
|
||||
/// routed through `sh -c` so the shell expands `~` to `$HOME`.
|
||||
/// Previously `~/.claude/hook.sh` was treated as a relative path and
|
||||
/// earlier `~/.claude/hook.sh` was treated as a relative path and
|
||||
/// joined to `source_dir`, producing a broken path.
|
||||
///
|
||||
/// The test injects `HOME` via `extra_env` so it works in sandboxed
|
||||
@@ -1245,7 +1247,7 @@ mod tests {
|
||||
configured_matcher: None,
|
||||
matcher: None,
|
||||
enabled: true,
|
||||
// `MISSING_GB1183_DEFAULT` is intentionally unset; the `:-`
|
||||
// `MISSING_GB1183_DEFAULT` is deliberately unset; the `:-`
|
||||
// modifier supplies a fallback that points at the real script.
|
||||
command: Some(std::path::PathBuf::from(format!(
|
||||
"${{MISSING_GB1183_DEFAULT:-{}}}",
|
||||
|
||||
@@ -35,44 +35,55 @@ fn is_blocked_ip(ip: &IpAddr) -> bool {
|
||||
IpAddr::V4(v4) => {
|
||||
let octets = v4.octets();
|
||||
if octets[0] == 127 {
|
||||
return false; // loopback — allowed for local dev
|
||||
// loopback — allowed for local dev
|
||||
return false;
|
||||
}
|
||||
if octets[0] == 10 {
|
||||
return true; // RFC 1918: 10.0.0.0/8
|
||||
// RFC 1918: 10.0.0.0/8
|
||||
return true;
|
||||
}
|
||||
if octets[0] == 172 && (16..=31).contains(&octets[1]) {
|
||||
return true; // RFC 1918: 172.16.0.0/12
|
||||
// RFC 1918: 172.16.0.0/12
|
||||
return true;
|
||||
}
|
||||
if octets[0] == 192 && octets[1] == 168 {
|
||||
return true; // RFC 1918: 192.168.0.0/16
|
||||
// RFC 1918: 192.168.0.0/16
|
||||
return true;
|
||||
}
|
||||
if octets[0] == 169 && octets[1] == 254 {
|
||||
return true; // RFC 3927: 169.254.0.0/16 (link-local, cloud metadata)
|
||||
// RFC 3927: 169.254.0.0/16 (link-local, cloud metadata)
|
||||
return true;
|
||||
}
|
||||
if octets[0] == 100 && (64..=127).contains(&octets[1]) {
|
||||
return true; // RFC 6598: 100.64.0.0/10 (CGNAT)
|
||||
// RFC 6598: 100.64.0.0/10 (CGNAT)
|
||||
return true;
|
||||
}
|
||||
if v4.is_unspecified() {
|
||||
return true; // 0.0.0.0
|
||||
// 0.0.0.0
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
IpAddr::V6(v6) => {
|
||||
if v6.is_loopback() {
|
||||
return false; // ::1 — allowed for local dev
|
||||
// ::1 — allowed for local dev
|
||||
return false;
|
||||
}
|
||||
if v6.is_unspecified() {
|
||||
return true; // ::
|
||||
// ::
|
||||
return true;
|
||||
}
|
||||
if let Some(v4) = v6.to_ipv4_mapped() {
|
||||
return is_blocked_ip(&IpAddr::V4(v4));
|
||||
}
|
||||
let segments = v6.segments();
|
||||
if segments[0] & 0xffc0 == 0xfe80 {
|
||||
return true; // fe80::/10 — link-local
|
||||
// fe80::/10 — link-local
|
||||
return true;
|
||||
}
|
||||
if segments[0] & 0xfe00 == 0xfc00 {
|
||||
return true; // fc00::/7 — unique local (ULA)
|
||||
// fc00::/7 — unique local (ULA)
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -398,7 +409,7 @@ mod tests {
|
||||
use super::*;
|
||||
use reqwest::StatusCode;
|
||||
|
||||
// ── parse_http_blocking_result tests ──────────────────────────
|
||||
// parse_http_blocking_result tests
|
||||
|
||||
#[test]
|
||||
fn http_allow_json() {
|
||||
@@ -560,7 +571,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── SSRF protection: is_blocked_ip tests ──────────────
|
||||
// SSRF protection: is_blocked_ip tests
|
||||
|
||||
#[test]
|
||||
fn ssrf_blocks_rfc1918_10x() {
|
||||
@@ -632,7 +643,7 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
// ── SSRF protection: validate_hook_url tests ──────────
|
||||
// SSRF protection: validate_hook_url tests
|
||||
|
||||
#[tokio::test]
|
||||
async fn ssrf_rejects_http_scheme() {
|
||||
@@ -675,7 +686,7 @@ mod tests {
|
||||
assert!(result.unwrap_err().contains("invalid URL"));
|
||||
}
|
||||
|
||||
// ── URL env-var expansion (extra_env precedence) ───────────
|
||||
// URL env-var expansion (extra_env precedence)
|
||||
|
||||
use crate::config::HookSpec;
|
||||
use crate::event::{HookEventEnvelope, HookEventName, HookPayload};
|
||||
@@ -883,7 +894,7 @@ mod tests {
|
||||
let (result, _, info) = run_http_hook(&spec, &envelope, &ctx, true).await;
|
||||
|
||||
// Either `Failed` (timeout / connection error) is fine; both
|
||||
// exercise paths that previously embedded the raw URL via
|
||||
// exercise paths that earlier embedded the raw URL via
|
||||
// `format!("...{e}")`. Pure timeouts use a different
|
||||
// formatting branch (no URL involved), so prefer the
|
||||
// connection-error case but tolerate either.
|
||||
|
||||
@@ -7,32 +7,23 @@ use crate::config::HookSpec;
|
||||
use crate::event::HookEventEnvelope;
|
||||
use crate::result::{HookDecision, HttpInfo};
|
||||
|
||||
/// Context passed to any hook runner for environment setup.
|
||||
pub struct RunContext<'a> {
|
||||
pub session_id: &'a str,
|
||||
pub workspace_root: &'a str,
|
||||
}
|
||||
|
||||
/// Result of running a single hook (any handler type).
|
||||
#[derive(Debug)]
|
||||
pub enum HookRunnerResult {
|
||||
/// Hook ran and produced a decision (for blocking hooks).
|
||||
Decision(HookDecision),
|
||||
/// Hook ran successfully (for non-blocking hooks).
|
||||
Success,
|
||||
/// Hook failed — caller should fail-open.
|
||||
/// Callers must fail open on this variant: a broken hook never blocks the
|
||||
/// session.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Bundle returned by each runner: the result, wall-clock duration, and
|
||||
/// optional HTTP metadata for enriched scrollback logging.
|
||||
/// Result, wall-clock duration, and HTTP metadata for scrollback enrichment.
|
||||
pub type HookRunOutput = (HookRunnerResult, Duration, Option<HttpInfo>);
|
||||
|
||||
/// Run a hook using the appropriate handler for its type.
|
||||
///
|
||||
/// Dispatches to `command::run_command_hook()` or `http::run_http_hook()`
|
||||
/// based on `spec.handler_type`. Returns the result, elapsed duration, and
|
||||
/// optional HTTP metadata for scrollback enrichment.
|
||||
pub async fn run_hook(
|
||||
spec: &HookSpec,
|
||||
envelope: &HookEventEnvelope,
|
||||
|
||||
@@ -1,32 +1,18 @@
|
||||
//! Test-only helpers shared across `kigi-hooks` unit + integration tests.
|
||||
//!
|
||||
//! This module is gated on `#[cfg(test)]` and is exported as `pub(crate)`
|
||||
//! so any in-crate `#[cfg(test)] mod tests` can use it. Integration tests
|
||||
//! under `tests/` cannot reach it; for those, copy or re-implement the
|
||||
//! handful of functions here that they need (the only one currently used
|
||||
//! by integration tests is unrelated).
|
||||
//! Test-only helpers for `kigi-hooks`. Gated on `#[cfg(test)]`, so integration
|
||||
//! tests under `tests/` cannot reach it — they must re-implement what they need.
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind};
|
||||
|
||||
/// Run `f` with the env var `name` set to `value` (or unset if `value`
|
||||
/// is `None`), restoring the previous value on return.
|
||||
///
|
||||
/// Uses `catch_unwind` so a panic inside `f` does not leak the env var
|
||||
/// into the rest of the test process.
|
||||
/// The save -> set -> run -> restore lifecycle is panic-safe but not race-safe:
|
||||
/// `cargo test` runs tests in parallel and env vars are process-global, so
|
||||
/// callers must pick uniquely-named vars.
|
||||
///
|
||||
/// `cargo test` runs tests in parallel by default. Process env vars are
|
||||
/// process-global, so callers should pick uniquely-named vars to avoid
|
||||
/// inter-test races. The lifecycle here (save -> set -> run -> restore)
|
||||
/// is panic-safe but not race-safe.
|
||||
///
|
||||
/// **FOLLOW-UP**: the helper does not
|
||||
/// enforce the unique-name discipline -- a future contributor passing
|
||||
/// a common name like `HOME` could trigger flaky tests. The standard
|
||||
/// fix is to add `serial_test` as a dev-dep and decorate every
|
||||
/// env-touching test with `#[serial(env_var)]` so the test runner
|
||||
/// serialises them. For now the unique-name
|
||||
/// convention plus `catch_unwind` restoration is sufficient for the
|
||||
/// tests that ship today.
|
||||
/// FIXME: nothing enforces the unique-name discipline; a caller passing `HOME`
|
||||
/// would produce flaky tests. The fix is a `serial_test` dev-dep plus
|
||||
/// `#[serial(env_var)]` on every env-touching test.
|
||||
pub(crate) fn with_env_var<R>(name: &str, value: Option<&str>, f: impl FnOnce() -> R) -> R {
|
||||
let previous = std::env::var_os(name);
|
||||
// SAFETY: env-var writes are not thread-safe. Callers use uniquely
|
||||
@@ -74,7 +60,7 @@ mod tests {
|
||||
#[test]
|
||||
fn restores_previous_unset_state_on_normal_return() {
|
||||
let key = "KIGI_HOOKS_TEST_SUPPORT_UNSET_RESTORE";
|
||||
// SAFETY: see module-level note.
|
||||
// SAFETY: see the thread-safety note on `with_env_var`.
|
||||
unsafe {
|
||||
std::env::remove_var(key);
|
||||
}
|
||||
@@ -87,7 +73,7 @@ mod tests {
|
||||
#[test]
|
||||
fn restores_after_panic() {
|
||||
let key = "KIGI_HOOKS_TEST_SUPPORT_PANIC_RESTORE";
|
||||
// SAFETY: see module-level note.
|
||||
// SAFETY: see the thread-safety note on `with_env_var`.
|
||||
unsafe {
|
||||
std::env::remove_var(key);
|
||||
}
|
||||
@@ -106,7 +92,7 @@ mod tests {
|
||||
#[test]
|
||||
fn allows_explicit_unset() {
|
||||
let key = "KIGI_HOOKS_TEST_SUPPORT_EXPLICIT_UNSET";
|
||||
// SAFETY: see module-level note.
|
||||
// SAFETY: see the thread-safety note on `with_env_var`.
|
||||
unsafe {
|
||||
std::env::set_var(key, "before");
|
||||
}
|
||||
@@ -114,7 +100,7 @@ mod tests {
|
||||
assert!(std::env::var(key).is_err());
|
||||
});
|
||||
assert_eq!(std::env::var(key).unwrap(), "before");
|
||||
// SAFETY: see module-level note.
|
||||
// SAFETY: see the thread-safety note on `with_env_var`.
|
||||
unsafe {
|
||||
std::env::remove_var(key);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ pub fn list_trusted_projects_with_file(trust_file: &Path) -> std::io::Result<Vec
|
||||
.collect())
|
||||
}
|
||||
|
||||
// ── Hook enable/disable ─────────────────────────────────────────────────
|
||||
// Hook enable/disable
|
||||
|
||||
/// Check whether a hook is disabled by name.
|
||||
///
|
||||
@@ -65,7 +65,8 @@ pub fn disable_hook(hook_name: &str) -> Result<(), String> {
|
||||
|
||||
fn disable_hook_with_file(hook_name: &str, file: &Path) -> Result<(), String> {
|
||||
if is_hook_disabled_with_file(hook_name, file) {
|
||||
return Ok(()); // Already disabled.
|
||||
// Already disabled.
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(parent) = file.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
|
||||
Reference in New Issue
Block a user