From 4e5e6bd2c0931860a8e526c5fd7f503b7b700029 Mon Sep 17 00:00:00 2001 From: KeiSei84 <2206745@gmail.com> Date: Tue, 26 May 2026 18:03:33 +0800 Subject: [PATCH] feat(phase-C): cross-CLI hook enforcement via kei_bash/kei_edit/kei_write MCP tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the "hooks only fire on Claude" gap. Phase C extends KeiSeiKit safety enforcement (no-github-push, safety-guard, destructive-guard, citation-verify, numeric-claims-guard) to any MCP-capable LLM CLI through a 3-tier honesty model. ## 3-tier model TIER 1 (full native): claude (existing), grok (port hooks to grok settings.json) TIER 2 (MCP-wrapped): copilot (--excluded-tools=shell + force kei_bash via MCP) TIER 3 (advisory): agy + kimi (cannot disable native shell; prompt-level only) ## Design (Constructor Pattern) 1. hooks/_lib/policy-chain.toml — SSoT: which hooks gate which tool (bash/edit/write) 2. _primitives/_rust/kei-mcp/src/handlers/safe_tools.rs — new module, 3 built-in MCP tools that synthesize Claude PreToolUse JSON, run hook chain, abort on exit-2, exec on all-pass. Same input contract → hooks reused as-is, no rewrite. 3. tools.rs short-circuit: kei_bash/kei_edit/kei_write dispatched before atom layer 4. 6 wire scripts: orchestrator + one per CLI (Constructor Pattern, no mixin) 5. bin/kei mcp-wire arm 6. docs/encyclopedia/cross-cli-policy.md — honest 3-tier matrix + verification ## Double-enforcement guard If kei-mcp invoked from a process with $CLAUDECODE=1 or $GROKCODE=1, the chain SKIPS — native hooks already fired. Wire scripts set these env vars in the MCP server registration for claude/grok respectively. On copilot/agy/kimi the env is unset → chain runs. ## Smoke (verified live) Block: kei_bash{command: forbidden-push-pattern} → JSON-RPC error -32603 with full "BLOCK — RULE 0.1 NO GITHUB PUSH" stderr ✓ Pass: kei_bash{command: "echo HELLO-FROM-KEI-BASH"} → result.content[0].text = "HELLO-FROM-KEI-BASH" ✓ tools/list: 4 built-ins present (spawn_agent + kei_bash + kei_edit + kei_write) ✓ ## Tests kei-mcp: 3/3 (tools_list assertions updated for atoms+4 built-ins). Build clean with toml = "0.8" dep added. ## Out of scope (deferred) - Codex CLI wiring (not installed locally) - ACP middleware proxy (transport, not middleware — ruled out at research) - Container/firejail sandboxing for agy/kimi (heavy; documented limit instead) - Native Rust PatternGate migration (optimization, separate phase) --- _primitives/_rust/Cargo.lock | 1 + _primitives/_rust/kei-mcp/Cargo.toml | 3 + _primitives/_rust/kei-mcp/src/handlers/mod.rs | 1 + .../_rust/kei-mcp/src/handlers/safe_tools.rs | 320 ++++++++++++++++++ .../_rust/kei-mcp/src/handlers/tools.rs | 11 + _primitives/_rust/kei-mcp/tests/tools_list.rs | 23 +- bin/kei | 7 + docs/encyclopedia/cross-cli-policy.md | 145 ++++++++ docs/encyclopedia/multi-cli-agents.md | 9 +- hooks/_lib/policy-chain.toml | 32 ++ scripts/kei-mcp-wire-agy.sh | 52 +++ scripts/kei-mcp-wire-claude.sh | 42 +++ scripts/kei-mcp-wire-copilot.sh | 52 +++ scripts/kei-mcp-wire-grok.sh | 77 +++++ scripts/kei-mcp-wire-kimi.sh | 52 +++ scripts/kei-mcp-wire.sh | 106 ++++++ 16 files changed, 927 insertions(+), 6 deletions(-) create mode 100644 _primitives/_rust/kei-mcp/src/handlers/safe_tools.rs create mode 100644 docs/encyclopedia/cross-cli-policy.md create mode 100644 hooks/_lib/policy-chain.toml create mode 100755 scripts/kei-mcp-wire-agy.sh create mode 100755 scripts/kei-mcp-wire-claude.sh create mode 100755 scripts/kei-mcp-wire-copilot.sh create mode 100755 scripts/kei-mcp-wire-grok.sh create mode 100755 scripts/kei-mcp-wire-kimi.sh create mode 100755 scripts/kei-mcp-wire.sh diff --git a/_primitives/_rust/Cargo.lock b/_primitives/_rust/Cargo.lock index bf65d2d..e7638b4 100644 --- a/_primitives/_rust/Cargo.lock +++ b/_primitives/_rust/Cargo.lock @@ -3973,6 +3973,7 @@ dependencies = [ "serde_json", "tempfile", "tokio", + "toml", ] [[package]] diff --git a/_primitives/_rust/kei-mcp/Cargo.toml b/_primitives/_rust/kei-mcp/Cargo.toml index d9d753b..b6f0768 100644 --- a/_primitives/_rust/kei-mcp/Cargo.toml +++ b/_primitives/_rust/kei-mcp/Cargo.toml @@ -21,6 +21,9 @@ serde_json = { workspace = true } # v0.39: io-std added for tokio::io::stdin/stdout used by the MCP stdio # transport in main.rs (workspace tokio doesn't enable io-std by default). tokio = { workspace = true, features = ["io-std"] } +# v0.40 (Phase C): toml needed for safe_tools::policy_chain — reads +# ~/.claude/hooks/_lib/policy-chain.toml to know which hooks to chain. +toml = "0.8" anyhow = { workspace = true } kei-atom-discovery = { path = "../kei-atom-discovery" } kei-skills = { path = "../kei-skills" } diff --git a/_primitives/_rust/kei-mcp/src/handlers/mod.rs b/_primitives/_rust/kei-mcp/src/handlers/mod.rs index fc43b69..da6d208 100644 --- a/_primitives/_rust/kei-mcp/src/handlers/mod.rs +++ b/_primitives/_rust/kei-mcp/src/handlers/mod.rs @@ -12,6 +12,7 @@ pub mod initialize; pub mod prompts; pub mod resources; +pub mod safe_tools; pub mod tools; use crate::protocol::{err, JsonRpcRequest, JsonRpcResponse, Method, ServerContext, METHOD_NOT_FOUND}; diff --git a/_primitives/_rust/kei-mcp/src/handlers/safe_tools.rs b/_primitives/_rust/kei-mcp/src/handlers/safe_tools.rs new file mode 100644 index 0000000..68f70b0 --- /dev/null +++ b/_primitives/_rust/kei-mcp/src/handlers/safe_tools.rs @@ -0,0 +1,320 @@ +//! 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. +//! +//! Guard against double-enforcement: if the parent process is already inside +//! Claude Code (`$CLAUDECODE=1`) or Grok (`$GROKCODE=1`), the chain is +//! skipped — the CLI's native hooks already fired on its own PreToolUse. + +use crate::protocol::{err, ok, JsonRpcRequest, JsonRpcResponse, INTERNAL_ERROR, INVALID_PARAMS}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::fs; +use std::path::PathBuf; +use std::process::Stdio; +use std::time::Duration; +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); + + let fut = cmd.spawn().map_err(|e| format!("spawn bash: {e}"))?.wait_with_output(); + let out = tokio::time::timeout(Duration::from_secs(SAFE_TOOL_TIMEOUT_SECS), fut) + .await + .map_err(|_| "kei_bash timeout".to_string())? + .map_err(|e| format!("wait bash: {e}"))?; + + 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}") }) +} + +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"))?; + + let hook_input = json!({ + "tool_name": "Edit", + "tool_input": { + "file_path": file_path, + "old_string": old_string, + "new_string": new_string + } + }); + run_chain("edit", &hook_input).await?; + + let contents = fs::read_to_string(file_path) + .map_err(|e| format!("read {file_path}: {e}"))?; + if !contents.contains(old_string) { + return Err(format!("kei_edit: old_string not found in {file_path}")); + } + let updated = contents.replacen(old_string, new_string, 1); + fs::write(file_path, &updated) + .map_err(|e| format!("write {file_path}: {e}"))?; + Ok(format!("edited {file_path} ({} bytes)", 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"))?; + + let hook_input = json!({ + "tool_name": "Write", + "tool_input": { "file_path": file_path, "content": content } + }); + run_chain("write", &hook_input).await?; + + if let Some(parent) = std::path::Path::new(file_path).parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent) + .map_err(|e| format!("mkdir {}: {e}", parent.display()))?; + } + } + fs::write(file_path, content) + .map_err(|e| format!("write {file_path}: {e}"))?; + Ok(format!("wrote {file_path} ({} bytes)", content.len())) +} + +// ---- 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. +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)?; + if chain.is_empty() { + return Ok(()); + } + + 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() { + // Missing hook is a config error — log but don't block. Better + // to surface it to the user as a stderr-side warning than to + // silently allow the action. + eprintln!("[safe_tools] missing hook (skipped): {}", path.display()); + continue; + } + + let mut child = Command::new(&path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(|e| format!("spawn {}: {e}", path.display()))?; + + 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 = tokio::time::timeout(Duration::from_secs(SAFE_TOOL_TIMEOUT_SECS), fut) + .await + .map_err(|_| format!("hook {} timeout", hook))? + .map_err(|e| format!("wait {}: {e}", path.display()))?; + + 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 ----------------------------------------------------- + +fn load_chain(tool: &str) -> Result, String> { + let path = chain_path()?; + if !path.is_file() { + // No policy-chain.toml → unsafe default = pass through with a warning. + // This matches Claude Code's behavior when no hooks are configured. + eprintln!("[safe_tools] no policy-chain.toml at {}; passing through", path.display()); + return Ok(vec![]); + } + let raw = fs::read_to_string(&path) + .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 diff --git a/_primitives/_rust/kei-mcp/src/handlers/tools.rs b/_primitives/_rust/kei-mcp/src/handlers/tools.rs index 583f14f..927c0cf 100644 --- a/_primitives/_rust/kei-mcp/src/handlers/tools.rs +++ b/_primitives/_rust/kei-mcp/src/handlers/tools.rs @@ -37,6 +37,12 @@ pub fn list(req: JsonRpcRequest, ctx: &ServerContext) -> JsonRpcResponse { // CLI (grok / agy / copilot / kimi / claude) can spawn a KeiSeiKit agent // as a sub-agent. Bypasses atom discovery (it's an internal handler). tools.push(spawn_agent_descriptor()); + // v0.40 (Phase C): policy-gated MCP tools — kei_bash / kei_edit / + // kei_write run the configured hook chain BEFORE executing the action. + // This restores Claude Code's PreToolUse safety on non-Claude CLIs + // (Grok / Agy / Copilot / Kimi) — any MCP-capable orchestrator that + // disables its native shell + uses kei_bash gets full enforcement. + tools.extend(super::safe_tools::descriptors()); tools.sort_by(|a, b| { a.get("name").and_then(Value::as_str).unwrap_or("") .cmp(b.get("name").and_then(Value::as_str).unwrap_or("")) @@ -66,6 +72,11 @@ pub async fn call(req: JsonRpcRequest, ctx: &ServerContext) -> JsonRpcResponse { }; } + // v0.40 (Phase C): kei_bash / kei_edit / kei_write — policy-gated tools. + if matches!(name.as_str(), "kei_bash" | "kei_edit" | "kei_write") { + return super::safe_tools::dispatch_safe(req, &name, &args).await; + } + match invoke_atom(&ctx.atoms_root, &name, &args).await { Ok(result) => ok(req.id, json!({ "content": [{ "type": "text", "text": serde_json::to_string(&result).unwrap_or_default() }], diff --git a/_primitives/_rust/kei-mcp/tests/tools_list.rs b/_primitives/_rust/kei-mcp/tests/tools_list.rs index 03d5512..63f476a 100644 --- a/_primitives/_rust/kei-mcp/tests/tools_list.rs +++ b/_primitives/_rust/kei-mcp/tests/tools_list.rs @@ -68,12 +68,19 @@ async fn tools_list_returns_two_atoms_with_descriptors() { let resp = dispatch(req, &ctx).await; let result = resp.result.expect("should have result"); let tools = result["tools"].as_array().expect("tools array"); - // v0.39: list also includes the built-in `spawn_agent` tool (atoms + 1). - assert_eq!(tools.len(), 3); + // v0.40 (Phase C): list includes 4 built-ins (spawn_agent + kei_bash + + // kei_edit + kei_write) on top of discovered atoms. + assert_eq!(tools.len(), 6); // 2 atoms + 4 built-ins assert!( tools.iter().any(|t| t["name"] == "spawn_agent"), "spawn_agent built-in must be present" ); + for kei in ["kei_bash", "kei_edit", "kei_write"] { + assert!( + tools.iter().any(|t| t["name"] == kei), + "{kei} built-in must be present" + ); + } // sorted alphabetically assert_eq!(tools[0]["name"], "kei-sage::ask"); assert_eq!(tools[1]["name"], "kei-task::search"); @@ -96,8 +103,14 @@ async fn tools_list_handles_empty_root() { }; let resp = dispatch(req, &ctx).await; let result = resp.result.expect("should have result"); - // v0.39: empty atoms root still surfaces the built-in `spawn_agent` tool. + // v0.40 (Phase C): empty atoms root surfaces 4 built-ins + // (spawn_agent + kei_bash + kei_edit + kei_write). let tools = result["tools"].as_array().unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0]["name"], "spawn_agent"); + assert_eq!(tools.len(), 4); + let names: Vec<&str> = tools.iter() + .filter_map(|t| t["name"].as_str()) + .collect(); + for required in ["spawn_agent", "kei_bash", "kei_edit", "kei_write"] { + assert!(names.contains(&required), "missing built-in: {required}"); + } } diff --git a/bin/kei b/bin/kei index 191ca81..338da55 100755 --- a/bin/kei +++ b/bin/kei @@ -17,6 +17,9 @@ # # backends: claude grok agy copilot kimi codex # # `kei run-via list` shows install status + agents # kei primary [] # get/set primary LLM provider (DNA fallback) +# kei mcp-wire [] # wire kei-mcp into a CLI's MCP config + hook setup +# # (Phase C cross-CLI policy enforcement) +# kei mcp-wire --list # show enforcement tier per CLI # kei --on= # one-shot launch of (does not change primary) # kei [args...] # splash → exec primary CLI (default: claude) # @@ -59,6 +62,10 @@ case "${1:-}" in shift exec "$HOME/.claude/scripts/kei-pick.sh" "$@" ;; + mcp-wire|wire) + shift + exec "$HOME/.claude/scripts/kei-mcp-wire.sh" "$@" + ;; esac # --- one-shot --on= override (does not write primary.toml) ------- diff --git a/docs/encyclopedia/cross-cli-policy.md b/docs/encyclopedia/cross-cli-policy.md new file mode 100644 index 0000000..9793b70 --- /dev/null +++ b/docs/encyclopedia/cross-cli-policy.md @@ -0,0 +1,145 @@ +# Cross-CLI policy enforcement + +> *Same safety rules. Any LLM CLI. Three honesty tiers.* + +KeiSeiKit's safety hooks (`no-github-push`, `safety-guard`, `destructive-guard`, +`citation-verify`, `numeric-claims-guard`) originally fired only inside Claude +Code's `PreToolUse` pipeline. Phase C extends enforcement to other CLIs — +but the strength of enforcement depends on what each CLI permits. + +## The 3-tier honesty model + +| Tier | What it means | CLIs | +|---|---|---| +| **TIER 1 — full native** | Tool-call enforcement at the CLI's own hook layer. Same as Claude. | claude, **grok** | +| **TIER 2 — MCP-wrapped** | Native shell disabled at launch; agent forced to use our policy-gated `kei_bash`/`kei_edit`/`kei_write` MCP tools. | **copilot** | +| **TIER 3 — advisory** | CLI can't disable native shell; we register kei-mcp and instruct the agent to prefer `kei_*` tools, but enforcement is prompt-level only. | **agy, kimi** | + +For patent-sensitive or production-PR work — stick to TIER 1 (claude or grok). + +## How to wire + +One command sets up enforcement for whichever CLIs you have installed: + +```bash +kei mcp-wire # detect + wire all installed CLIs +kei mcp-wire grok # wire one CLI +kei mcp-wire --dry-run # preview config changes without writing +kei mcp-wire --list # show enforcement tier per CLI +``` + +The orchestrator is idempotent — running twice produces the same config. + +## What `kei mcp-wire` writes + +### claude (TIER 1 — already enforced) +No-op. Native PreToolUse hooks already gate every tool call. `kei mcp-wire claude` +prints the optional `mcpServers` snippet you can add to +`~/.claude/settings.json` if you want claude to also see `spawn_agent` for +sub-agent dispatch. + +### grok (TIER 1 — port our hooks) +Writes `~/.grok/settings.json` `hooks.PreToolUse` block: + +- `Bash` matcher → `no-github-push.sh` + `safety-guard.sh` + `destructive-guard.sh` +- `Edit` matcher → `citation-verify.sh` + `numeric-claims-guard.sh` +- `Write` matcher → `citation-verify.sh` + `numeric-claims-guard.sh` + +Plus registers kei-mcp with `GROKCODE=1` env (so kei-mcp's policy chain skips +duplicate enforcement when invoked via Grok — your native hooks already fired). + +xAI's Grok uses the same JSON input contract as Claude Code's PreToolUse, so +our hook scripts run unchanged. Identical enforcement to claude. + +### copilot (TIER 2 — disable native shell, force MCP) +Writes `~/.copilot/mcp-config.json` registering kei-mcp. To activate enforcement, +launch copilot with `--excluded-tools='shell'`: + +```bash +alias copilot='copilot --excluded-tools=shell' +``` + +The agent will have NO native shell tool, only kei-mcp's `kei_bash` — +which runs the policy chain before execution. `kei_edit` / `kei_write` +similarly gate file mutations. + +### agy / kimi (TIER 3 — advisory) +Writes their MCP config (`~/.gemini/config/mcp_config.json` for agy, +`~/.kimi/mcp.json` for kimi) registering kei-mcp. + +**The honest part:** these CLIs do NOT have a way to disable their native +shell. The agent CAN reach for native bash regardless of what we tell it. +The system prompt nudges it toward `kei_bash`, but a determined or careless +agent can bypass. + +For patent-sensitive work — **don't use agy or kimi as orchestrator**. +Use them for analysis / brainstorming / no-side-effect tasks only. + +## Internals + +### policy-chain.toml (SSoT) + +One file declares which hooks gate which tool, for all CLIs that go through +the MCP layer: + +```toml +# ~/.claude/hooks/_lib/policy-chain.toml +[bash] +chain = ["no-github-push.sh", "safety-guard.sh", "destructive-guard.sh"] + +[edit] +chain = ["citation-verify.sh", "numeric-claims-guard.sh"] + +[write] +chain = ["citation-verify.sh", "numeric-claims-guard.sh"] +``` + +To add a hook: append its basename. The hook script must already exist in +`~/.claude/hooks/` and follow the standard PreToolUse contract (read JSON +on stdin with `.tool_name` + `.tool_input`, return exit 0 = pass / 2 = block). + +### kei-mcp built-in tools + +`kei-mcp` (Rust MCP server at `_primitives/_rust/kei-mcp/`) exposes four +built-in tools that bypass atom discovery: + +- `spawn_agent(name, task, on?)` — invokes a KeiSeiKit agent on any backend +- `kei_bash(command, cwd?)` — runs `[bash]` chain → executes +- `kei_edit(file_path, old_string, new_string)` — runs `[edit]` chain → edits +- `kei_write(file_path, content)` — runs `[write]` chain → writes + +The chain runs against the same hook scripts Claude uses; identical input +shape, identical decisions. On block, the hook's stderr surfaces as the MCP +error message so the calling agent sees exactly why. + +### Double-enforcement guard + +If kei-mcp is invoked from a process where `$CLAUDECODE=1` or `$GROKCODE=1`, +it SKIPS its hook chain — the CLI's native hooks already fired. This is set +automatically by `kei mcp-wire claude` / `kei mcp-wire grok`. On copilot / +agy / kimi the env is unset → chain runs. + +## Verification + +```bash +# All 4 built-ins must list: +echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \ + | kei-mcp | jq -r '.result.capabilities' + +# Block test (kei_bash refuses forbidden command): +echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"kei_bash","arguments":{"command":"git push https://github.com/x/y.git main"}}}' \ + | kei-mcp 2>&1 | grep "RULE 0.1" # expects: BLOCK — RULE 0.1 NO GITHUB PUSH + +# Pass test: +echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"kei_bash","arguments":{"command":"echo OK"}}}' \ + | kei-mcp | tail -1 | jq -r '.result.content[0].text' # expects: OK +``` + +## Related + +- [Multi-CLI agent invocation](./multi-cli-agents.md) — DNA-resolved agent dispatch +- `kei-mcp` source: `_primitives/_rust/kei-mcp/src/handlers/safe_tools.rs` +- Policy SSoT: `hooks/_lib/policy-chain.toml` +- Wire scripts: `scripts/kei-mcp-wire*.sh` diff --git a/docs/encyclopedia/multi-cli-agents.md b/docs/encyclopedia/multi-cli-agents.md index a389721..5a72a83 100644 --- a/docs/encyclopedia/multi-cli-agents.md +++ b/docs/encyclopedia/multi-cli-agents.md @@ -171,7 +171,14 @@ Wire kei-mcp into the orchestrator's MCP config (each CLI has its own): Point each at `/_primitives/_rust/target/release/kei-mcp` (built via `cargo build -p kei-mcp --release`). -## Rule enforcement caveat (READ THIS) +## Rule enforcement — see also: cross-CLI policy + +**Phase C delivered**: KeiSeiKit's safety hooks now have a 3-tier enforcement +model across CLIs. See [cross-cli-policy.md](./cross-cli-policy.md) for the +full matrix and `kei mcp-wire` setup. Short version: TIER 1 (full native) +on claude+grok, TIER 2 (MCP-wrapped) on copilot, TIER 3 (advisory) on agy+kimi. + +## Rule enforcement caveat (READ THIS — pre-Phase-C view) KeiSeiKit hooks (`numeric-claims-guard`, `citation-verify`, `no-github-push`, `safety-guard`, `push-to-main`, etc.) are **Claude Code-side**: diff --git a/hooks/_lib/policy-chain.toml b/hooks/_lib/policy-chain.toml new file mode 100644 index 0000000..fdd42f4 --- /dev/null +++ b/hooks/_lib/policy-chain.toml @@ -0,0 +1,32 @@ +# policy-chain.toml — SSoT for which hooks gate which MCP tool. +# +# Consumed by `kei-mcp::handlers::safe_tools` to enforce KeiSeiKit's safety +# rules on non-Claude CLIs (Grok / Agy / Copilot / Kimi) via the +# `kei_bash` / `kei_edit` / `kei_write` MCP tools. +# +# Hooks live in ~/.claude/hooks/ (overridable via $KEI_HOOKS_DIR). +# Exit codes: 0 = pass, 2 = block, other non-zero = treat as block + log. +# The dispatcher iterates `chain` IN ORDER and aborts on first non-zero. +# +# Constructor Pattern: ONE chain for all CLIs. Per-CLI override deferred +# until proven necessary. To extend, append a hook basename (no .sh) to +# the relevant chain — the hook script must already exist in ~/.claude/hooks/. + +[bash] +chain = [ + "no-github-push.sh", + "safety-guard.sh", + "destructive-guard.sh", +] + +[edit] +chain = [ + "citation-verify.sh", + "numeric-claims-guard.sh", +] + +[write] +chain = [ + "citation-verify.sh", + "numeric-claims-guard.sh", +] diff --git a/scripts/kei-mcp-wire-agy.sh b/scripts/kei-mcp-wire-agy.sh new file mode 100755 index 0000000..ef5159a --- /dev/null +++ b/scripts/kei-mcp-wire-agy.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# kei-mcp-wire-agy — TIER 3: advisory enforcement for Google Antigravity. +# +# Antigravity (Gemini-backed) has NO tool allowlist mechanism — only the +# binary --dangerously-skip-permissions flag. We CANNOT disable its native +# shell. Best we can do: +# 1. Register kei-mcp via ~/.gemini/config/mcp_config.json +# 2. Prompt the agent (via its system prompt) to prefer kei_bash +# 3. Document honestly that this is advisory, not hard-enforced. + +set -eu + +CFG="$HOME/.gemini/config/mcp_config.json" +KEI_MCP_BIN="$HOME/.claude/_primitives/_rust/target/release/kei-mcp" +[ -f "$KEI_MCP_BIN" ] || KEI_MCP_BIN="$(command -v kei-mcp 2>/dev/null || true)" + +if [ -z "$KEI_MCP_BIN" ] || [ ! -x "$KEI_MCP_BIN" ]; then + echo " agy: kei-mcp binary missing — build first: cargo build -p kei-mcp --release" + exit 0 +fi + +mkdir -p "$(dirname "$CFG")" +[ -f "$CFG" ] || echo '{}' > "$CFG" + +desired=$(cat < "$tmp" +mv "$tmp" "$CFG" + +cat < "$CFG" + +desired=$(cat < "$tmp" +mv "$tmp" "$CFG" + +echo " copilot: kei-mcp registered → $CFG" +echo " copilot: to enforce, launch with: copilot --excluded-tools='shell'" +echo " (this disables native shell; agent must use kei_bash via MCP)" +echo " Consider adding an alias: alias copilot='copilot --excluded-tools=shell'" diff --git a/scripts/kei-mcp-wire-grok.sh b/scripts/kei-mcp-wire-grok.sh new file mode 100755 index 0000000..054b190 --- /dev/null +++ b/scripts/kei-mcp-wire-grok.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# kei-mcp-wire-grok — TIER 1: port KeiSeiKit hooks to Grok's PreToolUse pipeline. +# +# Grok CLI supports Claude-Code-compatible PreToolUse hooks via +# ~/.grok/settings.json. Same JSON input contract → our existing +# ~/.claude/hooks/*.sh scripts run unchanged. +# +# We register THREE hook entries (one per Bash-gating safety hook) plus +# the kei-mcp MCP server so Grok can also call spawn_agent. +# +# Idempotent: jq-merge into existing settings.json; foreign entries survive. + +set -eu + +CFG="$HOME/.grok/settings.json" +HOOKS_DIR="$HOME/.claude/hooks" +KEI_MCP_BIN="$HOME/.claude/_primitives/_rust/target/release/kei-mcp" +[ -f "$KEI_MCP_BIN" ] || KEI_MCP_BIN="$(command -v kei-mcp 2>/dev/null || true)" + +mkdir -p "$(dirname "$CFG")" +[ -f "$CFG" ] || echo '{}' > "$CFG" + +# Build the hook block — three Bash hooks + two Edit/Write hooks (same as +# Claude's policy-chain.toml). +desired=$(cat < "$tmp" +else + jq -s '.[0] * .[1]' "$CFG" <(printf '%s\n' "$desired") > "$tmp" +fi +mv "$tmp" "$CFG" + +echo " grok: wired PreToolUse hooks → $CFG" +echo " 5 hook entries (Bash×3 + Edit×2 + Write×2)" +[ -n "$mcp_block" ] && echo " kei-mcp MCP server registered (with GROKCODE=1 guard)" +echo " Same enforcement as Claude Code." diff --git a/scripts/kei-mcp-wire-kimi.sh b/scripts/kei-mcp-wire-kimi.sh new file mode 100755 index 0000000..0b369bf --- /dev/null +++ b/scripts/kei-mcp-wire-kimi.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# kei-mcp-wire-kimi — TIER 3: advisory enforcement for Moonshot Kimi. +# +# Kimi uses a confirmation-prompt model — no tool allowlist syntax, no +# --excluded-tools flag. The user is prompted before every native tool +# call (YOLO mode auto-approves). MCP server config at ~/.kimi/mcp.json. +# Best we can do: register kei-mcp + prompt the agent to prefer kei_bash. + +set -eu + +CFG="$HOME/.kimi/mcp.json" +KEI_MCP_BIN="$HOME/.claude/_primitives/_rust/target/release/kei-mcp" +[ -f "$KEI_MCP_BIN" ] || KEI_MCP_BIN="$(command -v kei-mcp 2>/dev/null || true)" + +if [ -z "$KEI_MCP_BIN" ] || [ ! -x "$KEI_MCP_BIN" ]; then + echo " kimi: kei-mcp binary missing — build first: cargo build -p kei-mcp --release" + exit 0 +fi + +mkdir -p "$(dirname "$CFG")" +[ -f "$CFG" ] || echo '{"mcpServers":{}}' > "$CFG" + +desired=$(cat < "$tmp" +mv "$tmp" "$CFG" + +cat < # wire one: claude/grok/copilot/agy/kimi +# kei mcp-wire --check # diff: current vs target (no writes) +# kei mcp-wire --dry-run # preview changes without applying +# kei mcp-wire --list # show enforcement tier per CLI +# +# Enforcement tiers (3-tier honesty model): +# TIER 1 — full native: claude (existing hooks), grok (ports our hooks +# to ~/.grok/settings.json — same JSON shape) +# TIER 2 — MCP-wrapped: copilot (disable native shell + force kei_bash) +# TIER 3 — advisory: agy + kimi (cannot disable native shell; +# MCP available but enforcement is prompt-only) + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +DRY_RUN=0 +CHECK=0 +LIST=0 +TARGET="" + +usage() { sed -n '2,17p' "$0" | sed 's|^# \{0,1\}||'; } + +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=1 ;; + --check) CHECK=1 ;; + --list) LIST=1 ;; + --help|-h) usage; exit 0 ;; + *) TARGET="$arg" ;; + esac +done + +export KEI_WIRE_DRY_RUN="$DRY_RUN" +export KEI_WIRE_CHECK="$CHECK" + +declare -A TIERS=( + [claude]="TIER 1: full native" + [grok]="TIER 1: full native (ports our hooks)" + [copilot]="TIER 2: MCP-wrapped (disable native shell)" + [agy]="TIER 3: advisory (no native-shell disable)" + [kimi]="TIER 3: advisory (confirmation model only)" +) + +backend_bin() { + case "$1" in + claude) echo "claude" ;; + grok) echo "grok" ;; + agy|antigravity) echo "agy" ;; + copilot) echo "copilot" ;; + kimi) echo "kimi" ;; + *) return 1 ;; + esac +} + +if [ "$LIST" = "1" ]; then + echo "Cross-CLI enforcement tiers:" + for cli in claude grok copilot agy kimi; do + bin=$(backend_bin "$cli") + if command -v "$bin" >/dev/null 2>&1; then + mark="✓" + else + mark="✗" + fi + printf " %s %-8s %s\n" "$mark" "$cli" "${TIERS[$cli]}" + done + exit 0 +fi + +wire_one() { + local cli="$1" wire_script="$SCRIPT_DIR/kei-mcp-wire-$cli.sh" + if [ ! -x "$wire_script" ]; then + echo "[kei-mcp-wire] no wire script for: $cli (expected $wire_script)" >&2 + return 2 + fi + local bin + bin=$(backend_bin "$cli") || { echo "unknown cli: $cli" >&2; return 2; } + if ! command -v "$bin" >/dev/null 2>&1; then + echo "[kei-mcp-wire] $cli not installed (skipping)" + return 0 + fi + echo + echo "──── $cli (${TIERS[$cli]}) ────" + "$wire_script" +} + +if [ -n "$TARGET" ]; then + wire_one "$TARGET" + exit $? +fi + +# No target → wire all installed CLIs. +echo "kei-mcp-wire: detecting installed CLIs..." +for cli in claude grok copilot agy kimi; do + wire_one "$cli" +done +echo +echo "done. See \`kei mcp-wire --list\` for per-CLI enforcement tier."