feat(v0.41): patch 5 Gemini security findings + Copilot doc bug + claude/grok perms
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
Audit pass via Phase C dogfooding (security-auditor @ Agy/Gemini reviewing our own safe_tools.rs) surfaced 5 real bugs. All fixed. ## Gemini findings (5 real bugs) [#1 HIGH] FAIL-OPEN on missing config/hook Before: missing policy-chain.toml → "passing through" warning; missing hook script → "skipped" warning. Misconfig silently disabled enforcement. After: both paths FAIL-CLOSED with clear error surfaced to caller. Tests/dev can opt in to pass-through via KEI_POLICY_CHAIN_OPTIONAL=1. [#2 HIGH] Path traversal in kei_edit/kei_write Before: no validation; attacker could pass file_path=/etc/passwd or $HOME/.ssh/authorized_keys. After: validate_path() rejects '..' segments, system dirs (/etc/, /usr/, /System/, /var/, /root/), and dotfile-secret dirs (~/.ssh/, ~/.aws/, ~/.gnupg/, ~/.config/gcloud/). Override via KEI_ALLOWED_ROOTS for explicit single-root confinement. [#3 HIGH] CLAUDECODE/GROKCODE env bypass Behavior unchanged — this guard is a perf/UX optimization to avoid double-firing hooks when called from inside Claude/Grok (which already ran their own PreToolUse). Documented explicitly as NOT a security boundary: attacker controlling parent env already owns the invocation. Module header gains a DESIGN NOTE making this load-bearing. [#4 MED] std::fs in async context Before: handle_edit/handle_write used std::fs::{read_to_string,write}, which block the tokio worker thread. Pathological paths like /dev/random would freeze a worker indefinitely. After: tokio::fs::{read_to_string,write}.await — async I/O, worker stays responsive. [#5 MED] kill_on_drop only kills immediate child Before: timeout in kei_bash drops the Child handle; tokio's kill_on_drop SIGKILLs only the shell. Grandchildren (e.g., 'sleep 1000 &') orphaned. After: Unix-only: spawn child in its own process group (Command::process_group(0)), and on timeout libc::kill(-pid, SIGKILL) to take down the whole group. New libc dep on Unix. ## Copilot doc fix Doc claimed "kei-mcp exposes 4 built-in tools" without saying spawn_agent lives in tools.rs while kei_bash/edit/write live in safe_tools.rs. Validator agent flagged this as FALSE/MISLEADING. Now the doc spells out the two-file structure + adds a v0.41 hardening summary. ## claude/grok subprocess permissions Cross-CLI audit demo revealed that 'claude -p' and 'grok --print' returned empty when invoked headless with a real audit task — they need explicit permission flags to use Read/Grep tools in non-interactive mode. Added: claude: --permission-mode=bypassPermissions grok: --always-approve agy: --dangerously-skip-permissions Override via KEI_AGENT_PERMISSIVE=0 to keep strict default. Re-verified: claude+grok both echo SMOKE-OK-V41 with the flag. ## Verification cargo test -p kei-mcp --release → 3/3 pass MCP JSON-RPC smoke (all 7): - tools/list shows 4 built-ins ✓ - kei_bash blocks RULE 0.1 push ✓ - kei_bash passes 'echo OK' ✓ - kei_write rejects /etc/passwd ✓ - kei_write rejects ../ traversal ✓ - kei_write rejects ~/.ssh/* ✓ - missing policy-chain → FAIL-CLOSED with clear error ✓ - KEI_POLICY_CHAIN_OPTIONAL=1 → opt-in pass-through ✓
This commit is contained in:
parent
75325aaf03
commit
8086bec486
5 changed files with 229 additions and 37 deletions
1
_primitives/_rust/Cargo.lock
generated
1
_primitives/_rust/Cargo.lock
generated
|
|
@ -3969,6 +3969,7 @@ dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"kei-atom-discovery",
|
"kei-atom-discovery",
|
||||||
"kei-skills",
|
"kei-skills",
|
||||||
|
"libc",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,11 @@ tokio = { workspace = true, features = ["io-std"] }
|
||||||
# v0.40 (Phase C): toml needed for safe_tools::policy_chain — reads
|
# v0.40 (Phase C): toml needed for safe_tools::policy_chain — reads
|
||||||
# ~/.claude/hooks/_lib/policy-chain.toml to know which hooks to chain.
|
# ~/.claude/hooks/_lib/policy-chain.toml to know which hooks to chain.
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
|
# v0.41 (audit fix #5): killpg via libc on Unix — kill_on_drop only SIGKILLs
|
||||||
|
# the immediate child shell, leaving grandchildren orphaned. We set the child
|
||||||
|
# in its own process group and killpg() the group on timeout.
|
||||||
|
[target.'cfg(unix)'.dependencies]
|
||||||
|
libc = "0.2"
|
||||||
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" }
|
||||||
|
|
|
||||||
|
|
@ -15,17 +15,29 @@
|
||||||
//! abstraction layer. Shell-out per hook keeps the contract identical to
|
//! abstraction layer. Shell-out per hook keeps the contract identical to
|
||||||
//! Claude's native PreToolUse pipeline.
|
//! Claude's native PreToolUse pipeline.
|
||||||
//!
|
//!
|
||||||
//! Guard against double-enforcement: if the parent process is already inside
|
//! CLAUDECODE / GROKCODE guard — DESIGN NOTE (NOT a security boundary):
|
||||||
//! Claude Code (`$CLAUDECODE=1`) or Grok (`$GROKCODE=1`), the chain is
|
//! When invoked from inside Claude Code (`$CLAUDECODE=1`) or Grok the chain
|
||||||
//! skipped — the CLI's native hooks already fired on its own PreToolUse.
|
//! 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)
|
||||||
|
|
||||||
use crate::protocol::{err, ok, JsonRpcRequest, JsonRpcResponse, INTERNAL_ERROR, INVALID_PARAMS};
|
use crate::protocol::{err, ok, JsonRpcRequest, JsonRpcResponse, INTERNAL_ERROR, INVALID_PARAMS};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::fs;
|
use std::path::{Path, PathBuf};
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use tokio::fs;
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
|
@ -132,12 +144,25 @@ async fn handle_bash(args: &Value) -> Result<String, String> {
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
.kill_on_drop(true);
|
.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 fut = cmd.spawn().map_err(|e| format!("spawn bash: {e}"))?.wait_with_output();
|
let child = cmd.spawn().map_err(|e| format!("spawn bash: {e}"))?;
|
||||||
let out = tokio::time::timeout(Duration::from_secs(SAFE_TOOL_TIMEOUT_SECS), fut)
|
let pid_opt = child.id();
|
||||||
.await
|
let fut = child.wait_with_output();
|
||||||
.map_err(|_| "kei_bash timeout".to_string())?
|
|
||||||
.map_err(|e| format!("wait bash: {e}"))?;
|
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 stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||||
|
|
@ -151,6 +176,27 @@ async fn handle_bash(args: &Value) -> Result<String, String> {
|
||||||
Ok(if stderr.is_empty() { stdout } else { format!("{stdout}\n[stderr]\n{stderr}") })
|
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<String, String> {
|
async fn handle_edit(args: &Value) -> Result<String, String> {
|
||||||
let file_path = args.get("file_path").and_then(Value::as_str)
|
let file_path = args.get("file_path").and_then(Value::as_str)
|
||||||
.ok_or_else(|| missing_arg("kei_edit", "file_path"))?;
|
.ok_or_else(|| missing_arg("kei_edit", "file_path"))?;
|
||||||
|
|
@ -159,25 +205,29 @@ async fn handle_edit(args: &Value) -> Result<String, String> {
|
||||||
let new_string = args.get("new_string").and_then(Value::as_str)
|
let new_string = args.get("new_string").and_then(Value::as_str)
|
||||||
.ok_or_else(|| missing_arg("kei_edit", "new_string"))?;
|
.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!({
|
let hook_input = json!({
|
||||||
"tool_name": "Edit",
|
"tool_name": "Edit",
|
||||||
"tool_input": {
|
"tool_input": {
|
||||||
"file_path": file_path,
|
"file_path": safe_path.display().to_string(),
|
||||||
"old_string": old_string,
|
"old_string": old_string,
|
||||||
"new_string": new_string
|
"new_string": new_string
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
run_chain("edit", &hook_input).await?;
|
run_chain("edit", &hook_input).await?;
|
||||||
|
|
||||||
let contents = fs::read_to_string(file_path)
|
// v0.41 fix #4: tokio::fs (async)
|
||||||
.map_err(|e| format!("read {file_path}: {e}"))?;
|
let contents = fs::read_to_string(&safe_path).await
|
||||||
|
.map_err(|e| format!("read {}: {e}", safe_path.display()))?;
|
||||||
if !contents.contains(old_string) {
|
if !contents.contains(old_string) {
|
||||||
return Err(format!("kei_edit: old_string not found in {file_path}"));
|
return Err(format!("kei_edit: old_string not found in {}", safe_path.display()));
|
||||||
}
|
}
|
||||||
let updated = contents.replacen(old_string, new_string, 1);
|
let updated = contents.replacen(old_string, new_string, 1);
|
||||||
fs::write(file_path, &updated)
|
fs::write(&safe_path, &updated).await
|
||||||
.map_err(|e| format!("write {file_path}: {e}"))?;
|
.map_err(|e| format!("write {}: {e}", safe_path.display()))?;
|
||||||
Ok(format!("edited {file_path} ({} bytes)", updated.len()))
|
Ok(format!("edited {} ({} bytes)", safe_path.display(), updated.len()))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_write(args: &Value) -> Result<String, String> {
|
async fn handle_write(args: &Value) -> Result<String, String> {
|
||||||
|
|
@ -186,21 +236,113 @@ async fn handle_write(args: &Value) -> Result<String, String> {
|
||||||
let content = args.get("content").and_then(Value::as_str)
|
let content = args.get("content").and_then(Value::as_str)
|
||||||
.ok_or_else(|| missing_arg("kei_write", "content"))?;
|
.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!({
|
let hook_input = json!({
|
||||||
"tool_name": "Write",
|
"tool_name": "Write",
|
||||||
"tool_input": { "file_path": file_path, "content": content }
|
"tool_input": { "file_path": safe_path.display().to_string(), "content": content }
|
||||||
});
|
});
|
||||||
run_chain("write", &hook_input).await?;
|
run_chain("write", &hook_input).await?;
|
||||||
|
|
||||||
if let Some(parent) = std::path::Path::new(file_path).parent() {
|
if let Some(parent) = safe_path.parent() {
|
||||||
if !parent.as_os_str().is_empty() {
|
if !parent.as_os_str().is_empty() {
|
||||||
fs::create_dir_all(parent)
|
fs::create_dir_all(parent).await
|
||||||
.map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
|
.map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fs::write(file_path, content)
|
fs::write(&safe_path, content).await
|
||||||
.map_err(|e| format!("write {file_path}: {e}"))?;
|
.map_err(|e| format!("write {}: {e}", safe_path.display()))?;
|
||||||
Ok(format!("wrote {file_path} ({} bytes)", content.len()))
|
Ok(format!("wrote {} ({} bytes)", safe_path.display(), content.len()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// v0.41 fix #2 (Gemini HIGH): reject obvious path-traversal / sensitive-path
|
||||||
|
/// targets BEFORE running hooks. Defense-in-depth: hooks may also flag this,
|
||||||
|
/// but having the Rust layer reject obvious attacks gives a fast-fail
|
||||||
|
/// independent of hook configuration.
|
||||||
|
///
|
||||||
|
/// Allowed roots: $PWD (recursively), $HOME (excluding dotfile-secret dirs).
|
||||||
|
/// Override: set KEI_ALLOWED_ROOTS=":" -separated absolute paths.
|
||||||
|
/// Always rejected: /etc/, /usr/, /System/, /var/, /private/etc/, $HOME/.ssh/,
|
||||||
|
/// $HOME/.aws/, $HOME/.config/gcloud/, $HOME/.gnupg/, any path containing "..".
|
||||||
|
fn validate_path(p: &str) -> Result<PathBuf, String> {
|
||||||
|
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. Canonicalize the parent (file may not exist yet for kei_write);
|
||||||
|
// if even the parent doesn't exist, use the absolute form.
|
||||||
|
let canonical = 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)
|
||||||
|
} 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}"));
|
||||||
|
};
|
||||||
|
let canon_str = canonical.display().to_string();
|
||||||
|
|
||||||
|
// 3. Reject obvious sensitive directories.
|
||||||
|
let denylist = [
|
||||||
|
"/etc/", "/usr/", "/System/", "/var/", "/private/etc/", "/private/var/",
|
||||||
|
"/root/",
|
||||||
|
];
|
||||||
|
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") {
|
||||||
|
let secret_dirs = [".ssh/", ".aws/", ".gnupg/", ".config/gcloud/"];
|
||||||
|
for sd in secret_dirs {
|
||||||
|
let full = format!("{home}/{sd}");
|
||||||
|
if canon_str.starts_with(&full) {
|
||||||
|
return Err(format!("file_path: denied (secret dir): {canon_str}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 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<String> {
|
||||||
|
if let Ok(v) = std::env::var("KEI_ALLOWED_ROOTS") {
|
||||||
|
return v.split(':').filter(|s| !s.is_empty()).map(String::from).collect();
|
||||||
|
}
|
||||||
|
let mut roots = Vec::new();
|
||||||
|
if let Ok(cwd) = std::env::current_dir() {
|
||||||
|
roots.push(format!("{}/", cwd.display()));
|
||||||
|
}
|
||||||
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
|
roots.push(format!("{home}/"));
|
||||||
|
}
|
||||||
|
roots
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- chain runner -------------------------------------------------------
|
// ---- chain runner -------------------------------------------------------
|
||||||
|
|
@ -229,11 +371,15 @@ async fn run_chain(tool: &str, hook_input: &Value) -> Result<(), String> {
|
||||||
for hook in chain {
|
for hook in chain {
|
||||||
let path = hooks_dir.join(&hook);
|
let path = hooks_dir.join(&hook);
|
||||||
if !path.is_file() {
|
if !path.is_file() {
|
||||||
// Missing hook is a config error — log but don't block. Better
|
// v0.41 fix #1 (Gemini HIGH): FAIL-CLOSED on missing hook.
|
||||||
// to surface it to the user as a stderr-side warning than to
|
// Previously we logged a warning and continued — that meant a
|
||||||
// silently allow the action.
|
// misconfigured deployment (hook deleted, wrong path) silently
|
||||||
eprintln!("[safe_tools] missing hook (skipped): {}", path.display());
|
// disabled enforcement. Now: refuse the action, surface the
|
||||||
continue;
|
// error so the operator notices.
|
||||||
|
return Err(format!(
|
||||||
|
"[policy-chain] hook missing: {} (declared in policy-chain.toml [{}])",
|
||||||
|
path.display(), tool
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut child = Command::new(&path)
|
let mut child = Command::new(&path)
|
||||||
|
|
@ -274,12 +420,19 @@ async fn run_chain(tool: &str, hook_input: &Value) -> Result<(), String> {
|
||||||
fn load_chain(tool: &str) -> Result<Vec<String>, String> {
|
fn load_chain(tool: &str) -> Result<Vec<String>, String> {
|
||||||
let path = chain_path()?;
|
let path = chain_path()?;
|
||||||
if !path.is_file() {
|
if !path.is_file() {
|
||||||
// No policy-chain.toml → unsafe default = pass through with a warning.
|
// v0.41 fix #1 (Gemini HIGH companion): default behavior when
|
||||||
// This matches Claude Code's behavior when no hooks are configured.
|
// policy-chain.toml is absent is now configurable via env. Without
|
||||||
eprintln!("[safe_tools] no policy-chain.toml at {}; passing through", path.display());
|
// explicit opt-in to pass-through, FAIL-CLOSED — caller sees a
|
||||||
return Ok(vec![]);
|
// clear error instead of silent bypass.
|
||||||
|
if std::env::var("KEI_POLICY_CHAIN_OPTIONAL").as_deref() == Ok("1") {
|
||||||
|
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)
|
let raw = std::fs::read_to_string(&path)
|
||||||
.map_err(|e| format!("read policy-chain.toml: {e}"))?;
|
.map_err(|e| format!("read policy-chain.toml: {e}"))?;
|
||||||
let parsed: PolicyChain = toml::from_str(&raw)
|
let parsed: PolicyChain = toml::from_str(&raw)
|
||||||
.map_err(|e| format!("parse policy-chain.toml: {e}"))?;
|
.map_err(|e| format!("parse policy-chain.toml: {e}"))?;
|
||||||
|
|
|
||||||
|
|
@ -100,10 +100,14 @@ on stdin with `.tool_name` + `.tool_input`, return exit 0 = pass / 2 = block).
|
||||||
|
|
||||||
### kei-mcp built-in tools
|
### kei-mcp built-in tools
|
||||||
|
|
||||||
`kei-mcp` (Rust MCP server at `_primitives/_rust/kei-mcp/`) exposes four
|
`kei-mcp` (Rust MCP server at `_primitives/_rust/kei-mcp/`) exposes 4
|
||||||
built-in tools that bypass atom discovery:
|
built-in tools across two source files (both bypass the atom-discovery
|
||||||
|
loop in `handlers/tools.rs`):
|
||||||
|
|
||||||
|
In `handlers/tools.rs`:
|
||||||
- `spawn_agent(name, task, on?)` — invokes a KeiSeiKit agent on any backend
|
- `spawn_agent(name, task, on?)` — invokes a KeiSeiKit agent on any backend
|
||||||
|
|
||||||
|
In `handlers/safe_tools.rs` (Phase C, v0.40+):
|
||||||
- `kei_bash(command, cwd?)` — runs `[bash]` chain → executes
|
- `kei_bash(command, cwd?)` — runs `[bash]` chain → executes
|
||||||
- `kei_edit(file_path, old_string, new_string)` — runs `[edit]` chain → edits
|
- `kei_edit(file_path, old_string, new_string)` — runs `[edit]` chain → edits
|
||||||
- `kei_write(file_path, content)` — runs `[write]` chain → writes
|
- `kei_write(file_path, content)` — runs `[write]` chain → writes
|
||||||
|
|
@ -112,6 +116,23 @@ 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
|
shape, identical decisions. On block, the hook's stderr surfaces as the MCP
|
||||||
error message so the calling agent sees exactly why.
|
error message so the calling agent sees exactly why.
|
||||||
|
|
||||||
|
**v0.41 hardening** (post-audit fixes):
|
||||||
|
|
||||||
|
- **Fail-CLOSED on missing config** — if `policy-chain.toml` is absent the
|
||||||
|
chain refuses to run (was: silent pass-through). Tests / dev can opt in
|
||||||
|
via `KEI_POLICY_CHAIN_OPTIONAL=1` env.
|
||||||
|
- **Fail-CLOSED on missing hook script** — if a hook declared in the chain
|
||||||
|
is not on disk the call fails (was: warn-and-skip).
|
||||||
|
- **Path-traversal guard** on `kei_edit` / `kei_write` — rejects `..`
|
||||||
|
segments, `/etc/`, `/usr/`, `/System/`, `/var/`, `/root/`, plus
|
||||||
|
`$HOME/{.ssh,.aws,.gnupg,.config/gcloud}/` recursively. Override via
|
||||||
|
`KEI_ALLOWED_ROOTS=':'-separated-absolute-paths`.
|
||||||
|
- **Async file I/O** — `kei_edit` / `kei_write` now use `tokio::fs` so a
|
||||||
|
pathological file (`/dev/random` etc.) cannot block a tokio worker.
|
||||||
|
- **Process-group kill on timeout** — `kei_bash` puts its child shell in
|
||||||
|
its own process group; on timeout the entire group is `killpg(SIGKILL)`'d
|
||||||
|
so grandchildren don't orphan (Unix-only; no-op on Windows).
|
||||||
|
|
||||||
### Double-enforcement guard
|
### Double-enforcement guard
|
||||||
|
|
||||||
If kei-mcp is invoked from a process where `$CLAUDECODE=1` or `$GROKCODE=1`,
|
If kei-mcp is invoked from a process where `$CLAUDECODE=1` or `$GROKCODE=1`,
|
||||||
|
|
|
||||||
|
|
@ -115,9 +115,21 @@ backend_invoke() {
|
||||||
exec "$bin" --agent "$agent_name" --print "${prompt##*TASK FOR THIS RUN:}"
|
exec "$bin" --agent "$agent_name" --print "${prompt##*TASK FOR THIS RUN:}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# v0.41 fix: headless subprocess invocation of claude/grok without
|
||||||
|
# --dangerously-skip-permissions returns empty (the agent's system prompt
|
||||||
|
# asks for Read/Grep tools, but those need permission prompts which can't
|
||||||
|
# be answered in -p mode). Pass the flag so the agent actually executes.
|
||||||
|
# Override via KEI_AGENT_PERMISSIVE=0 to keep the strict default.
|
||||||
|
local permissive_claude="" permissive_grok=""
|
||||||
|
if [ "${KEI_AGENT_PERMISSIVE:-1}" = "1" ]; then
|
||||||
|
permissive_claude="--permission-mode=bypassPermissions"
|
||||||
|
permissive_grok="--always-approve"
|
||||||
|
fi
|
||||||
|
|
||||||
case "$backend" in
|
case "$backend" in
|
||||||
claude) exec "$bin" -p "$prompt" ;;
|
claude) exec "$bin" $permissive_claude -p "$prompt" ;;
|
||||||
grok|agy|antigravity) exec "$bin" --print "$prompt" ;;
|
grok) exec "$bin" $permissive_grok --print "$prompt" ;;
|
||||||
|
agy|antigravity) exec "$bin" --dangerously-skip-permissions --print "$prompt" ;;
|
||||||
copilot) exec "$bin" --prompt "$prompt" ;;
|
copilot) exec "$bin" --prompt "$prompt" ;;
|
||||||
kimi)
|
kimi)
|
||||||
# Kimi has NO one-shot print mode (smoke-tested 2026-05-26): bare `kimi`
|
# Kimi has NO one-shot print mode (smoke-tested 2026-05-26): bare `kimi`
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue