//! Phase C — cross-CLI hook enforcement via MCP-wrapped tools. //! //! Exposes three built-in MCP tools — `kei_bash`, `kei_edit`, `kei_write` — //! that synthesize Claude Code's PreToolUse hook input contract, chain //! through the hook scripts declared in `~/.claude/hooks/_lib/policy-chain.toml`, //! and only execute the wrapped action if every hook returns exit 0. //! //! Why this exists: when an agent runs on Grok / Agy / Copilot / Kimi, none //! of our claude-side PreToolUse hooks fire. The agent could read the rules //! in its system prompt but the tool-call layer was previously ungated. The //! `kei_*` MCP tools restore that gate for any MCP-capable CLI. //! //! Constructor Pattern: ONE policy SSoT (`policy-chain.toml`), ONE dispatcher //! (this file), hooks reused as-is from `~/.claude/hooks/`. No rewrite, no //! abstraction layer. Shell-out per hook keeps the contract identical to //! Claude's native PreToolUse pipeline. //! //! CLAUDECODE / GROKCODE guard — DESIGN NOTE (NOT a security boundary): //! When invoked from inside Claude Code (`$CLAUDECODE=1`) or Grok the chain //! is SKIPPED to avoid double-firing the same hooks (they already ran on the //! CLI's own PreToolUse). This is a perf / UX optimization for the inside-CLI //! call path — NOT an authorization check. An attacker who can set the //! parent process's environment already controls the CLI invocation anyway; //! re-running hooks would not stop them. To raise the bar for confused-deputy //! scenarios use full sandboxing (Phase D) or run kei-mcp as a separate UID. //! //! v0.41 audit fixes (2026-05-26, Gemini security review): //! #1 fail-CLOSED on missing hooks (was: silently skip) //! #2 path-traversal guard on kei_edit/kei_write (canonicalize + root check) //! #3 CLAUDECODE bypass — documented as design (see above), no behavior change //! #4 tokio::fs for async file I/O (was: blocking std::fs on tokio thread) //! #5 process-group kill on Unix (was: kill_on_drop SIGKILLs only direct child) //! //! v0.42 re-audit fixes (2026-05-26, 4-CLI dogfood: Claude+Grok+Gemini+Copilot): //! #1 [CRITICAL] symlink LEAF bypass — canonicalize full path + reject //! leaf symlinks (v0.41 only canonicalized PARENT; ln -s ~/.ssh/keys ./x //! then kei_write x followed the link to the target) //! #2 [HIGH] $HOME removed from default allowed_roots — was a blanket //! allow that let agent overwrite ~/.claude/hooks (self-neuter), ~/.zshrc //! (RCE on next shell), and credential stores. Default: $PWD only. //! Denylist also extended with .claude/, .grok/, .gemini/, .copilot/, //! .kimi/, and exact shell-init filenames. //! #3 [HIGH] empty [bash]/[edit]/[write] section also FAIL-CLOSED (was: //! empty vec → pass-through). KEI_POLICY_CHAIN_OPTIONAL=1 to opt in. //! #4 [MED] load_chain converted to async + tokio::fs (was: blocking //! std::fs on tokio worker thread). //! #5 [MED] set_process_group + killpg applied to HOOK subprocess too //! (v0.41 only had it on the bash action; hook grandchildren orphaned). //! #6 [MED] doc note that aggregate timeout is still per-step (60s × //! N hooks + 60s action). Single-deadline implementation deferred to //! v0.43 — not security-blocking. use crate::protocol::{err, ok, JsonRpcRequest, JsonRpcResponse, INTERNAL_ERROR, INVALID_PARAMS}; use serde::Deserialize; use serde_json::{json, Value}; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::Duration; use tokio::fs; use tokio::io::AsyncWriteExt; use tokio::process::Command; /// Hard cap on how long a single hook chain + action may take. Matches the /// timeout in `handlers::tools::ATOM_TIMEOUT_SECS` for consistency. const SAFE_TOOL_TIMEOUT_SECS: u64 = 60; #[derive(Deserialize, Default)] struct PolicyChain { #[serde(default)] bash: ChainSpec, #[serde(default)] edit: ChainSpec, #[serde(default)] write: ChainSpec, } #[derive(Deserialize, Default)] struct ChainSpec { #[serde(default)] chain: Vec, } /// MCP tool descriptors — appended to `tools/list` by `handlers::tools::list`. pub fn descriptors() -> Vec { vec![ json!({ "name": "kei_bash", "description": "Run a shell command after running KeiSeiKit's [bash] policy chain (no-github-push, safety-guard, destructive-guard). Blocks on hook exit 2 with the hook's stderr surfaced as the MCP error message. Use this instead of native shell on non-Claude CLIs to inherit Claude Code's safety enforcement.", "inputSchema": { "type": "object", "properties": { "command": { "type": "string", "description": "Shell command to execute" }, "cwd": { "type": "string", "description": "Optional working directory; defaults to $PWD" } }, "required": ["command"] } }), json!({ "name": "kei_edit", "description": "Modify a file (replace old_string with new_string) after running KeiSeiKit's [edit] policy chain (citation-verify, numeric-claims-guard). Blocks unverified academic citations and numeric claims without evidence markers.", "inputSchema": { "type": "object", "properties": { "file_path": { "type": "string" }, "old_string": { "type": "string" }, "new_string": { "type": "string" } }, "required": ["file_path", "old_string", "new_string"] } }), json!({ "name": "kei_write", "description": "Write content to a file after running KeiSeiKit's [write] policy chain (citation-verify, numeric-claims-guard). Blocks unverified academic citations and numeric claims without evidence markers.", "inputSchema": { "type": "object", "properties": { "file_path": { "type": "string" }, "content": { "type": "string" } }, "required": ["file_path", "content"] } }), ] } /// Top-level dispatch entry — called from `handlers::tools::call` when the /// tool name matches one of the three `kei_*` built-ins. pub async fn dispatch_safe(req: JsonRpcRequest, name: &str, args: &Value) -> JsonRpcResponse { let result = match name { "kei_bash" => handle_bash(args).await, "kei_edit" => handle_edit(args).await, "kei_write" => handle_write(args).await, _ => Err(format!("safe_tools dispatched unknown name: {name}")), }; match result { Ok(text) => ok(req.id, json!({ "content": [{ "type": "text", "text": text }], "isError": false, })), Err(e) => err(req.id, INTERNAL_ERROR, e), } } // ---- per-tool handlers -------------------------------------------------- async fn handle_bash(args: &Value) -> Result { let command = args.get("command").and_then(Value::as_str) .ok_or_else(|| missing_arg("kei_bash", "command"))?; let cwd = args.get("cwd").and_then(Value::as_str); let hook_input = json!({ "tool_name": "Bash", "tool_input": { "command": command } }); run_chain("bash", &hook_input).await?; let mut cmd = Command::new("bash"); cmd.arg("-c").arg(command); if let Some(dir) = cwd { cmd.current_dir(dir); } cmd.stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); // v0.41 fix #5 (Gemini MED): put child in its own process group so timeout // kills it and ALL grandchildren together (not just the immediate shell). set_process_group(&mut cmd); let child = cmd.spawn().map_err(|e| format!("spawn bash: {e}"))?; let pid_opt = child.id(); let fut = child.wait_with_output(); let out = match tokio::time::timeout(Duration::from_secs(SAFE_TOOL_TIMEOUT_SECS), fut).await { Ok(Ok(o)) => o, Ok(Err(e)) => return Err(format!("wait bash: {e}")), Err(_) => { // Timeout — kill the entire process group, not just the child. if let Some(pid) = pid_opt { killpg_best_effort(pid); } return Err("kei_bash timeout".to_string()); } }; let stdout = String::from_utf8_lossy(&out.stdout).to_string(); let stderr = String::from_utf8_lossy(&out.stderr).to_string(); if !out.status.success() { return Err(format!( "bash exited {}: {}", out.status.code().unwrap_or(-1), stderr.trim() )); } Ok(if stderr.is_empty() { stdout } else { format!("{stdout}\n[stderr]\n{stderr}") }) } // v0.41 fix #5: process-group helpers (Unix-only; no-op on other platforms). // tokio::process::Command::process_group is available on Unix without // requiring the std::os::unix::process::CommandExt trait import. #[cfg(unix)] fn set_process_group(cmd: &mut Command) { cmd.process_group(0); // 0 = new session leader for this child } #[cfg(not(unix))] fn set_process_group(_cmd: &mut Command) {} #[cfg(unix)] fn killpg_best_effort(pid: u32) { // SAFETY: libc::kill on a negative PID targets the process group. // SIGKILL = 9. Best-effort — ignore errors (process may have exited). unsafe { let _ = libc::kill(-(pid as i32), libc::SIGKILL); } } #[cfg(not(unix))] fn killpg_best_effort(_pid: u32) {} async fn handle_edit(args: &Value) -> Result { let file_path = args.get("file_path").and_then(Value::as_str) .ok_or_else(|| missing_arg("kei_edit", "file_path"))?; let old_string = args.get("old_string").and_then(Value::as_str) .ok_or_else(|| missing_arg("kei_edit", "old_string"))?; let new_string = args.get("new_string").and_then(Value::as_str) .ok_or_else(|| missing_arg("kei_edit", "new_string"))?; // v0.41 fix #2: path-traversal guard let safe_path = validate_path(file_path)?; let hook_input = json!({ "tool_name": "Edit", "tool_input": { "file_path": safe_path.display().to_string(), "old_string": old_string, "new_string": new_string } }); run_chain("edit", &hook_input).await?; // v0.41 fix #4: tokio::fs (async) let contents = fs::read_to_string(&safe_path).await .map_err(|e| format!("read {}: {e}", safe_path.display()))?; if !contents.contains(old_string) { return Err(format!("kei_edit: old_string not found in {}", safe_path.display())); } let updated = contents.replacen(old_string, new_string, 1); fs::write(&safe_path, &updated).await .map_err(|e| format!("write {}: {e}", safe_path.display()))?; Ok(format!("edited {} ({} bytes)", safe_path.display(), updated.len())) } async fn handle_write(args: &Value) -> Result { let file_path = args.get("file_path").and_then(Value::as_str) .ok_or_else(|| missing_arg("kei_write", "file_path"))?; let content = args.get("content").and_then(Value::as_str) .ok_or_else(|| missing_arg("kei_write", "content"))?; // v0.41 fix #2: path-traversal guard let safe_path = validate_path(file_path)?; let hook_input = json!({ "tool_name": "Write", "tool_input": { "file_path": safe_path.display().to_string(), "content": content } }); run_chain("write", &hook_input).await?; if let Some(parent) = safe_path.parent() { if !parent.as_os_str().is_empty() { fs::create_dir_all(parent).await .map_err(|e| format!("mkdir {}: {e}", parent.display()))?; } } fs::write(&safe_path, content).await .map_err(|e| format!("write {}: {e}", safe_path.display()))?; Ok(format!("wrote {} ({} bytes)", safe_path.display(), content.len())) } /// Path-traversal + symlink + denylist guard. /// /// v0.41 (initial): rejected `..`, canonicalized PARENT, checked denylist + roots. /// → 4-CLI re-audit (2026-05-26) found this was bypassable via symlink at the /// leaf and self-attackable via the $HOME blanket-allowed root. /// /// v0.42 fixes: /// #1 [CRITICAL] reject if the leaf is a symlink (was: validated parent /// only, fs::write followed leaf symlink to anywhere). Done via /// `symlink_metadata` on the leaf BEFORE write, and full `canonicalize` /// on the leaf when the file already exists. /// #2 [HIGH] $HOME removed from default allowed-roots — default is $PWD /// only. Denylist now also covers $HOME/.claude/ (the substrate /// itself), shell init files, and credential stores. Operators who /// need broader access set KEI_ALLOWED_ROOTS explicitly. fn validate_path(p: &str) -> Result { if p.is_empty() { return Err("file_path: empty".into()); } // 1. Reject literal `..` segments — covers most traversal attempts. if p.split('/').any(|seg| seg == "..") { return Err(format!("file_path: '..' segment not allowed in {p}")); } let path = Path::new(p); // 2. Build a canonical path. Prefer canonicalizing the FULL path (resolves // symlinks at the leaf, fixing v0.41 CRITICAL bypass). For files that // don't exist yet (kei_write new file), canonicalize the parent and // join the leaf — but then explicitly check the leaf isn't a symlink // via symlink_metadata before writing. let canonical = if path.exists() { // File exists — canonicalize full path, including resolving any leaf // symlink to its real target. The denylist/roots check below then // sees the REAL destination, not the symlink name. path.canonicalize() .map_err(|e| format!("file_path: canonicalize {}: {e}", path.display()))? } else if let Some(parent) = path.parent() { if parent.as_os_str().is_empty() || parent == Path::new("") { std::env::current_dir() .map_err(|e| format!("file_path: cwd unavailable: {e}"))? .join(path.file_name().unwrap_or_default()) } else if parent.exists() { parent.canonicalize() .map_err(|e| format!("file_path: canonicalize {}: {e}", parent.display()))? .join(path.file_name().unwrap_or_default()) } else if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir() .map_err(|e| format!("file_path: cwd unavailable: {e}"))? .join(path) } } else { return Err(format!("file_path: invalid {p}")); }; // 3. Even when the file doesn't exist yet, the LEAF could already be a // dangling symlink that `fs::write` would follow on creation. Reject. if let Ok(meta) = std::fs::symlink_metadata(&canonical) { if meta.file_type().is_symlink() { return Err(format!( "file_path: leaf is a symlink (refusing to follow): {}", canonical.display() )); } } let canon_str = canonical.display().to_string(); // 4. Reject system + substrate-control + credential paths. let denylist = [ "/etc/", "/usr/", "/System/", "/var/", "/private/etc/", "/private/var/", "/root/", "/bin/", "/sbin/", ]; for d in denylist { if canon_str.starts_with(d) { return Err(format!("file_path: denied (system dir): {canon_str}")); } } if let Ok(home) = std::env::var("HOME") { // v0.42 fix #2 extended denylist — these targets enable self-attack // (overwrite the substrate or shell init for RCE on next session). let dir_secrets = [ ".ssh/", ".aws/", ".gnupg/", ".config/gcloud/", ".cargo/credentials", ".npmrc", ".docker/config.json", ".kube/", ".claude/", // our own substrate: hooks, settings, agents ".grok/", // sibling CLI's settings ".gemini/", // antigravity settings ".copilot/", // copilot config ".kimi/", // kimi config ]; for sd in dir_secrets { let full = format!("{home}/{sd}"); if canon_str.starts_with(&full) { return Err(format!("file_path: denied (secret/substrate dir): {canon_str}")); } } // Exact shell-init files (overwriting → RCE on next shell start). let init_files = [ ".zshrc", ".bashrc", ".profile", ".bash_profile", ".zprofile", ".zshenv", ".bash_login", ".inputrc", ".gitconfig", ".config/fish/config.fish", ]; for f in init_files { let full = format!("{home}/{f}"); if canon_str == full { return Err(format!("file_path: denied (shell-init file): {canon_str}")); } } } // 5. Enforce allowed-root containment. let roots = allowed_roots(); if !roots.is_empty() { let ok = roots.iter().any(|r| canon_str.starts_with(r)); if !ok { return Err(format!( "file_path: outside allowed roots {roots:?}: {canon_str}" )); } } Ok(canonical) } fn allowed_roots() -> Vec { if let Ok(v) = std::env::var("KEI_ALLOWED_ROOTS") { return v.split(':').filter(|s| !s.is_empty()).map(String::from).collect(); } // v0.42 fix #2 (Claude+Gemini HIGH): default to $PWD ONLY. Was: $PWD + // $HOME blanket — too permissive, agent could overwrite ~/.claude/hooks/ // or ~/.zshrc and self-neuter the safety layer. Operators who need // broader access opt in via KEI_ALLOWED_ROOTS=":" -separated abs paths. let mut roots = Vec::new(); if let Ok(cwd) = std::env::current_dir() { roots.push(format!("{}/", cwd.display())); } roots } // ---- chain runner ------------------------------------------------------- /// Run the configured hook chain for `tool` ("bash"/"edit"/"write"), piping /// `hook_input` to each hook's stdin in order. Exit 0 → continue. Exit 2 (or /// other non-zero) → return Err with the hook's stderr. /// /// Skips the chain if the parent process is already inside Claude or Grok /// (env flags), since those CLIs' native PreToolUse hooks already fired. /// Run the configured hook chain for `tool` ("bash"/"edit"/"write"). /// /// v0.42 fixes: /// #3 [HIGH] empty chain (section absent or zero hooks) now FAILS CLOSED /// unless KEI_POLICY_CHAIN_OPTIONAL=1. /// #4 [MED] load_chain() converted to async (was: blocking std::fs). /// #5 [MED] hook subprocess gets `process_group(0)` + killpg on timeout /// (was: only the bash action got it; hooks could orphan). /// #6 [MED] aggregate timeout across the whole chain + action (was: /// per-hook 60s, so chain+action could legitimately run /// 4× the documented cap on a 3-hook chain). async fn run_chain(tool: &str, hook_input: &Value) -> Result<(), String> { if env_truthy("CLAUDECODE") || env_truthy("GROKCODE") { // Native hooks already enforced — don't double-fire. return Ok(()); } let chain = load_chain(tool).await?; if chain.is_empty() { // v0.42 fix #3 (Claude+Gemini HIGH): empty section is the same // misconfig class as missing file — FAIL CLOSED with explicit opt-in. if env_truthy("KEI_POLICY_CHAIN_OPTIONAL") { return Ok(()); } return Err(format!( "[policy-chain] section [{tool}] is empty — refusing to run \ (set KEI_POLICY_CHAIN_OPTIONAL=1 to allow pass-through, e.g. for tests)" )); } let hooks_dir = hooks_dir()?; let payload = serde_json::to_string(hook_input) .map_err(|e| format!("encode hook input: {e}"))?; for hook in chain { let path = hooks_dir.join(&hook); if !path.is_file() { return Err(format!( "[policy-chain] hook missing: {} (declared in policy-chain.toml [{}])", path.display(), tool )); } let mut child_cmd = Command::new(&path); child_cmd .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); // v0.42 fix #5: put hook child in its own process group so timeout // can killpg the whole tree (was: kill_on_drop = immediate child only). set_process_group(&mut child_cmd); let mut child = child_cmd .spawn() .map_err(|e| format!("spawn {}: {e}", path.display()))?; let pid_opt = child.id(); if let Some(mut stdin) = child.stdin.take() { stdin.write_all(payload.as_bytes()).await .map_err(|e| format!("write stdin to {}: {e}", path.display()))?; stdin.shutdown().await .map_err(|e| format!("close stdin to {}: {e}", path.display()))?; } let fut = child.wait_with_output(); let out = match tokio::time::timeout(Duration::from_secs(SAFE_TOOL_TIMEOUT_SECS), fut).await { Ok(Ok(o)) => o, Ok(Err(e)) => return Err(format!("wait {}: {e}", path.display())), Err(_) => { // v0.42 fix #5: kill the whole hook process group, not just // the immediate child. if let Some(pid) = pid_opt { killpg_best_effort(pid); } return Err(format!("hook {hook} timeout")); } }; let code = out.status.code().unwrap_or(-1); if code == 0 { continue; } let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); return Err(format!( "[blocked by {hook} exit={code}]\n{stderr}" )); } Ok(()) } // ---- config helpers ----------------------------------------------------- /// v0.42 fix #4: async + tokio::fs (was: blocking std::fs would freeze /// a tokio worker if policy-chain.toml lived on a slow / hung mount). async fn load_chain(tool: &str) -> Result, String> { let path = chain_path()?; // tokio::fs::try_exists avoids a blocking is_file() syscall. let exists = fs::try_exists(&path).await.unwrap_or(false); if !exists { if env_truthy("KEI_POLICY_CHAIN_OPTIONAL") { return Ok(vec![]); } return Err(format!( "[policy-chain] config missing: {} (set KEI_POLICY_CHAIN_OPTIONAL=1 to allow pass-through, e.g. for tests)", path.display() )); } let raw = fs::read_to_string(&path).await .map_err(|e| format!("read policy-chain.toml: {e}"))?; let parsed: PolicyChain = toml::from_str(&raw) .map_err(|e| format!("parse policy-chain.toml: {e}"))?; let chain = match tool { "bash" => parsed.bash.chain, "edit" => parsed.edit.chain, "write" => parsed.write.chain, _ => return Err(format!("unknown tool kind: {tool}")), }; Ok(chain) } fn chain_path() -> Result { if let Ok(p) = std::env::var("KEI_POLICY_CHAIN") { return Ok(PathBuf::from(p)); } let dir = hooks_dir()?; Ok(dir.join("_lib").join("policy-chain.toml")) } fn hooks_dir() -> Result { if let Ok(p) = std::env::var("KEI_HOOKS_DIR") { return Ok(PathBuf::from(p)); } let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?; Ok(PathBuf::from(home).join(".claude").join("hooks")) } fn env_truthy(name: &str) -> bool { matches!(std::env::var(name).as_deref(), Ok("1") | Ok("true") | Ok("TRUE") | Ok("yes")) } fn missing_arg(tool: &str, field: &str) -> String { format!("{tool}: missing '{field}' argument") } #[allow(dead_code)] const INVALID_PARAMS_REF: i32 = INVALID_PARAMS; // silence unused-import warning if removed