feat(phase-C): cross-CLI hook enforcement via kei_bash/kei_edit/kei_write MCP tools
Some checks are pending
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / preflight (push) Waiting to run
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / vps-smoke (push) Waiting to run
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:frustration-matrix,kei-frustration-loop,kei-skill-importer,kei-projects-index,kei-projects-watcher,kei-gdrive-import,kei-leak-matrix,kei-skills,kei-gateway,kei-cron-scheduler,kei-export-trajectories,kei-backend-daytona,kei-d… (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-compute-baremetal,kei-compute-vultr,kei-compute-linode,kei-compute-digitalocean,kei-svc-systemd,kei-llm-bridge-mlx name:hosted-sleep-compute]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-diff,kei-scheduler,kei-watch,kei-prune,kei-discover,kei-brain-view,kei-hibernate,kei-ledger-sign,kei-fork name:wave13-15]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-git-gitea,kei-git-forgejo,kei-git-gitlab,kei-git-bitbucket,kei-memory-sled,kei-memory-redis,kei-memory-postgres,kei-memory-sqlite,kei-auth-google,kei-auth-apple,kei-auth-magiclink,kei-auth-webauthn,kei-notify-slack,kei-n… (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-ledger,kei-migrate,kei-changelog,kei-memory,kei-store,kei-conflict-scan,kei-refactor-engine,kei-graph-check,kei-shared,kei-dna-index,kei-pet name:core]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-machine-probe,kei-llm-ollama,kei-llm-llamacpp,kei-llm-mlx,kei-llm-router,kei-model name:llm-stack]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-router,kei-sage,kei-task,kei-chat-store,kei-crossdomain,kei-search-core,kei-content-store,kei-social-store,kei-curator,kei-auth,kei-artifact name:mcp-lbm]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:keisei,kei-forge,kei-runtime,kei-runtime-core,kei-atom-discovery,kei-agent-runtime,kei-capability,kei-provision,kei-entity-store,kei-pipe,kei-cache,kei-spawn,kei-replay name:atom-substrate]) (push) Blocked by required conditions
Some checks are pending
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / preflight (push) Waiting to run
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / vps-smoke (push) Waiting to run
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:frustration-matrix,kei-frustration-loop,kei-skill-importer,kei-projects-index,kei-projects-watcher,kei-gdrive-import,kei-leak-matrix,kei-skills,kei-gateway,kei-cron-scheduler,kei-export-trajectories,kei-backend-daytona,kei-d… (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-compute-baremetal,kei-compute-vultr,kei-compute-linode,kei-compute-digitalocean,kei-svc-systemd,kei-llm-bridge-mlx name:hosted-sleep-compute]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-diff,kei-scheduler,kei-watch,kei-prune,kei-discover,kei-brain-view,kei-hibernate,kei-ledger-sign,kei-fork name:wave13-15]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-git-gitea,kei-git-forgejo,kei-git-gitlab,kei-git-bitbucket,kei-memory-sled,kei-memory-redis,kei-memory-postgres,kei-memory-sqlite,kei-auth-google,kei-auth-apple,kei-auth-magiclink,kei-auth-webauthn,kei-notify-slack,kei-n… (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-ledger,kei-migrate,kei-changelog,kei-memory,kei-store,kei-conflict-scan,kei-refactor-engine,kei-graph-check,kei-shared,kei-dna-index,kei-pet name:core]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-machine-probe,kei-llm-ollama,kei-llm-llamacpp,kei-llm-mlx,kei-llm-router,kei-model name:llm-stack]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:kei-router,kei-sage,kei-task,kei-chat-store,kei-crossdomain,kei-search-core,kei-content-store,kei-social-store,kei-curator,kei-auth,kei-artifact name:mcp-lbm]) (push) Blocked by required conditions
CI (Forgejo Actions — self-hosted runner on Mac, host mode) / rust-primitives (map[crates:keisei,kei-forge,kei-runtime,kei-runtime-core,kei-atom-discovery,kei-agent-runtime,kei-capability,kei-provision,kei-entity-store,kei-pipe,kei-cache,kei-spawn,kei-replay name:atom-substrate]) (push) Blocked by required conditions
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)
This commit is contained in:
parent
3fec43ea7e
commit
4e5e6bd2c0
16 changed files with 927 additions and 6 deletions
1
_primitives/_rust/Cargo.lock
generated
1
_primitives/_rust/Cargo.lock
generated
|
|
@ -3973,6 +3973,7 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@ serde_json = { workspace = true }
|
||||||
# v0.39: io-std added for tokio::io::stdin/stdout used by the MCP stdio
|
# 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).
|
# transport in main.rs (workspace tokio doesn't enable io-std by default).
|
||||||
tokio = { workspace = true, features = ["io-std"] }
|
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 }
|
anyhow = { workspace = true }
|
||||||
kei-atom-discovery = { path = "../kei-atom-discovery" }
|
kei-atom-discovery = { path = "../kei-atom-discovery" }
|
||||||
kei-skills = { path = "../kei-skills" }
|
kei-skills = { path = "../kei-skills" }
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
pub mod initialize;
|
pub mod initialize;
|
||||||
pub mod prompts;
|
pub mod prompts;
|
||||||
pub mod resources;
|
pub mod resources;
|
||||||
|
pub mod safe_tools;
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
|
|
||||||
use crate::protocol::{err, JsonRpcRequest, JsonRpcResponse, Method, ServerContext, METHOD_NOT_FOUND};
|
use crate::protocol::{err, JsonRpcRequest, JsonRpcResponse, Method, ServerContext, METHOD_NOT_FOUND};
|
||||||
|
|
|
||||||
320
_primitives/_rust/kei-mcp/src/handlers/safe_tools.rs
Normal file
320
_primitives/_rust/kei-mcp/src/handlers/safe_tools.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MCP tool descriptors — appended to `tools/list` by `handlers::tools::list`.
|
||||||
|
pub fn descriptors() -> Vec<Value> {
|
||||||
|
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<String, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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<Vec<String>, 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<PathBuf, String> {
|
||||||
|
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<PathBuf, String> {
|
||||||
|
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
|
||||||
|
|
@ -37,6 +37,12 @@ pub fn list(req: JsonRpcRequest, ctx: &ServerContext) -> JsonRpcResponse {
|
||||||
// CLI (grok / agy / copilot / kimi / claude) can spawn a KeiSeiKit agent
|
// CLI (grok / agy / copilot / kimi / claude) can spawn a KeiSeiKit agent
|
||||||
// as a sub-agent. Bypasses atom discovery (it's an internal handler).
|
// as a sub-agent. Bypasses atom discovery (it's an internal handler).
|
||||||
tools.push(spawn_agent_descriptor());
|
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| {
|
tools.sort_by(|a, b| {
|
||||||
a.get("name").and_then(Value::as_str).unwrap_or("")
|
a.get("name").and_then(Value::as_str).unwrap_or("")
|
||||||
.cmp(b.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 {
|
match invoke_atom(&ctx.atoms_root, &name, &args).await {
|
||||||
Ok(result) => ok(req.id, json!({
|
Ok(result) => ok(req.id, json!({
|
||||||
"content": [{ "type": "text", "text": serde_json::to_string(&result).unwrap_or_default() }],
|
"content": [{ "type": "text", "text": serde_json::to_string(&result).unwrap_or_default() }],
|
||||||
|
|
|
||||||
|
|
@ -68,12 +68,19 @@ async fn tools_list_returns_two_atoms_with_descriptors() {
|
||||||
let resp = dispatch(req, &ctx).await;
|
let resp = dispatch(req, &ctx).await;
|
||||||
let result = resp.result.expect("should have result");
|
let result = resp.result.expect("should have result");
|
||||||
let tools = result["tools"].as_array().expect("tools array");
|
let tools = result["tools"].as_array().expect("tools array");
|
||||||
// v0.39: list also includes the built-in `spawn_agent` tool (atoms + 1).
|
// v0.40 (Phase C): list includes 4 built-ins (spawn_agent + kei_bash +
|
||||||
assert_eq!(tools.len(), 3);
|
// kei_edit + kei_write) on top of discovered atoms.
|
||||||
|
assert_eq!(tools.len(), 6); // 2 atoms + 4 built-ins
|
||||||
assert!(
|
assert!(
|
||||||
tools.iter().any(|t| t["name"] == "spawn_agent"),
|
tools.iter().any(|t| t["name"] == "spawn_agent"),
|
||||||
"spawn_agent built-in must be present"
|
"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
|
// sorted alphabetically
|
||||||
assert_eq!(tools[0]["name"], "kei-sage::ask");
|
assert_eq!(tools[0]["name"], "kei-sage::ask");
|
||||||
assert_eq!(tools[1]["name"], "kei-task::search");
|
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 resp = dispatch(req, &ctx).await;
|
||||||
let result = resp.result.expect("should have result");
|
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();
|
let tools = result["tools"].as_array().unwrap();
|
||||||
assert_eq!(tools.len(), 1);
|
assert_eq!(tools.len(), 4);
|
||||||
assert_eq!(tools[0]["name"], "spawn_agent");
|
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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
7
bin/kei
7
bin/kei
|
|
@ -17,6 +17,9 @@
|
||||||
# # backends: claude grok agy copilot kimi codex
|
# # backends: claude grok agy copilot kimi codex
|
||||||
# # `kei run-via list` shows install status + agents
|
# # `kei run-via list` shows install status + agents
|
||||||
# kei primary [<backend>] # get/set primary LLM provider (DNA fallback)
|
# kei primary [<backend>] # get/set primary LLM provider (DNA fallback)
|
||||||
|
# kei mcp-wire [<cli>] # 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=<backend> # one-shot launch of <backend> (does not change primary)
|
# kei --on=<backend> # one-shot launch of <backend> (does not change primary)
|
||||||
# kei [args...] # splash → exec primary CLI (default: claude)
|
# kei [args...] # splash → exec primary CLI (default: claude)
|
||||||
#
|
#
|
||||||
|
|
@ -59,6 +62,10 @@ case "${1:-}" in
|
||||||
shift
|
shift
|
||||||
exec "$HOME/.claude/scripts/kei-pick.sh" "$@"
|
exec "$HOME/.claude/scripts/kei-pick.sh" "$@"
|
||||||
;;
|
;;
|
||||||
|
mcp-wire|wire)
|
||||||
|
shift
|
||||||
|
exec "$HOME/.claude/scripts/kei-mcp-wire.sh" "$@"
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# --- one-shot --on=<backend> override (does not write primary.toml) -------
|
# --- one-shot --on=<backend> override (does not write primary.toml) -------
|
||||||
|
|
|
||||||
145
docs/encyclopedia/cross-cli-policy.md
Normal file
145
docs/encyclopedia/cross-cli-policy.md
Normal file
|
|
@ -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`
|
||||||
|
|
@ -171,7 +171,14 @@ Wire kei-mcp into the orchestrator's MCP config (each CLI has its own):
|
||||||
Point each at `<kit>/_primitives/_rust/target/release/kei-mcp` (built via
|
Point each at `<kit>/_primitives/_rust/target/release/kei-mcp` (built via
|
||||||
`cargo build -p kei-mcp --release`).
|
`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`,
|
KeiSeiKit hooks (`numeric-claims-guard`, `citation-verify`, `no-github-push`,
|
||||||
`safety-guard`, `push-to-main`, etc.) are **Claude Code-side**:
|
`safety-guard`, `push-to-main`, etc.) are **Claude Code-side**:
|
||||||
|
|
|
||||||
32
hooks/_lib/policy-chain.toml
Normal file
32
hooks/_lib/policy-chain.toml
Normal file
|
|
@ -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",
|
||||||
|
]
|
||||||
52
scripts/kei-mcp-wire-agy.sh
Executable file
52
scripts/kei-mcp-wire-agy.sh
Executable file
|
|
@ -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 <<JSON
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"kei-mcp": {
|
||||||
|
"command": "$KEI_MCP_BIN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "${KEI_WIRE_DRY_RUN:-0}" = "1" ] || [ "${KEI_WIRE_CHECK:-0}" = "1" ]; then
|
||||||
|
echo " agy: would merge into $CFG:"
|
||||||
|
printf '%s\n' "$desired"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
tmp=$(mktemp)
|
||||||
|
jq -s '.[0] * .[1]' "$CFG" <(printf '%s\n' "$desired") > "$tmp"
|
||||||
|
mv "$tmp" "$CFG"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
agy: kei-mcp registered → $CFG
|
||||||
|
⚠ TIER 3 advisory: Antigravity has no way to disable native shell.
|
||||||
|
Native bash remains reachable and ungated. The agent reads the
|
||||||
|
system prompt (which mentions kei_bash) but may still use native.
|
||||||
|
For patent-sensitive / production-PR work, use Claude or Grok.
|
||||||
|
EOF
|
||||||
42
scripts/kei-mcp-wire-claude.sh
Executable file
42
scripts/kei-mcp-wire-claude.sh
Executable file
|
|
@ -0,0 +1,42 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# kei-mcp-wire-claude — verify Claude Code MCP wiring (TIER 1: already native).
|
||||||
|
#
|
||||||
|
# Claude Code reads MCP servers from ~/.claude/settings.json `mcpServers`
|
||||||
|
# block. We don't strictly need kei-mcp here (Claude's native PreToolUse
|
||||||
|
# hooks already enforce policy), but adding it gives Claude access to
|
||||||
|
# `spawn_agent` for cross-CLI sub-agent dispatch.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
CFG="$HOME/.claude/settings.json"
|
||||||
|
BIN="$HOME/.claude/_primitives/_rust/target/release/kei-mcp"
|
||||||
|
[ -f "$BIN" ] || BIN="$(command -v kei-mcp 2>/dev/null || true)"
|
||||||
|
|
||||||
|
if [ -z "$BIN" ] || [ ! -x "$BIN" ]; then
|
||||||
|
echo " kei-mcp binary not found — build first: cargo build -p kei-mcp --release"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " claude: native PreToolUse hooks already enforce policy chain (TIER 1)"
|
||||||
|
echo " kei-mcp binary: $BIN"
|
||||||
|
echo " (spawn_agent + kei_bash MCP tools available if added to"
|
||||||
|
echo " $CFG mcpServers — optional for Claude.)"
|
||||||
|
|
||||||
|
# Optional: dump merge snippet
|
||||||
|
if [ "${KEI_WIRE_CHECK:-0}" = "1" ] || [ "${KEI_WIRE_DRY_RUN:-0}" = "1" ]; then
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Suggested merge into $CFG:
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"kei-mcp": {
|
||||||
|
"command": "$BIN",
|
||||||
|
"env": { "CLAUDECODE": "1" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(CLAUDECODE=1 tells kei-mcp to skip its hook chain — your native hooks
|
||||||
|
already fire on PreToolUse. Avoids double-enforcement.)
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
52
scripts/kei-mcp-wire-copilot.sh
Executable file
52
scripts/kei-mcp-wire-copilot.sh
Executable file
|
|
@ -0,0 +1,52 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# kei-mcp-wire-copilot — TIER 2: MCP-wrapped enforcement for GitHub Copilot.
|
||||||
|
#
|
||||||
|
# Copilot CLI has NO hook system, BUT:
|
||||||
|
# 1. Supports --excluded-tools='shell' to disable native shell.
|
||||||
|
# 2. Has MCP server config at ~/.copilot/mcp-config.json.
|
||||||
|
# So: register kei-mcp via MCP, and instruct user to launch Copilot with
|
||||||
|
# --excluded-tools=shell so the agent can't use native bash and must use
|
||||||
|
# our policy-gated kei_bash.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
CFG="$HOME/.copilot/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 " copilot: 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 <<JSON
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"kei-mcp": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "$KEI_MCP_BIN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "${KEI_WIRE_DRY_RUN:-0}" = "1" ] || [ "${KEI_WIRE_CHECK:-0}" = "1" ]; then
|
||||||
|
echo " copilot: would merge into $CFG:"
|
||||||
|
printf '%s\n' "$desired"
|
||||||
|
echo
|
||||||
|
echo " copilot: launch flag to enforce: --excluded-tools='shell'"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
tmp=$(mktemp)
|
||||||
|
jq -s '.[0] * .[1]' "$CFG" <(printf '%s\n' "$desired") > "$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'"
|
||||||
77
scripts/kei-mcp-wire-grok.sh
Executable file
77
scripts/kei-mcp-wire-grok.sh
Executable file
|
|
@ -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 <<JSON
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
{"matcher": "Bash", "hooks": [{"type": "command", "command": "$HOOKS_DIR/no-github-push.sh"}]},
|
||||||
|
{"matcher": "Bash", "hooks": [{"type": "command", "command": "$HOOKS_DIR/safety-guard.sh"}]},
|
||||||
|
{"matcher": "Bash", "hooks": [{"type": "command", "command": "$HOOKS_DIR/destructive-guard.sh"}]},
|
||||||
|
{"matcher": "Edit", "hooks": [{"type": "command", "command": "$HOOKS_DIR/citation-verify.sh"}]},
|
||||||
|
{"matcher": "Edit", "hooks": [{"type": "command", "command": "$HOOKS_DIR/numeric-claims-guard.sh"}]},
|
||||||
|
{"matcher": "Write", "hooks": [{"type": "command", "command": "$HOOKS_DIR/citation-verify.sh"}]},
|
||||||
|
{"matcher": "Write", "hooks": [{"type": "command", "command": "$HOOKS_DIR/numeric-claims-guard.sh"}]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
)
|
||||||
|
|
||||||
|
mcp_block=""
|
||||||
|
if [ -n "$KEI_MCP_BIN" ] && [ -x "$KEI_MCP_BIN" ]; then
|
||||||
|
mcp_block=$(cat <<JSON
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"kei-mcp": {
|
||||||
|
"command": "$KEI_MCP_BIN",
|
||||||
|
"env": { "GROKCODE": "1" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${KEI_WIRE_DRY_RUN:-0}" = "1" ] || [ "${KEI_WIRE_CHECK:-0}" = "1" ]; then
|
||||||
|
echo " grok: would merge into $CFG:"
|
||||||
|
printf '%s\n' "$desired"
|
||||||
|
[ -n "$mcp_block" ] && printf '%s\n' "$mcp_block"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Merge: existing | desired (desired wins on key conflict; arrays are
|
||||||
|
# replaced, not appended — Grok PreToolUse semantics).
|
||||||
|
tmp=$(mktemp)
|
||||||
|
if [ -n "$mcp_block" ]; then
|
||||||
|
jq -s '.[0] * .[1] * .[2]' "$CFG" <(printf '%s\n' "$desired") <(printf '%s\n' "$mcp_block") > "$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."
|
||||||
52
scripts/kei-mcp-wire-kimi.sh
Executable file
52
scripts/kei-mcp-wire-kimi.sh
Executable file
|
|
@ -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 <<JSON
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"kei-mcp": {
|
||||||
|
"command": "$KEI_MCP_BIN",
|
||||||
|
"transport": "stdio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "${KEI_WIRE_DRY_RUN:-0}" = "1" ] || [ "${KEI_WIRE_CHECK:-0}" = "1" ]; then
|
||||||
|
echo " kimi: would merge into $CFG:"
|
||||||
|
printf '%s\n' "$desired"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
tmp=$(mktemp)
|
||||||
|
jq -s '.[0] * .[1]' "$CFG" <(printf '%s\n' "$desired") > "$tmp"
|
||||||
|
mv "$tmp" "$CFG"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
kimi: kei-mcp registered → $CFG
|
||||||
|
Alternative via Kimi CLI: kimi mcp add kei-mcp --transport stdio \\
|
||||||
|
--command "$KEI_MCP_BIN"
|
||||||
|
⚠ TIER 3 advisory: Kimi has only confirmation prompts, no allowlist.
|
||||||
|
Native shell remains reachable. Keep YOLO mode OFF for safety.
|
||||||
|
For patent-sensitive work, use Claude or Grok as orchestrator.
|
||||||
|
EOF
|
||||||
106
scripts/kei-mcp-wire.sh
Executable file
106
scripts/kei-mcp-wire.sh
Executable file
|
|
@ -0,0 +1,106 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# kei-mcp-wire — orchestrator for cross-CLI MCP enforcement setup.
|
||||||
|
#
|
||||||
|
# Phase C cube — wires kei-mcp (with kei_bash/kei_edit/kei_write tools) into
|
||||||
|
# each installed LLM CLI's MCP config, plus per-CLI tool-restriction config
|
||||||
|
# where the CLI supports it.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# kei mcp-wire # detect installed CLIs + wire each
|
||||||
|
# kei mcp-wire <cli> # 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."
|
||||||
Loading…
Reference in a new issue