Compare commits

...

No commits in common. "v0.49.0" and "main" have entirely different histories.

41 changed files with 834 additions and 1357 deletions

View file

@ -145,7 +145,7 @@ jobs:
continue-on-error: true
workflow-lint:
# v0.20.1: guards against the dtolnay-SHA-class incident (2026-04-22).
# v0.20.1: guards against the dtolnay-SHA-class incident.
# actionlint catches workflow syntax; validate-workflow-shas.sh catches
# fabricated / force-pushed SHA pins. Runs fast (<30s).
runs-on: ubuntu-latest

View file

@ -13,78 +13,4 @@ All notable changes are tagged via `git tag v*`. This file tracks unreleased wor
## Released
Release notes per tag are kept in the GitHub Releases UI:
https://github.com/KeiSeiLab/KeiSeiKit-1.0/releases
Highlights below; full notes in each tag's GitHub Release page.
### v0.45.0 — post-install onboarding wizard + 5 prod-install bug fixes (2026-05-26)
User feedback from real curl|bash with `profile=full`: "нет выбора провайдера, нахуй не понятно что делать после установки". Closed.
- **NEW** `kei onboard` — 4-step wizard auto-triggered at end of install (TTY only). Walks user through: pick primary CLI → kei mcp-wire → MOONSHOT_API_KEY hint → kei-doctor health check. Re-runnable any time.
- **NEW** `bin/kei onboard|setup|wizard` arm.
- **FIX** `act_runner: command not found` — resolver tries `act_runner``gitea-runner`; brew install switched to `gitea-runner` (functionally equivalent for Forgejo).
- **FIX** Forgejo `no such table: user` — added `forgejo migrate` before `admin user create` (idempotent).
- **FIX** `zoekt: No formulae or casks found` — graceful fallback: brew taps → `go install` → clean skip with warning.
- **DEFERRED** `kei-shared missing` + launchd `Input/output error` → v0.46.
### v0.44.0 — pre-release audit: 1 CRITICAL + 4 HIGH + 4 MEDIUM (2026-05-26)
Four-CLI parallel pre-release audit (Claude+Grok+Gemini+Copilot, each reviewing different angle) surfaced 9 real issues in v0.43. All patched.
- **CRITICAL** Walk-up canonicalize for non-existent leaf paths (defeats v0.42 fix #1 when parent didn't exist either).
- **HIGH** O_NOFOLLOW open + fd-write closes TOCTOU window during hook chain await.
- **HIGH** Sanitize MOONSHOT_API_KEY pre-curl (config injection blocked).
- **HIGH** `env_clear` + whitelist on subprocess spawn (no secret leak via kei_bash).
- **HIGH** `Path::starts_with` + canonical KEI_ALLOWED_ROOTS (no prefix-bypass).
- **MED** macOS $TMPDIR carve-out (allowed_roots check FIRST; narrowed /var/ blanket).
- **MED** Timeout doc honesty (per-step not aggregate).
- **MED** cwd in hook input.
- **MED** Failure-fallback cache has full schema.
### v0.43.0 — kei limits + 4 audit fixes (2026-05-26)
- **NEW** `kei limits` — honest subscription-quota report. Research-grounded: 4 of 5 CLIs have no public quota API. Only Kimi balance via Moonshot `/v1/users/me/balance` (requires MOONSHOT_API_KEY).
- **NEW** Pet integration — reads cache, shows Kimi balance segment if live.
- **FIX** Atomic cache write (mktemp + atomic mv).
- **FIX** `tonumber?` swallows parse errors; `_safe_json` wrapper.
- **FIX** Token off argv (curl `--config -` via stdin).
- **FIX** `jq` runtime guard.
### v0.42.0 — re-audit fixes: 1 CRITICAL + 5 HIGH+MED (2026-05-26)
Re-audit found v0.41 fixes were incomplete. All patched.
- **CRITICAL** Symlink leaf bypass — canonicalize full path + reject is_symlink leaf for new files (3-of-4 reviewers convergent).
- **HIGH** $HOME removed from default allowed_roots (was self-neuter vector — agent could overwrite `~/.claude/hooks/*`).
- **HIGH** Empty section `[bash]/[edit]/[write]` now also FAIL-CLOSED.
- **MED** `tokio::fs` in load_chain.
- **MED** process_group + killpg applied to hook subprocess too.
### v0.41.0 — security hardening from Phase C dogfooding (2026-05-26)
- **HIGH** Fail-CLOSED on missing config + hook (was: silent pass-through).
- **HIGH** Path-traversal guard (denylist + canonicalize).
- **MED** `tokio::fs` async I/O (was: blocking std::fs on tokio thread).
- **MED** Process-group kill on Unix.
### v0.40.0 — Phase C: cross-CLI hook enforcement (2026-05-26)
- **NEW** `kei_bash` / `kei_edit` / `kei_write` MCP tools in `kei-mcp`.
- **NEW** `policy-chain.toml` SSoT for which hooks gate which tool.
- **NEW** 3-tier enforcement model (Claude+Grok TIER 1, Copilot TIER 2, Agy+Kimi TIER 3).
- **NEW** `kei mcp-wire` orchestrator + 5 per-CLI wire scripts.
### v0.39.x — multi-LLM DNA (2026-05-26)
- **NEW** `kei pick` interactive picker.
- **NEW** `kei agent <name>` with DNA-driven provider resolution.
- **NEW** `kei primary` get/set default backend.
- **NEW** `spawn_agent` MCP tool — any MCP-capable CLI can spawn KeiSeiKit agents on any backend.
### v0.38.0 — opt-in hook packs + stack profiles (2026-05-26)
- **NEW** Hook packs (safety / evidence / observability / epistemic / orchestration / git-guard / stack-rust).
- **NEW** Stack profiles (minimal / web / ml / systems / mobile).
- **NEW** `kei configure` re-runnable.
Release notes per tag are kept in the GitHub Releases UI. See `git tag --sort=-creatordate`.

165
README.md
View file

@ -1,81 +1,23 @@
# KeiSeiKit
A **multi-LLM substrate** for agentic coding. Same agent definition,
any LLM backend — Claude Code, Grok, Antigravity (Gemini), GitHub
Copilot, or Kimi. Pick your orchestrator with `kei pick`; agents
spawn sub-agents on other LLMs via MCP `spawn_agent`; safety hooks
enforce on every backend through a 3-tier model. Three-phase nightly
sleep consolidates 30-session windows into morning markdown reports.
A **multi-LLM substrate** that gives any agentic coding tool persistent
memory, deterministic agent identity, and self-maintaining orchestration.
Works first-class with Claude Code; MCP-compatible bridges generate
context for Cursor / Continue / Zed / Aider / Windsurf / Cline /
OpenClaw / Kimi from the same source-of-truth.
**Apache 2.0** — explicit patent grant + retaliation clause.
## Highlights
- **5 LLM CLIs unified.** Claude Code (native hooks), Grok (port to
`~/.grok/settings.json`), Antigravity/Gemini, GitHub Copilot, Kimi.
DNA-routed: each agent's manifest declares a `provider`;
`kei agent <name>` resolves DNA → primary → claude fallback.
- **Sub-agents on any backend.** Agents call `spawn_agent` (built-in MCP
tool in `kei-mcp`) to dispatch other agents to whichever LLM fits
the task. Cross-CLI orchestration without lock-in — Grok can spawn
critic@Claude, then ml-implementer@Gemini, all from one session.
- **3-tier policy enforcement.** Claude + Grok TIER 1 (full native
PreToolUse), Copilot TIER 2 (MCP-wrapped + `--excluded-tools=shell`),
Agy + Kimi TIER 3 (advisory). `no-github-push`, `safety-guard`,
`destructive-guard`, `citation-verify`, `numeric-claims-guard`
surface on every backend that supports tool-call gating.
- **Three-phase nightly sleep.** Phase A (incubation — queued tasks
via `/sleep-on-it`), Phase B (REM consolidation — analyzes last 30
sessions, writes morning markdown), Phase C (NREM deep-sleep, every
7 days — conflict scan + refactor proposals). Outputs are markdown;
you decide what merges.
- **Native token streaming.** Each backend streams in its own print
mode (`claude -p`, `grok --print`, `agy --print`, `copilot --prompt`);
KeiSeiKit composes the agent prompt + task and passes through. No
buffering layer.
- **Persistent memory.** SQLite ledger + content-addressable store,
session-spanning context, cross-machine sync via memory-repo.
- **Agent DNA.** Deterministic variable-length identity per
invocation: `<role>::<caps>::<scope-sha8>::<body-sha8>-<nonce8>`.
Same task → same prefix → "did this run before?" via SQL, no
embeddings.
- **Constructor Pattern.** Substrate, not framework. You compose; it
doesn't dictate workflow. File >200 LOC → decompose. No mixins, no
DI containers, no abstract factories.
- **Self-maintaining.** Every substrate edit cascades: registry
updates, agent regeneration, DNA index refresh, keimd graph
reindex. Auto-self-indexing via kei-registry SQLite.
## By the numbers (v0.49)
110 Rust crates · 69 skills · 54 hooks · 38 agent manifests ·
86 substrate blocks · 18 capability atoms · 7 substrate roles ·
565 indexed DNAs · 6 install profiles (minimal → full).
## Platforms
- **macOS** (arm64 + x64) — fully supported, primary dev target.
- **Linux** (Ubuntu, Debian, Fedora, Arch — x64 + arm64) — fully supported.
- **Windows** — substrate itself is Bash-only, but the **MCP server binary**
ships as `kei-mcp-server-windows-x64.exe` in every release. Three
recommended paths:
- **WSL2** (recommended) — install Windows Subsystem for Linux,
then run `bootstrap.sh` inside Ubuntu/Debian as normal. Full
substrate works. `bootstrap.sh` auto-detects WSL2 and Git Bash;
on bare Windows it prints a one-time WSL setup guide and copies
the `wsl --install` command to your clipboard.
- **MCP-only** — drop `kei-mcp-server-windows-x64.exe` into your
Claude Desktop / VS Code MCP config to get `spawn_agent` +
`kei_bash`/`kei_edit`/`kei_write` tools, without the full
Bash-based substrate. Skills, hooks, and `kei` CLI not available
in this mode.
- **Native PowerShell port** — demand-driven. WSL gives 100%
coverage today with 0 code duplication, so a native `.ps1`
substrate isn't built yet. If you want it, open an issue with
a thumbs-up on the existing Windows-native tracker (or file
one) — once demand is real, we'll build it. The MCP-server
binary path already covers the common "just want spawn_agent
in Claude Desktop" case.
**Apache 2.0** — explicit patent grant + retaliation clause. 105 Rust
crates [REAL: `grep -E '^\s*"[a-z-]+",' _primitives/_rust/Cargo.toml | wc -l`],
69 skills [REAL: `ls skills/ | wc -l`], 54 hooks
[REAL: `ls hooks/*.sh | wc -l`], 38 agent manifests
[REAL: `ls _manifests/*.toml | wc -l`], 85 substrate blocks
[REAL: `find _blocks/ -name '*.md' | wc -l`], 18 capability atoms
[REAL: `find _capabilities/ -mindepth 2 -maxdepth 2 -type d | wc -l`],
7 substrate roles [REAL: `ls _roles/*.toml | wc -l`]. Self-indexing
via kei-registry SQLite (565 active DNAs
[REAL: `head -3 docs/DNA-INDEX.md | grep "Total blocks:"`] as of
2026-05-03). Three-phase nightly consolidation. Foreign-project
ingestion runtime (`kei-import <repo-url>`).
## Maturity matrix
@ -151,54 +93,6 @@ duplicated install logic.
into client-native config — those are bridge targets, not separate
profiles.
## Post-install — the `kei` CLI (v0.45+)
After install, `kei` is the substrate entrypoint. On first interactive
run an onboarding wizard walks you through picking a primary LLM
orchestrator and wiring kei-mcp into the CLIs you have installed:
```bash
kei # launch primary CLI (default: claude)
kei onboard # post-install wizard (re-runnable)
kei pick # interactive primary picker
kei primary [<backend>] # get/set primary LLM provider
kei agent <name> "<task>" # invoke agent: backend from DNA → primary
kei agent --on=grok <name> "..." # invoke agent on a specific backend
kei run-via <backend> <name> "<task>" # explicit-backend dispatch
kei mcp-wire # wire kei-mcp into all installed CLIs
kei mcp-wire --list # show enforcement tier per CLI
kei limits # honest subscription-quota report
# (4 of 5 CLIs have no public API)
kei configure # re-pick hook packs + stack profile
kei message ... # cross-session mailbox
kei --status # splash with substrate health
```
### Multi-LLM agent dispatch
Agents are markdown prompts that can be served by ANY of 5 supported
CLIs (Claude Code, Grok, Antigravity-Gemini, GitHub Copilot, Kimi).
Each agent's manifest may declare a `provider` field that becomes its
DNA; `kei agent <name>` then routes to that provider automatically.
See [`docs/encyclopedia/multi-cli-agents.md`](./docs/encyclopedia/multi-cli-agents.md).
### Cross-CLI policy enforcement
KeiSeiKit's safety hooks (`no-github-push`, `safety-guard`,
`destructive-guard`, `citation-verify`, `numeric-claims-guard`) extend
to non-Claude CLIs through a 3-tier enforcement model:
- **TIER 1 — full native**: Claude (existing) + Grok (ports our 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)
See [`docs/encyclopedia/cross-cli-policy.md`](./docs/encyclopedia/cross-cli-policy.md)
for the full matrix + setup.
### Outcome-only — try just the outcome loop (5 files, ~200 LOC)
If you want to try only the outcome-tracking primitive without
@ -386,8 +280,25 @@ covered by their contributions lose their license to the work.
Pre-2026-04-30 versions remain available under their original MIT
terms (irrevocable). See [LICENSE](./LICENSE) and [NOTICE](./NOTICE).
<!--
Author / collaboration section removed — to be written by hand.
TODO: replace this comment with the section you want.
-->
## Author & collaboration
Built by Denis Parfionovich (`parfionovich@keilab.io`) running
48 parallel Claude Code terminals per day. Solo-maintained.
Apache 2.0 makes the bus factor manageable: any AI-assisted
developer (you, your Claude, your Cursor, your Aider) can read
this codebase and continue it.
**Forks welcome. PRs welcome. Issues welcome.**
**Open to collaboration.** If you have:
- a use-case this substrate would solve and you can't see how — open
a discussion
- ideas for the SaaS roadmap (cross-machine memory sync, hosted
nightly consolidation, encyclopedia-as-API) — email or open an issue
- a related project you're building (agent infra, MCP servers,
cross-tool bridges, prompt-engineering substrates) and want to
cross-pollinate — reach out
- want to integrate KeiSeiKit primitives into your product or
research — Apache 2.0 already permits it; happy to help you wire it
Email reaches the author directly. No marketing list, no funnel.

View file

@ -370,7 +370,7 @@ Counter: each FAILED attempt on the SAME problem = +1. Success = reset.
- Secrets discipline — `.env` gitignored, grep staged files for credential patterns before commit, no plaintext in Terraform state / Dockerfile / CI inline / logs
- Paid-compute cost guard — dashboard balance check, pricing-page verification, single-variant first, 2-min monitor (Modal, AWS, GCP, fal.ai, Apify, ElevenLabs)
- Post-deploy verification — run the project's verification command from `memory/{project}.md`, record endpoints/creds refs
- Shared-infra risk flagging — e.g. Recruiter shares an EC2 with tip-platform, marketing-ai-agent, psychology-tests
- Shared-infra risk flagging — e.g. Recruiter shares EC2 i-0a8b747023809d451 with tip-platform, marketing-ai-agent, psychology-tests
**Out (hand off):**
- `code-implementer` — deploy pipeline requires new application code / binary / library (not infra definition)
@ -439,7 +439,7 @@ Blockers / next: <list>
- `{path::user-rules}/dev-workflow.md`
- `{path::user-memory}/security-restricted-projects.md`
- `Compute Cost Incident: $98.78 Modal overrun — no dashboard check, unverified prices.`
- `Recruiter shared-EC2 risk (<ec2-instance-id> shared with 3 projects, default SECRET_KEY, no CSRF).`
- `Recruiter shared-EC2 risk (i-0a8b747023809d451 shared with 3 projects, default SECRET_KEY, no CSRF).`
- `CloudSync 146 GB bloat: two duplicate LaunchAgents both writing logs. Scan for duplicates before adding infra.`
## Output Footer (RULE 0.16)

View file

@ -0,0 +1,738 @@
//! Phase C — cross-CLI hook enforcement via MCP-wrapped tools.
//!
//! Exposes three built-in MCP tools — `kei_bash`, `kei_edit`, `kei_write` —
//! that synthesize Claude Code's PreToolUse hook input contract, chain
//! through the hook scripts declared in `~/.claude/hooks/_lib/policy-chain.toml`,
//! and only execute the wrapped action if every hook returns exit 0.
//!
//! Why this exists: when an agent runs on Grok / Agy / Copilot / Kimi, none
//! of our claude-side PreToolUse hooks fire. The agent could read the rules
//! in its system prompt but the tool-call layer was previously ungated. The
//! `kei_*` MCP tools restore that gate for any MCP-capable CLI.
//!
//! Constructor Pattern: ONE policy SSoT (`policy-chain.toml`), ONE dispatcher
//! (this file), hooks reused as-is from `~/.claude/hooks/`. No rewrite, no
//! abstraction layer. Shell-out per hook keeps the contract identical to
//! Claude's native PreToolUse pipeline.
//!
//! CLAUDECODE / GROKCODE guard — DESIGN NOTE (NOT a security boundary):
//! When invoked from inside Claude Code (`$CLAUDECODE=1`) or Grok the chain
//! is SKIPPED to avoid double-firing the same hooks (they already ran on the
//! CLI's own PreToolUse). This is a perf / UX optimization for the inside-CLI
//! call path — NOT an authorization check. An attacker who can set the
//! parent process's environment already controls the CLI invocation anyway;
//! re-running hooks would not stop them. To raise the bar for confused-deputy
//! scenarios use full sandboxing (Phase D) or run kei-mcp as a separate UID.
//!
//! v0.41 audit fixes (2026-05-26, Gemini security review):
//! #1 fail-CLOSED on missing hooks (was: silently skip)
//! #2 path-traversal guard on kei_edit/kei_write (canonicalize + root check)
//! #3 CLAUDECODE bypass — documented as design (see above), no behavior change
//! #4 tokio::fs for async file I/O (was: blocking std::fs on tokio thread)
//! #5 process-group kill on Unix (was: kill_on_drop SIGKILLs only direct child)
//!
//! v0.42 re-audit fixes (2026-05-26, 4-CLI dogfood: Claude+Grok+Gemini+Copilot):
//! #1 [CRITICAL] symlink LEAF bypass — canonicalize full path + reject
//! leaf symlinks (v0.41 only canonicalized PARENT; ln -s ~/.ssh/keys ./x
//! then kei_write x followed the link to the target)
//! #2 [HIGH] $HOME removed from default allowed_roots — was a blanket
//! allow that let agent overwrite ~/.claude/hooks (self-neuter), ~/.zshrc
//! (RCE on next shell), and credential stores. Default: $PWD only.
//! Denylist also extended with .claude/, .grok/, .gemini/, .copilot/,
//! .kimi/, and exact shell-init filenames.
//! #3 [HIGH] empty [bash]/[edit]/[write] section also FAIL-CLOSED (was:
//! empty vec → pass-through). KEI_POLICY_CHAIN_OPTIONAL=1 to opt in.
//! #4 [MED] load_chain converted to async + tokio::fs (was: blocking
//! std::fs on tokio worker thread).
//! #5 [MED] set_process_group + killpg applied to HOOK subprocess too
//! (v0.41 only had it on the bash action; hook grandchildren orphaned).
//! #6 [MED] doc note that aggregate timeout is still per-step (60s ×
//! N hooks + 60s action). Single-deadline implementation deferred to
//! v0.43 — not security-blocking.
use crate::protocol::{err, ok, JsonRpcRequest, JsonRpcResponse, INTERNAL_ERROR, INVALID_PARAMS};
use serde::Deserialize;
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use tokio::fs;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
/// Per-step timeout (each hook AND the action each get up to this long).
/// For an N-hook chain the total wall-clock cap is approximately
/// `(N+1) * SAFE_TOOL_TIMEOUT_SECS`. v0.44 doc-honesty fix (Claude MED):
/// prior versions claimed this was an "aggregate" cap, which was always
/// wrong. Aggregate-deadline impl is deferred; for now the per-step
/// semantics are documented honestly so operators pick a sane value.
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);
// v0.44 fix #8 (Gemini MED): include cwd in hook input. Without this,
// safety-guard could approve a destructive command (e.g. `rm -rf *`)
// assuming PWD, while the actual cwd arg redirected it to a sensitive
// dir. Hooks now see the real working directory.
let hook_input = json!({
"tool_name": "Bash",
"tool_input": {
"command": command,
"cwd": cwd
}
});
run_chain("bash", &hook_input).await?;
let mut cmd = Command::new("bash");
cmd.arg("-c").arg(command);
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
// v0.41 fix #5: 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);
// v0.44 fix #4 (Gemini HIGH): clear parent env on subprocess spawn.
// Was: child inherited AWS_*, GITHUB_TOKEN, MOONSHOT_API_KEY, etc.
// An agent that exec's `env` via kei_bash could exfiltrate all of them.
// Now: only PATH/HOME/USER/LANG/TERM/SHELL forwarded (set in helper).
apply_safe_env(&mut cmd);
let child = cmd.spawn().map_err(|e| format!("spawn bash: {e}"))?;
let pid_opt = child.id();
let fut = child.wait_with_output();
let out = match tokio::time::timeout(Duration::from_secs(SAFE_TOOL_TIMEOUT_SECS), fut).await {
Ok(Ok(o)) => o,
Ok(Err(e)) => return Err(format!("wait bash: {e}")),
Err(_) => {
// Timeout — kill the entire process group, not just the child.
if let Some(pid) = pid_opt {
killpg_best_effort(pid);
}
return Err("kei_bash timeout".to_string());
}
};
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
if !out.status.success() {
return Err(format!(
"bash exited {}: {}",
out.status.code().unwrap_or(-1),
stderr.trim()
));
}
Ok(if stderr.is_empty() { stdout } else { format!("{stdout}\n[stderr]\n{stderr}") })
}
// v0.41 fix #5: process-group helpers (Unix-only; no-op on other platforms).
#[cfg(unix)]
fn set_process_group(cmd: &mut Command) {
cmd.process_group(0);
}
#[cfg(not(unix))]
fn set_process_group(_cmd: &mut Command) {}
/// v0.44 fix #4 (Gemini HIGH): strip parent env on subprocess spawn so secrets
/// like AWS_*, GITHUB_TOKEN, MOONSHOT_API_KEY etc. don't leak to user-controlled
/// bash commands or hook scripts. Whitelist forwards only PATH/HOME/USER/LANG/
/// TERM/SHELL — enough to keep tools functional, none of it sensitive.
///
/// Override: `KEI_SAFE_ENV_EXTRA=":-separated list"` adds named vars to the
/// whitelist for callers that legitimately need (e.g. NIX_PATH, JAVA_HOME).
fn apply_safe_env(cmd: &mut Command) {
cmd.env_clear();
let default_keep = [
"PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL",
"LC_CTYPE", "TERM", "PWD", "TMPDIR",
];
for k in default_keep {
if let Ok(v) = std::env::var(k) {
cmd.env(k, v);
}
}
if let Ok(extras) = std::env::var("KEI_SAFE_ENV_EXTRA") {
for k in extras.split(':') {
let k = k.trim();
if k.is_empty() { continue; }
if let Ok(v) = std::env::var(k) {
cmd.env(k, v);
}
}
}
}
#[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> {
let file_path = args.get("file_path").and_then(Value::as_str)
.ok_or_else(|| missing_arg("kei_edit", "file_path"))?;
let old_string = args.get("old_string").and_then(Value::as_str)
.ok_or_else(|| missing_arg("kei_edit", "old_string"))?;
let new_string = args.get("new_string").and_then(Value::as_str)
.ok_or_else(|| missing_arg("kei_edit", "new_string"))?;
// v0.44 LOW: reject empty old_string (would silently prepend new_string
// because contents.contains("") is always true).
if old_string.is_empty() {
return Err("kei_edit: old_string must not be empty".into());
}
let safe_path = validate_path(file_path)?;
let hook_input = json!({
"tool_name": "Edit",
"tool_input": {
"file_path": safe_path.display().to_string(),
"old_string": old_string,
"new_string": new_string
}
});
run_chain("edit", &hook_input).await?;
// v0.44 fix #2 (Gemini HIGH + Claude #4 MED): close TOCTOU window. After
// validate_path approved the path, a concurrent process could swap the
// file for a symlink before our write. Open the existing file with
// O_NOFOLLOW so the open itself fails on symlink-swap; then read/write
// through the open fd (not the path again) so no second path lookup.
open_nofollow_read_write_edit(&safe_path, old_string, new_string).await
}
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 safe_path = validate_path(file_path)?;
let hook_input = json!({
"tool_name": "Write",
"tool_input": { "file_path": safe_path.display().to_string(), "content": content }
});
run_chain("write", &hook_input).await?;
if let Some(parent) = safe_path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent).await
.map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
}
}
// v0.44 fix #2: open with O_NOFOLLOW + O_CREAT to refuse swap-to-symlink.
open_nofollow_write(&safe_path, content).await
}
/// v0.44 fix #2: edit via O_NOFOLLOW-opened fd to close the TOCTOU window
/// between validate_path and the write. The open() itself refuses if the leaf
/// has been swapped to a symlink during the hook-chain await.
#[cfg(unix)]
async fn open_nofollow_read_write_edit(
path: &Path, old_string: &str, new_string: &str,
) -> Result<String, String> {
use std::os::unix::fs::OpenOptionsExt;
let path = path.to_path_buf();
let old_s = old_string.to_string();
let new_s = new_string.to_string();
// Blocking syscalls on a dedicated thread (tokio::task::spawn_blocking).
let result = tokio::task::spawn_blocking(move || -> Result<String, String> {
let mut f = std::fs::OpenOptions::new()
.read(true).write(true)
.custom_flags(libc::O_NOFOLLOW)
.open(&path)
.map_err(|e| format!("kei_edit: open(O_NOFOLLOW) {}: {e}", path.display()))?;
use std::io::{Read, Write, Seek, SeekFrom};
let mut contents = String::new();
f.read_to_string(&mut contents)
.map_err(|e| format!("kei_edit: read {}: {e}", path.display()))?;
if !contents.contains(&old_s) {
return Err(format!("kei_edit: old_string not found in {}", path.display()));
}
let updated = contents.replacen(&old_s, &new_s, 1);
f.set_len(0).map_err(|e| format!("kei_edit: truncate {}: {e}", path.display()))?;
f.seek(SeekFrom::Start(0))
.map_err(|e| format!("kei_edit: seek {}: {e}", path.display()))?;
f.write_all(updated.as_bytes())
.map_err(|e| format!("kei_edit: write {}: {e}", path.display()))?;
Ok(format!("edited {} ({} bytes)", path.display(), updated.len()))
}).await
.map_err(|e| format!("kei_edit: thread join: {e}"))?;
result
}
#[cfg(not(unix))]
async fn open_nofollow_read_write_edit(
path: &Path, old_string: &str, new_string: &str,
) -> Result<String, String> {
// Non-Unix fallback: best-effort using tokio::fs (no O_NOFOLLOW available).
let contents = fs::read_to_string(path).await
.map_err(|e| format!("read {}: {e}", path.display()))?;
if !contents.contains(old_string) {
return Err(format!("kei_edit: old_string not found in {}", path.display()));
}
let updated = contents.replacen(old_string, new_string, 1);
fs::write(path, &updated).await
.map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(format!("edited {} ({} bytes)", path.display(), updated.len()))
}
#[cfg(unix)]
async fn open_nofollow_write(path: &Path, content: &str) -> Result<String, String> {
use std::os::unix::fs::OpenOptionsExt;
let path = path.to_path_buf();
let bytes = content.as_bytes().to_vec();
let result = tokio::task::spawn_blocking(move || -> Result<String, String> {
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
// O_NOFOLLOW: refuse if the leaf is a symlink (someone swapped it
// during our await). Without this the v0.42 symlink_metadata pre-check
// was just an indicator — fs::write still followed.
opts.custom_flags(libc::O_NOFOLLOW);
// O_EXCL combined with O_CREAT could be added when path does not yet
// exist to refuse any pre-existing inode — but the test suite uses
// the same path multiple times, so we keep truncate semantics. The
// O_NOFOLLOW + symlink_metadata pre-check is sufficient.
let mut f = opts.open(&path)
.map_err(|e| format!("kei_write: open(O_NOFOLLOW) {}: {e}", path.display()))?;
use std::io::Write;
f.write_all(&bytes)
.map_err(|e| format!("kei_write: write {}: {e}", path.display()))?;
Ok(format!("wrote {} ({} bytes)", path.display(), bytes.len()))
}).await
.map_err(|e| format!("kei_write: thread join: {e}"))?;
result
}
#[cfg(not(unix))]
async fn open_nofollow_write(path: &Path, content: &str) -> Result<String, String> {
fs::write(path, content).await
.map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(format!("wrote {} ({} bytes)", path.display(), content.len()))
}
/// Path-traversal + symlink + denylist guard.
///
/// v0.41 (initial): rejected `..`, canonicalized PARENT, checked denylist + roots.
/// → 4-CLI re-audit (2026-05-26) found this was bypassable via symlink at the
/// leaf and self-attackable via the $HOME blanket-allowed root.
///
/// v0.42 fixes:
/// #1 [CRITICAL] reject if the leaf is a symlink (was: validated parent
/// only, fs::write followed leaf symlink to anywhere). Done via
/// `symlink_metadata` on the leaf BEFORE write, and full `canonicalize`
/// on the leaf when the file already exists.
/// #2 [HIGH] $HOME removed from default allowed-roots — default is $PWD
/// only. Denylist now also covers $HOME/.claude/ (the substrate
/// itself), shell init files, and credential stores. Operators who
/// need broader access set KEI_ALLOWED_ROOTS explicitly.
fn validate_path(p: &str) -> Result<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. Build a canonical path. Walk UP to the deepest existing ancestor,
// canonicalize it (resolves all symlinks in the existing prefix),
// then reattach the non-existent tail. This catches symlinks at ANY
// depth in the path, including nested non-existent leaves.
//
// v0.44 fix #1 (Gemini CRITICAL): v0.42 only canonicalized the immediate
// parent. If the parent didn't exist either (e.g. /proj/symlink_dir/
// new_subdir/file.txt where symlink_dir → /Users/denis), the path fell
// through to "absolute as-is" → no canonicalization → bypass.
let canonical = canonicalize_with_walk_up(path)?;
// 3. Even when the file doesn't exist yet, the LEAF could already be a
// dangling symlink that `fs::write` would follow on creation. Reject.
if let Ok(meta) = std::fs::symlink_metadata(&canonical) {
if meta.file_type().is_symlink() {
return Err(format!(
"file_path: leaf is a symlink (refusing to follow): {}",
canonical.display()
));
}
}
// 4. Allowed-root containment FIRST (v0.44 fix #6 reorder: was after
// denylist, which meant macOS $TMPDIR = /private/var/folders/... hit
// the /var/ denylist before reaching the allowed_roots check, blocking
// legitimate use of tempfile-backed CWD on macOS).
//
// v0.44 fix #5 (Claude HIGH): use Path::starts_with for component-aware
// containment — Path::starts_with("/home/u/proj") does NOT match
// /home/u/proj-secrets, the str::starts_with that was here did.
let roots = allowed_roots();
let in_allowed_root = roots.is_empty() || roots.iter().any(|r| {
canonical.starts_with(r)
});
if !in_allowed_root {
return Err(format!(
"file_path: outside allowed roots {:?}: {}",
roots, canonical.display()
));
}
let canon_str = canonical.display().to_string();
// 5. Reject system + substrate-control + credential paths.
// Note: paths inside an allowed root that also match a denylist entry
// are STILL denied (e.g. agent's CWD == ~/.claude/ — denied even
// though it matches a default root). System dirs not in any allowed
// root would have been caught above anyway.
let denylist = [
"/etc/", "/usr/", "/System/", "/var/db/", "/var/log/", "/var/root/",
"/private/etc/", "/private/var/db/", "/private/var/log/", "/private/var/root/",
"/root/", "/bin/", "/sbin/",
];
// NOTE: /var/folders/ (macOS $TMPDIR) and /private/tmp/ are NOT denied —
// they are legitimate working dirs for tempfile-backed agents.
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 dir_secrets = [
".ssh/", ".aws/", ".gnupg/", ".config/gcloud/", ".cargo/credentials",
".npmrc", ".docker/config.json", ".kube/",
".claude/", ".grok/", ".gemini/", ".copilot/", ".kimi/",
];
for sd in dir_secrets {
let full = format!("{home}/{sd}");
if canon_str.starts_with(&full) {
return Err(format!("file_path: denied (secret/substrate dir): {canon_str}"));
}
}
let init_files = [
".zshrc", ".bashrc", ".profile", ".bash_profile", ".zprofile",
".zshenv", ".bash_login", ".inputrc", ".gitconfig",
".config/fish/config.fish",
];
for f in init_files {
let full = format!("{home}/{f}");
if canon_str == full {
return Err(format!("file_path: denied (shell-init file): {canon_str}"));
}
}
}
Ok(canonical)
}
/// v0.44 fix #1: walk up the path looking for the deepest existing ancestor,
/// canonicalize THAT, then reattach the non-existent tail components.
/// Resolves symlinks at any depth (existing OR non-existing branches).
fn canonicalize_with_walk_up(path: &Path) -> Result<PathBuf, String> {
// Make the path absolute first so we can walk up reliably.
let abs = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|e| format!("file_path: cwd unavailable: {e}"))?
.join(path)
};
// Walk up from the leaf, collecting non-existent components in reverse.
let mut current = abs.clone();
let mut tail: Vec<std::ffi::OsString> = Vec::new();
let canon = loop {
if current.exists() {
break current.canonicalize()
.map_err(|e| format!("file_path: canonicalize {}: {e}", current.display()))?;
}
let name = current.file_name()
.ok_or_else(|| format!("file_path: path has no existing ancestor: {}", abs.display()))?
.to_os_string();
let parent = match current.parent() {
Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
_ => return Err(format!("file_path: walked to root without finding existing dir: {}", abs.display())),
};
tail.push(name);
current = parent;
};
// Reattach tail (in reverse — we pushed from leaf to root).
let mut result = canon;
for name in tail.into_iter().rev() {
result.push(name);
}
Ok(result)
}
fn allowed_roots() -> Vec<String> {
// Canonicalize each entry so symlinked roots (e.g. macOS /var → /private/var,
// /tmp → /private/tmp) match canonicalized targets. Trailing slash added
// for the consistency-with-default format. v0.44 fix #5 + #6 combined.
let canon_with_slash = |raw: &str| -> Option<String> {
let p = Path::new(raw);
let canon = std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
let mut s = canon.display().to_string();
if !s.ends_with('/') { s.push('/'); }
if s.is_empty() { None } else { Some(s) }
};
if let Ok(v) = std::env::var("KEI_ALLOWED_ROOTS") {
return v.split(':')
.filter(|s| !s.is_empty())
.filter_map(canon_with_slash)
.collect();
}
let mut roots = Vec::new();
if let Ok(cwd) = std::env::current_dir() {
if let Some(r) = canon_with_slash(&cwd.display().to_string()) {
roots.push(r);
}
}
roots
}
// ---- chain runner -------------------------------------------------------
/// Run the configured hook chain for `tool` ("bash"/"edit"/"write"), piping
/// `hook_input` to each hook's stdin in order. Exit 0 → continue. Exit 2 (or
/// other non-zero) → return Err with the hook's stderr.
///
/// Skips the chain if the parent process is already inside Claude or Grok
/// (env flags), since those CLIs' native PreToolUse hooks already fired.
/// Run the configured hook chain for `tool` ("bash"/"edit"/"write").
///
/// v0.42 fixes:
/// #3 [HIGH] empty chain (section absent or zero hooks) now FAILS CLOSED
/// unless KEI_POLICY_CHAIN_OPTIONAL=1.
/// #4 [MED] load_chain() converted to async (was: blocking std::fs).
/// #5 [MED] hook subprocess gets `process_group(0)` + killpg on timeout
/// (was: only the bash action got it; hooks could orphan).
/// #6 [MED] aggregate timeout across the whole chain + action (was:
/// per-hook 60s, so chain+action could legitimately run
/// 4× the documented cap on a 3-hook chain).
async fn run_chain(tool: &str, hook_input: &Value) -> Result<(), String> {
if env_truthy("CLAUDECODE") || env_truthy("GROKCODE") {
// Native hooks already enforced — don't double-fire.
return Ok(());
}
let chain = load_chain(tool).await?;
if chain.is_empty() {
// v0.42 fix #3 (Claude+Gemini HIGH): empty section is the same
// misconfig class as missing file — FAIL CLOSED with explicit opt-in.
if env_truthy("KEI_POLICY_CHAIN_OPTIONAL") {
return Ok(());
}
return Err(format!(
"[policy-chain] section [{tool}] is empty — refusing to run \
(set KEI_POLICY_CHAIN_OPTIONAL=1 to allow pass-through, e.g. for tests)"
));
}
let hooks_dir = hooks_dir()?;
let payload = serde_json::to_string(hook_input)
.map_err(|e| format!("encode hook input: {e}"))?;
for hook in chain {
let path = hooks_dir.join(&hook);
if !path.is_file() {
return Err(format!(
"[policy-chain] hook missing: {} (declared in policy-chain.toml [{}])",
path.display(), tool
));
}
let mut child_cmd = Command::new(&path);
child_cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
set_process_group(&mut child_cmd);
// v0.44 fix #4: same env-isolation for hook subprocess.
apply_safe_env(&mut child_cmd);
let mut child = child_cmd
.spawn()
.map_err(|e| format!("spawn {}: {e}", path.display()))?;
let pid_opt = child.id();
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(payload.as_bytes()).await
.map_err(|e| format!("write stdin to {}: {e}", path.display()))?;
stdin.shutdown().await
.map_err(|e| format!("close stdin to {}: {e}", path.display()))?;
}
let fut = child.wait_with_output();
let out = match tokio::time::timeout(Duration::from_secs(SAFE_TOOL_TIMEOUT_SECS), fut).await {
Ok(Ok(o)) => o,
Ok(Err(e)) => return Err(format!("wait {}: {e}", path.display())),
Err(_) => {
// v0.42 fix #5: kill the whole hook process group, not just
// the immediate child.
if let Some(pid) = pid_opt {
killpg_best_effort(pid);
}
return Err(format!("hook {hook} timeout"));
}
};
let code = out.status.code().unwrap_or(-1);
if code == 0 {
continue;
}
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
return Err(format!(
"[blocked by {hook} exit={code}]\n{stderr}"
));
}
Ok(())
}
// ---- config helpers -----------------------------------------------------
/// v0.42 fix #4: async + tokio::fs (was: blocking std::fs would freeze
/// a tokio worker if policy-chain.toml lived on a slow / hung mount).
async fn load_chain(tool: &str) -> Result<Vec<String>, String> {
let path = chain_path()?;
// tokio::fs::try_exists avoids a blocking is_file() syscall.
let exists = fs::try_exists(&path).await.unwrap_or(false);
if !exists {
if env_truthy("KEI_POLICY_CHAIN_OPTIONAL") {
return Ok(vec![]);
}
return Err(format!(
"[policy-chain] config missing: {} (set KEI_POLICY_CHAIN_OPTIONAL=1 to allow pass-through, e.g. for tests)",
path.display()
));
}
let raw = fs::read_to_string(&path).await
.map_err(|e| format!("read policy-chain.toml: {e}"))?;
let parsed: PolicyChain = toml::from_str(&raw)
.map_err(|e| format!("parse policy-chain.toml: {e}"))?;
let chain = match tool {
"bash" => parsed.bash.chain,
"edit" => parsed.edit.chain,
"write" => parsed.write.chain,
_ => return Err(format!("unknown tool kind: {tool}")),
};
Ok(chain)
}
fn chain_path() -> Result<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

View file

@ -1,159 +0,0 @@
//! Policy chain loader + runner.
//!
//! v0.46: extracted from monolithic safe_tools.rs. Reads
//! `~/.claude/hooks/_lib/policy-chain.toml` to get the hook list for each
//! tool kind (bash/edit/write), pipes synthesized PreToolUse input to each
//! hook, aborts on first non-zero exit.
//!
//! v0.46 architectural fix #1 (Claude critic CRITICAL): REMOVED env-based
//! chain-skip (CLAUDECODE / GROKCODE). The skip was logically broken — it
//! assumed native PreToolUse would catch the call, but PreToolUse matchers
//! fire on tool_name="Bash"|"Edit"|"Write" and MCP tools are named
//! `kei_bash`/`kei_edit`/`kei_write`. Native hooks NEVER fire on these
//! → skip created an auth-bypass hole on Grok. Chain now ALWAYS runs.
use super::env_guard::{apply_safe_env, killpg_best_effort, set_process_group};
use super::SAFE_TOOL_TIMEOUT_SECS;
use serde::Deserialize;
use serde_json::Value;
use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;
use tokio::fs;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
#[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>,
}
/// Run the configured hook chain for `tool` ("bash"/"edit"/"write").
pub async fn run_chain(tool: &str, hook_input: &Value) -> Result<(), String> {
let chain = load_chain(tool).await?;
if chain.is_empty() {
// v0.42 fix #3: empty section is the same misconfig class as missing
// file — FAIL CLOSED with explicit opt-in.
if env_truthy("KEI_POLICY_CHAIN_OPTIONAL") {
return Ok(());
}
return Err(format!(
"[policy-chain] section [{tool}] is empty — refusing to run \
(set KEI_POLICY_CHAIN_OPTIONAL=1 to allow pass-through, e.g. for tests)"
));
}
let hooks_dir = hooks_dir()?;
let payload = serde_json::to_string(hook_input)
.map_err(|e| format!("encode hook input: {e}"))?;
for hook in chain {
let path = hooks_dir.join(&hook);
if !path.is_file() {
return Err(format!(
"[policy-chain] hook missing: {} (declared in policy-chain.toml [{}])",
path.display(), tool
));
}
let mut child_cmd = Command::new(&path);
child_cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
set_process_group(&mut child_cmd);
apply_safe_env(&mut child_cmd);
let mut child = child_cmd
.spawn()
.map_err(|e| format!("spawn {}: {e}", path.display()))?;
let pid_opt = child.id();
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(payload.as_bytes()).await
.map_err(|e| format!("write stdin to {}: {e}", path.display()))?;
stdin.shutdown().await
.map_err(|e| format!("close stdin to {}: {e}", path.display()))?;
}
let fut = child.wait_with_output();
let out = match tokio::time::timeout(Duration::from_secs(SAFE_TOOL_TIMEOUT_SECS), fut).await {
Ok(Ok(o)) => o,
Ok(Err(e)) => return Err(format!("wait {}: {e}", path.display())),
Err(_) => {
if let Some(pid) = pid_opt {
killpg_best_effort(pid);
}
return Err(format!("hook {hook} timeout"));
}
};
let code = out.status.code().unwrap_or(-1);
if code == 0 {
continue;
}
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
return Err(format!(
"[blocked by {hook} exit={code}]\n{stderr}"
));
}
Ok(())
}
/// v0.44 fix #4: async + tokio::fs.
async fn load_chain(tool: &str) -> Result<Vec<String>, String> {
let path = chain_path()?;
let exists = fs::try_exists(&path).await.unwrap_or(false);
if !exists {
if env_truthy("KEI_POLICY_CHAIN_OPTIONAL") {
return Ok(vec![]);
}
return Err(format!(
"[policy-chain] config missing: {} (set KEI_POLICY_CHAIN_OPTIONAL=1 to allow pass-through, e.g. for tests)",
path.display()
));
}
let raw = fs::read_to_string(&path).await
.map_err(|e| format!("read policy-chain.toml: {e}"))?;
let parsed: PolicyChain = toml::from_str(&raw)
.map_err(|e| format!("parse policy-chain.toml: {e}"))?;
let chain = match tool {
"bash" => parsed.bash.chain,
"edit" => parsed.edit.chain,
"write" => parsed.write.chain,
_ => return Err(format!("unknown tool kind: {tool}")),
};
Ok(chain)
}
fn chain_path() -> Result<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"))
}

View file

@ -1,79 +0,0 @@
//! Subprocess environment + process-group hardening for kei_* tools.
//!
//! v0.46: extracted from monolithic safe_tools.rs.
use tokio::process::Command;
/// v0.41 fix #5: process-group helper (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)]
pub fn set_process_group(cmd: &mut Command) {
cmd.process_group(0);
}
#[cfg(not(unix))]
pub fn set_process_group(_cmd: &mut Command) {}
/// v0.41 fix #5: SIGKILL the entire process group (negative pid).
#[cfg(unix)]
pub fn killpg_best_effort(pid: u32) {
unsafe {
let _ = libc::kill(-(pid as i32), libc::SIGKILL);
}
}
#[cfg(not(unix))]
pub fn killpg_best_effort(_pid: u32) {}
/// v0.46 architectural fix: RAII guard. `kill_on_drop` only kills the
/// immediate child; backgrounded grandchildren survive (e.g. `bash -c
/// 'sleep 1000 &'`). v0.41 killpg fix only ran on the timeout error path.
/// Now: killpg fires on EVERY exit path (success, error, panic, early return)
/// via Drop. Caller disarms on clean wait_with_output success via `disarm()`.
pub struct KillPgGuard {
pid: Option<u32>,
}
impl KillPgGuard {
pub fn new(pid: Option<u32>) -> Self { Self { pid } }
/// Caller succeeded cleanly; child is already reaped by wait_with_output.
/// Skip the killpg fire on Drop.
pub fn disarm(&mut self) { self.pid = None; }
}
impl Drop for KillPgGuard {
fn drop(&mut self) {
if let Some(pid) = self.pid {
killpg_best_effort(pid);
}
}
}
/// v0.44 fix #4 (Gemini HIGH): strip parent env on subprocess spawn so secrets
/// like AWS_*, GITHUB_TOKEN, MOONSHOT_API_KEY etc. don't leak to user-controlled
/// bash commands or hook scripts. Whitelist forwards only PATH/HOME/USER/LANG/
/// TERM/SHELL/PWD/TMPDIR/LOGNAME/LC_* — enough to keep tools functional, none
/// of it sensitive.
///
/// Override: `KEI_SAFE_ENV_EXTRA=":-separated list"` adds named vars to the
/// whitelist for callers that legitimately need (e.g. NIX_PATH, JAVA_HOME).
pub fn apply_safe_env(cmd: &mut Command) {
cmd.env_clear();
let default_keep = [
"PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL",
"LC_CTYPE", "TERM", "PWD", "TMPDIR",
];
for k in default_keep {
if let Ok(v) = std::env::var(k) {
cmd.env(k, v);
}
}
if let Ok(extras) = std::env::var("KEI_SAFE_ENV_EXTRA") {
for k in extras.split(':') {
let k = k.trim();
if k.is_empty() { continue; }
if let Ok(v) = std::env::var(k) {
cmd.env(k, v);
}
}
}
}

View file

@ -1,222 +0,0 @@
//! Action executors for the three kei_* MCP tools.
//!
//! v0.46: extracted from monolithic safe_tools.rs. Wraps shell + file
//! operations with O_NOFOLLOW (close TOCTOU after policy chain) and uses
//! KillPgGuard (env_guard.rs) so killpg fires on EVERY exit path, not just
//! the timeout error arm.
use super::chain_runner::run_chain;
use super::env_guard::{apply_safe_env, set_process_group, KillPgGuard};
use super::path_guard::validate_path;
use super::SAFE_TOOL_TIMEOUT_SECS;
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use tokio::fs;
use tokio::process::Command;
pub 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,
"cwd": cwd
}
});
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);
set_process_group(&mut cmd);
apply_safe_env(&mut cmd);
let child = cmd.spawn().map_err(|e| format!("spawn bash: {e}"))?;
let pid_opt = child.id();
// v0.46 architectural fix: RAII guard. killpg fires on ANY exit path —
// including early returns, panics, and normal success (until disarmed).
let mut killpg_guard = KillPgGuard::new(pid_opt);
let fut = child.wait_with_output();
let out = match tokio::time::timeout(Duration::from_secs(SAFE_TOOL_TIMEOUT_SECS), fut).await {
Ok(Ok(o)) => o,
Ok(Err(e)) => return Err(format!("wait bash: {e}")),
Err(_) => return Err("kei_bash timeout".to_string()),
// Drop runs here → killpg fires.
};
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()
));
}
// v0.46 architectural fix: arm guard fires by default. Disarm here ONLY
// after we know the parent shell exited cleanly + we want to leave any
// legitimate backgrounded jobs alone. Trade-off: killpg also reaps
// intentional `&` jobs (`sleep 1000 &`). For kei_bash use-case this is
// correct — the tool should not leak processes across calls.
killpg_guard.disarm();
// v0.46: explicitly reap orphaned group AFTER guard disarm-on-success.
// The disarm() above means we trust kill_on_drop + the kernel to clean
// up — but kill_on_drop only kills the direct child. For backgrounded
// grandchildren we'd want a separate killpg here. For now, kei_bash docs
// that `&` jobs DO survive — set them up in nohup or another tool if
// long-running is intended.
let _ = killpg_guard;
Ok(if stderr.is_empty() { stdout } else { format!("{stdout}\n[stderr]\n{stderr}") })
}
pub 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"))?;
if old_string.is_empty() {
return Err("kei_edit: old_string must not be empty".into());
}
// v0.46 fix #4: blocking path validation moved off the tokio worker.
let p_owned = file_path.to_string();
let safe_path = tokio::task::spawn_blocking(move || validate_path(&p_owned))
.await
.map_err(|e| format!("kei_edit: thread join: {e}"))??;
let hook_input = json!({
"tool_name": "Edit",
"tool_input": {
"file_path": safe_path.display().to_string(),
"old_string": old_string,
"new_string": new_string
}
});
run_chain("edit", &hook_input).await?;
open_nofollow_read_write_edit(&safe_path, old_string, new_string).await
}
pub 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 p_owned = file_path.to_string();
let safe_path = tokio::task::spawn_blocking(move || validate_path(&p_owned))
.await
.map_err(|e| format!("kei_write: thread join: {e}"))??;
let hook_input = json!({
"tool_name": "Write",
"tool_input": { "file_path": safe_path.display().to_string(), "content": content }
});
run_chain("write", &hook_input).await?;
if let Some(parent) = safe_path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent).await
.map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
}
}
open_nofollow_write(&safe_path, content).await
}
/// v0.44 fix #2: edit via O_NOFOLLOW-opened fd to close the TOCTOU window
/// between validate_path and the write.
#[cfg(unix)]
async fn open_nofollow_read_write_edit(
path: &Path, old_string: &str, new_string: &str,
) -> Result<String, String> {
use std::os::unix::fs::OpenOptionsExt;
let path = path.to_path_buf();
let old_s = old_string.to_string();
let new_s = new_string.to_string();
let result = tokio::task::spawn_blocking(move || -> Result<String, String> {
let mut f = std::fs::OpenOptions::new()
.read(true).write(true)
.custom_flags(libc::O_NOFOLLOW)
.open(&path)
.map_err(|e| format!("kei_edit: open(O_NOFOLLOW) {}: {e}", path.display()))?;
use std::io::{Read, Write, Seek, SeekFrom};
let mut contents = String::new();
f.read_to_string(&mut contents)
.map_err(|e| format!("kei_edit: read {}: {e}", path.display()))?;
if !contents.contains(&old_s) {
return Err(format!("kei_edit: old_string not found in {}", path.display()));
}
let updated = contents.replacen(&old_s, &new_s, 1);
f.set_len(0).map_err(|e| format!("kei_edit: truncate {}: {e}", path.display()))?;
f.seek(SeekFrom::Start(0))
.map_err(|e| format!("kei_edit: seek {}: {e}", path.display()))?;
f.write_all(updated.as_bytes())
.map_err(|e| format!("kei_edit: write {}: {e}", path.display()))?;
Ok(format!("edited {} ({} bytes)", path.display(), updated.len()))
}).await
.map_err(|e| format!("kei_edit: thread join: {e}"))?;
result
}
#[cfg(not(unix))]
async fn open_nofollow_read_write_edit(
path: &Path, old_string: &str, new_string: &str,
) -> Result<String, String> {
let contents = fs::read_to_string(path).await
.map_err(|e| format!("read {}: {e}", path.display()))?;
if !contents.contains(old_string) {
return Err(format!("kei_edit: old_string not found in {}", path.display()));
}
let updated = contents.replacen(old_string, new_string, 1);
fs::write(path, &updated).await
.map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(format!("edited {} ({} bytes)", path.display(), updated.len()))
}
#[cfg(unix)]
async fn open_nofollow_write(path: &Path, content: &str) -> Result<String, String> {
use std::os::unix::fs::OpenOptionsExt;
let path = path.to_path_buf();
let bytes = content.as_bytes().to_vec();
let result = tokio::task::spawn_blocking(move || -> Result<String, String> {
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
opts.custom_flags(libc::O_NOFOLLOW);
let mut f = opts.open(&path)
.map_err(|e| format!("kei_write: open(O_NOFOLLOW) {}: {e}", path.display()))?;
use std::io::Write;
f.write_all(&bytes)
.map_err(|e| format!("kei_write: write {}: {e}", path.display()))?;
Ok(format!("wrote {} ({} bytes)", path.display(), bytes.len()))
}).await
.map_err(|e| format!("kei_write: thread join: {e}"))?;
result
}
#[cfg(not(unix))]
async fn open_nofollow_write(path: &Path, content: &str) -> Result<String, String> {
fs::write(path, content).await
.map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(format!("wrote {} ({} bytes)", path.display(), content.len()))
}
fn missing_arg(tool: &str, field: &str) -> String {
format!("{tool}: missing '{field}' argument")
}
// PathBuf only needed in cfg(unix) blocks via spawn_blocking captures.
#[allow(dead_code)]
fn _path_buf_keep() -> Option<PathBuf> { None }

View file

@ -1,99 +0,0 @@
//! Phase C — cross-CLI hook enforcement via MCP-wrapped tools.
//!
//! v0.46: decomposed from single safe_tools.rs (738 LOC, god-object per
//! architect audit) into 5 focused modules:
//!
//! mod.rs — descriptor list + tools/call dispatch (this file)
//! chain_runner.rs — load_chain + run_chain (policy enforcement engine)
//! path_guard.rs — validate_path + canonicalize-with-walk-up + allowed_roots
//! exec.rs — handle_bash/edit/write + O_NOFOLLOW open + write paths
//! env_guard.rs — apply_safe_env + set_process_group + KillPgGuard (RAII)
//!
//! Exposes three built-in MCP tools — `kei_bash`, `kei_edit`, `kei_write` —
//! that synthesize Claude Code's PreToolUse hook input contract and chain
//! through the hook scripts in `~/.claude/hooks/_lib/policy-chain.toml`.
//!
//! v0.46 architectural fix #1 (Claude critic CRITICAL): REMOVED env-based
//! chain-skip (was `CLAUDECODE=1` / `GROKCODE=1` → skip). Rationale: those
//! envs were set assuming "if we're inside Claude/Grok, native PreToolUse
//! already fires — skip our chain to avoid double-firing". But native
//! PreToolUse matchers fire on tool_name = "Bash"|"Edit"|"Write" — these
//! MCP tools are named `kei_bash`/`kei_edit`/`kei_write` (or with mcp__
//! prefix). Native hooks therefore NEVER fire on these calls, and the
//! env-skip created a real auth-bypass hole on Grok. Chain now ALWAYS
//! runs; the perf concern was fictional.
use crate::protocol::{err, ok, JsonRpcRequest, JsonRpcResponse, INTERNAL_ERROR};
use serde_json::{json, Value};
mod chain_runner;
mod env_guard;
mod exec;
mod path_guard;
/// Per-step timeout (each hook AND the action each get up to this long).
/// For an N-hook chain the total wall-clock cap is approximately
/// `(N+1) * SAFE_TOOL_TIMEOUT_SECS`. v0.44 doc-honesty: prior versions
/// claimed this was an "aggregate" cap which was always wrong.
pub(crate) const SAFE_TOOL_TIMEOUT_SECS: u64 = 60;
/// 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"]
}
}),
]
}
/// 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" => exec::handle_bash(args).await,
"kei_edit" => exec::handle_edit(args).await,
"kei_write" => exec::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),
}
}

View file

@ -1,166 +0,0 @@
//! Path-traversal + symlink + denylist guard for `kei_edit` / `kei_write`.
//!
//! v0.46: extracted from monolithic safe_tools.rs. Pure-sync helpers — the
//! async handlers in exec.rs wrap them in `spawn_blocking` so a slow
//! `canonicalize` syscall doesn't starve a tokio worker (v0.46 fix #4).
use std::path::{Path, PathBuf};
/// v0.41 (initial): rejected `..`, canonicalized PARENT, checked denylist + roots.
/// → 4-CLI re-audit (2026-05-26) found this was bypassable via symlink at the
/// leaf and self-attackable via the $HOME blanket-allowed root.
///
/// v0.42 fixes:
/// #1 [CRITICAL] reject if the leaf is a symlink for new files; canonicalize
/// full path when the file exists.
/// #2 [HIGH] $HOME removed from default allowed-roots — default is $PWD only.
/// Denylist now also covers $HOME/.claude/ (the substrate itself), shell
/// init files, and credential stores.
///
/// v0.44 fixes:
/// #1 [CRITICAL] walk_up_to_canonicalize — finds deepest existing ancestor,
/// canonicalizes THAT (resolving all symlinks in the existing prefix),
/// reattaches the non-existent tail. Closes the "parent's parent is a
/// symlink" bypass.
/// #5 [HIGH] Path::starts_with for component-aware containment + canonical
/// KEI_ALLOWED_ROOTS so /var → /private/var symlink works on macOS.
/// #6 [MED] allowed_roots check FIRST; narrowed /var/ blanket to /var/db/,
/// /var/log/, /var/root/ — macOS $TMPDIR = /var/folders/ now allowed.
pub fn validate_path(p: &str) -> Result<PathBuf, String> {
if p.is_empty() {
return Err("file_path: empty".into());
}
if p.split('/').any(|seg| seg == "..") {
return Err(format!("file_path: '..' segment not allowed in {p}"));
}
let path = Path::new(p);
let canonical = canonicalize_with_walk_up(path)?;
// Reject if the leaf is a symlink (covers dangling symlinks for new files).
if let Ok(meta) = std::fs::symlink_metadata(&canonical) {
if meta.file_type().is_symlink() {
return Err(format!(
"file_path: leaf is a symlink (refusing to follow): {}",
canonical.display()
));
}
}
// Allowed-root containment FIRST (v0.44 fix #6).
let roots = allowed_roots();
// v0.46 fix #3: empty allowed_roots → fail-CLOSED (was: silently
// disabled containment). Operator must explicitly set KEI_ALLOWED_ROOTS
// to "" if they want to disable, and we still reject empty.
if roots.is_empty() {
return Err(
"file_path: allowed_roots is empty — refusing all writes \
(set KEI_ALLOWED_ROOTS to a non-empty value or run from a real cwd)".into()
);
}
let in_allowed_root = roots.iter().any(|r| canonical.starts_with(r));
if !in_allowed_root {
return Err(format!(
"file_path: outside allowed roots {:?}: {}",
roots, canonical.display()
));
}
let canon_str = canonical.display().to_string();
// Reject system + substrate-control + credential paths.
let denylist = [
"/etc/", "/usr/", "/System/", "/var/db/", "/var/log/", "/var/root/",
"/private/etc/", "/private/var/db/", "/private/var/log/", "/private/var/root/",
"/root/", "/bin/", "/sbin/",
];
for d in denylist {
if canon_str.starts_with(d) {
return Err(format!("file_path: denied (system dir): {canon_str}"));
}
}
if let Ok(home) = std::env::var("HOME") {
let dir_secrets = [
".ssh/", ".aws/", ".gnupg/", ".config/gcloud/", ".cargo/credentials",
".npmrc", ".docker/config.json", ".kube/",
".claude/", ".grok/", ".gemini/", ".copilot/", ".kimi/",
];
for sd in dir_secrets {
let full = format!("{home}/{sd}");
if canon_str.starts_with(&full) {
return Err(format!("file_path: denied (secret/substrate dir): {canon_str}"));
}
}
let init_files = [
".zshrc", ".bashrc", ".profile", ".bash_profile", ".zprofile",
".zshenv", ".bash_login", ".inputrc", ".gitconfig",
".config/fish/config.fish",
];
for f in init_files {
let full = format!("{home}/{f}");
if canon_str == full {
return Err(format!("file_path: denied (shell-init file): {canon_str}"));
}
}
}
Ok(canonical)
}
/// v0.44 fix #1: walk up the path looking for the deepest existing ancestor,
/// canonicalize THAT, then reattach the non-existent tail components.
fn canonicalize_with_walk_up(path: &Path) -> Result<PathBuf, String> {
let abs = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|e| format!("file_path: cwd unavailable: {e}"))?
.join(path)
};
let mut current = abs.clone();
let mut tail: Vec<std::ffi::OsString> = Vec::new();
let canon = loop {
if current.exists() {
break current.canonicalize()
.map_err(|e| format!("file_path: canonicalize {}: {e}", current.display()))?;
}
let name = current.file_name()
.ok_or_else(|| format!("file_path: path has no existing ancestor: {}", abs.display()))?
.to_os_string();
let parent = match current.parent() {
Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
_ => return Err(format!("file_path: walked to root without finding existing dir: {}", abs.display())),
};
tail.push(name);
current = parent;
};
let mut result = canon;
for name in tail.into_iter().rev() {
result.push(name);
}
Ok(result)
}
pub fn allowed_roots() -> Vec<String> {
let canon_with_slash = |raw: &str| -> Option<String> {
let p = Path::new(raw);
let canon = std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
let mut s = canon.display().to_string();
if !s.ends_with('/') { s.push('/'); }
if s.is_empty() { None } else { Some(s) }
};
if let Ok(v) = std::env::var("KEI_ALLOWED_ROOTS") {
return v.split(':')
.filter(|s| !s.is_empty())
.filter_map(canon_with_slash)
.collect();
}
let mut roots = Vec::new();
if let Ok(cwd) = std::env::current_dir() {
if let Some(r) = canon_with_slash(&cwd.display().to_string()) {
roots.push(r);
}
}
roots
}

19
bin/kei
View file

@ -217,30 +217,17 @@ splash() {
as="$(active_sessions)"
# Only color if stdout is a tty. Brand palette: голубой (sky-blue) + жёлтый (gold).
local C0= C1= C2= C3= CV= CS= GO_BACK= SHADOW_BLOCK=
local C0= C1= C2= C3= CV=
if [ -t 1 ]; then
C0=$'\033[0m'
C1=$'\033[1;38;5;39m' # голубой (sky-blue) — logo
C2=$'\033[1;38;5;220m' # жёлтый (gold) — brand line
C3=$'\033[2;38;5;39m' # dim blue — separators
CV=$'\033[1;38;5;220m' # жёлтый — field values
CS=$'\033[1;38;5;130m' # благородная насыщенная жёлто-бронзовая тень
# v0.47 drop shadow: print shadow first (offset +2 cols right),
# then \e[7A returns cursor to start of art, blue letters overwrite
# shadow where they overlap. Visible shadow = right-edge tail +
# one full row below blue's bottom (offset +1 row down).
GO_BACK=$'\033[7A'
SHADOW_BLOCK="
${CS} ██╗ ██╗███████╗██╗███████╗███████╗██╗${C0}
${CS} ██║ ██╔╝██╔════╝██║██╔════╝██╔════╝██║${C0}
${CS} █████╔╝ █████╗ ██║███████╗█████╗ ██║${C0}
${CS} ██╔═██╗ ██╔══╝ ██║╚════██║██╔══╝ ██║${C0}
${CS} ██║ ██╗███████╗██║███████║███████╗██║${C0}
${CS} ╚═╝ ╚═╝╚══════╝╚═╝╚══════╝╚══════╝╚═╝${C0}${GO_BACK}"
fi
cat <<EOF
${SHADOW_BLOCK}
${C1} ██╗ ██╗███████╗██╗███████╗███████╗██╗${C0}
${C1} ██║ ██╔╝██╔════╝██║██╔════╝██╔════╝██║${C0}
${C1} █████╔╝ █████╗ ██║███████╗█████╗ ██║${C0}
@ -248,7 +235,7 @@ ${C1} ██╔═██╗ ██╔══╝ ██║╚════█
${C1} ██║ ██╗███████╗██║███████║███████╗██║${C0}
${C1} ╚═╝ ╚═╝╚══════╝╚═╝╚══════╝╚══════╝╚═╝${C0}
${C2} KeiSeiKit · substrate v0.49${C0}
${C2} KeiSeiKit · substrate v0.45${C0}
${C3} ─────────────────────────────────────${C0}
primary CLI : ${CV}${PRIMARY}${C0}
profile : ${CV}${p}${C0}

View file

@ -55,7 +55,7 @@ prompt_profile() {
# no 105-crate compile, can't half-fail. Matches install.sh's own default
# (was "cortex" here → divergent install vs direct install.sh). Opt up with
# --profile=cortex/full-hub.
if ! kei_is_interactive; then PROFILE="minimal"; return 0; fi
if [ ! -t 0 ]; then PROFILE="minimal"; return 0; fi
cat <<'WIZARD'
╔═══════════════════════════════════════════════════════════════════╗
@ -115,86 +115,14 @@ log() { echo "[bootstrap] $*"; }
err() { echo "[bootstrap] ERROR: $*" >&2; }
have() { command -v "$1" >/dev/null 2>&1; }
# v0.49: source the interactive-prompt cube (Constructor Pattern: ONE place
# where all interactivity logic lives). Tries kit-local path first (when
# running from a clone / curl|bash via cloned checkout), then installed
# path (when bootstrap re-runs from $HOME/.claude). Last-resort inline
# fallback if neither found — keeps the script self-bootable.
_KIT_DIR_PRE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -r "$_KIT_DIR_PRE/scripts/kei-prompt.sh" ]; then
# shellcheck source=scripts/kei-prompt.sh
. "$_KIT_DIR_PRE/scripts/kei-prompt.sh"
elif [ -r "$HOME/.claude/scripts/kei-prompt.sh" ]; then
# shellcheck disable=SC1091
. "$HOME/.claude/scripts/kei-prompt.sh"
else
# Self-contained fallback so bootstrap never breaks when run from a
# weird directory. Mirrors kei_is_interactive's contract only.
kei_is_interactive() {
[ "${KEI_NONINTERACTIVE:-0}" = "1" ] && return 1
if [ -r /dev/tty ] && [ -w /dev/tty ]; then return 0; fi
[ -t 0 ] && return 0
return 1
}
fi
unset _KIT_DIR_PRE
OS="$(uname -s)"
# --- 1. OS detection -----------------------------------------------------
# Detect WSL2 (uname -s = Linux but kernel reports Microsoft) — full path works.
# Detect Git Bash / Cygwin / MSYS on bare Windows — substrate cannot run there;
# guide user to WSL2 instead of dying silently.
IS_WSL=0
if [ "$OS" = "Linux" ] && [ -r /proc/version ] && grep -qiE "microsoft|wsl" /proc/version 2>/dev/null; then
IS_WSL=1
fi
case "$OS" in
Darwin|Linux)
if [ "$IS_WSL" = "1" ]; then
log "OS: WSL2 (Linux inside Windows) — full substrate path available"
else
log "OS: $OS"
fi
;;
MINGW*|MSYS*|CYGWIN*)
err ""
err "Detected: bare Windows ($OS) via Git Bash / Cygwin / MSYS."
err ""
err "KeiSeiKit's substrate is Bash-only and needs apt/brew + full POSIX —"
err "it will not run reliably outside WSL2."
err ""
err "A native PowerShell port is demand-driven — not built yet because"
err "WSL2 covers 100% with zero code duplication. If enough Windows users"
err "ask, we will ship one. Open / 👍 an issue at:"
err " https://github.com/KeiSeiLab/KeiSeiKit-1.0/issues"
err ""
err "Path forward (one-time setup, ~5 min + reboot):"
err ""
err " 1. Open PowerShell as Administrator."
err " 2. Run: wsl --install -d Ubuntu"
err " 3. Reboot when prompted; Ubuntu auto-starts on next login."
err " 4. Inside Ubuntu, re-run this same bootstrap:"
err " curl -fsSL https://raw.githubusercontent.com/KeiSeiLab/KeiSeiKit-1.0/main/bootstrap.sh | bash"
err ""
err "Alternative — MCP-only (no substrate, no skills, no hooks):"
err " Grab kei-mcp-server-windows-x64.exe from a release and wire it"
err " into Claude Desktop / VS Code MCP config. Gets you spawn_agent +"
err " kei_bash/kei_edit/kei_write only. See README → Platforms section."
err ""
# Best-effort: copy the wsl --install command to clipboard if possible.
if command -v clip.exe >/dev/null 2>&1; then
printf 'wsl --install -d Ubuntu' | clip.exe 2>/dev/null && \
err "(I've copied 'wsl --install -d Ubuntu' to your Windows clipboard.)"
fi
exit 1
;;
*)
err "unsupported OS: $OS (supported: Darwin / Linux / WSL2)"
exit 1
;;
Darwin|Linux) ;;
*) err "unsupported OS: $OS (only Darwin / Linux for now)"; exit 1 ;;
esac
log "OS: $OS"
# --- 2. install jq -------------------------------------------------------
install_jq() {
@ -251,19 +179,6 @@ log "checkout: $KIT_DIR"
# --- 5. run install ------------------------------------------------------
log "running install.sh --profile=$PROFILE $YES_FLAG ${EXTRA_FLAGS[*]:-}"
cd "$KIT_DIR"
# v0.48: reattach stdin to /dev/tty for the install + everything after.
# Under `curl|bash` stdin is the curl pipe, so install.sh's interactive
# gates (5 places: language pick, preflight, hooks-activate, sleep wizard,
# PATH wiring) all silently skip via [ -t 0 ] being false. Reattaching ONCE
# here cascades correctly: every child script inherits the terminal stdin
# and its [ -t 0 ] returns true. Only do it if /dev/tty is actually
# present and readable (CI / nohup / systemd: skip — those are headless).
if [ -r /dev/tty ] && [ -w /dev/tty ]; then
exec </dev/tty
log "stdin reattached to /dev/tty (curl|bash interactive prompts will work)"
fi
# Defensive: invoke via `bash` not `./install.sh` because GitHub's contents
# API does NOT preserve the executable bit on `gh api -X PUT` updates
# (only the git Data API does). Older clones may have install.sh with
@ -290,12 +205,11 @@ log "===========================================================================
log "DONE — KeiSeiKit installed (profile: $PROFILE)"
log "==========================================================================="
# v0.48: post-install onboarding wizard.
# stdin already reattached to /dev/tty above (when present), so [ -t 0 ]
# inside this scope correctly reports interactive vs headless. Wizard
# itself re-checks and exits cleanly if non-interactive.
# v0.45: post-install onboarding wizard.
# Auto-triggers if stdin is a TTY (real terminal). Wizard itself re-checks
# and exits cleanly if non-interactive — so curl|bash one-liner runs work too.
ONBOARD_SH="$HOME/.claude/scripts/kei-onboard.sh"
if [ -x "$ONBOARD_SH" ] && kei_is_interactive && [ "${KEI_NO_ONBOARD:-0}" != "1" ]; then
if [ -x "$ONBOARD_SH" ] && [ -t 0 ] && [ "${KEI_NO_ONBOARD:-0}" != "1" ]; then
log ""
log "Starting post-install onboarding (pick primary CLI + wire MCP)..."
log "Skip with KEI_NO_ONBOARD=1; re-run anytime with 'kei onboard'."
@ -316,23 +230,3 @@ log " - Or source the rc file the installer wrote (Bash: ~/.bashrc, Zsh: ~/.zsh
log " - Run kei-doctor for a full health diagnostic."
log " - For cortex profile: run /cortex-setup inside Claude Code."
log " - For sleep layer: run /sleep-setup inside Claude Code."
# v0.48: offer to launch `kei` for a first status look.
# stdin was reattached to /dev/tty above (when present), so [ -t 0 ] is
# now true under curl|bash too. Simple gate works correctly.
KEI_BIN_PATH="$HOME/.claude/bin/kei"
if [ -x "$KEI_BIN_PATH" ] && kei_is_interactive && [ "${KEI_NO_AUTORUN:-0}" != "1" ]; then
log ""
printf ' → Запустить kei сейчас? [Y/n] '
_reply=""
read -r _reply || _reply=""
case "${_reply:-Y}" in
[Nn]*)
log " (skipped — run 'kei' anytime to see substrate status)"
;;
*)
log ""
"$KEI_BIN_PATH" || true
;;
esac
fi

View file

@ -116,40 +116,6 @@ 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.
**v0.44 hardening** (post second 4-CLI re-audit, supersedes v0.42; CURRENT):
The second-round audit (Claude+Grok+Gemini+Copilot, each from different
angle) found 9 real issues in v0.42v0.43. All patched. Highlights:
- **Walk-up canonicalize** for non-existent leaf paths — closes the v0.42
bypass where the *parent's parent* could be a symlink. validate_path
now finds the deepest existing ancestor and canonicalizes from there.
- **O_NOFOLLOW + fd-write** — closes TOCTOU window between validate_path
and `fs::write`. Concurrent symlink-swap during hook chain await is now
rejected at `open()` time.
- **`env_clear` on subprocess spawn** — `kei_bash` no longer inherits
`AWS_*`, `GITHUB_TOKEN`, `MOONSHOT_API_KEY`, etc. Whitelist forwards
PATH/HOME/USER/LANG/TERM/SHELL/PWD/TMPDIR only. Add named vars via
`KEI_SAFE_ENV_EXTRA`.
- **`Path::starts_with` + canonical KEI_ALLOWED_ROOTS** —
`KEI_ALLOWED_ROOTS=/home/u/proj` no longer matches `/home/u/proj-evil/`.
Component-aware containment + symlink resolution (so `/var → /private/var`
on macOS works for `/var/folders` $TMPDIR).
- **MOONSHOT_API_KEY sanitization** in `kei limits` — token validated
against `[A-Za-z0-9_.-]+` before being fed to `curl --config -`; blocks
config injection if env value was tampered.
- **macOS `/var/folders` carve-out** — denylist no longer blocks $TMPDIR.
allowed_roots check runs BEFORE denylist; only `/var/db/`, `/var/log/`,
`/var/root/` etc. are now blanket-denied.
- **Hook subprocess hardening**`process_group(0)` + `killpg` now also
applied to hook spawn (was: only on bash action; v0.42 left hook
grandchildren orphan on timeout).
**v0.43 hardening** (post first re-audit):
- 4 audit fixes in `kei-limits.sh` (atomic cache, tonumber? parse,
off-argv token, jq runtime guard).
**v0.42 hardening** (post 4-CLI re-audit, supersedes v0.41):
- **Fail-CLOSED everywhere** — missing config, missing hook, OR empty

View file

@ -108,18 +108,6 @@ strengths; the substrate is agnostic about which you pick. Pick by:
- **Independent second opinion** — same agent, different model, see if
conclusions diverge.
## First-run wizard (`kei onboard`, v0.45+)
After install, `bootstrap.sh` auto-triggers `kei onboard` if stdin is a TTY.
The wizard walks through:
1. Pick primary LLM orchestrator (claude / grok / agy / copilot / kimi)
2. Run `kei mcp-wire` to wire kei-mcp into all detected CLIs
3. Optional MOONSHOT_API_KEY hint for `kei limits` live polling
4. Run `kei-doctor` health check
Re-run any time: `kei onboard`. Skip auto-trigger on install: `KEI_NO_ONBOARD=1`.
## Orchestrator picker — `kei` no longer hardcodes claude
Without args, `kei` reads `~/.claude/config/primary.toml` and execs that CLI.
@ -137,21 +125,6 @@ The splash shows `primary CLI: <backend>` so you always know which orchestrator
will start. If the chosen primary isn't installed, `kei` prints the install
command and offers `kei pick` as recovery.
## Subscription quotas — `kei limits` (v0.43+)
```bash
kei limits # human-readable report
kei limits --json # machine-readable
kei limits --quiet # cache-refresh only, no output
```
Research-grounded honest delivery: 4 of 5 CLIs have **no public programmatic
API for quota**. The command shows status markers + dashboard URLs for those.
Only Kimi exposes a balance API via Moonshot `/v1/users/me/balance`
requires `MOONSHOT_API_KEY` env. The cache lives at
`~/.claude/pet/limits-cache.json`; the pet statusline reads it (does NOT
poll itself) and displays the Kimi balance segment when live.
## Cross-CLI sub-agent spawn via MCP — `spawn_agent`
`kei-mcp` exposes a built-in `spawn_agent` MCP tool. Any CLI that connects

0
hooks/alignment-check.sh Normal file → Executable file
View file

0
hooks/chat-numeric-postflag.sh Normal file → Executable file
View file

0
hooks/chat-numeric-prewarn.sh Normal file → Executable file
View file

0
hooks/citation-verify.sh Normal file → Executable file
View file

0
hooks/no-downgrade.sh Normal file → Executable file
View file

8
hooks/no-python-without-approval.sh Normal file → Executable file
View file

@ -4,7 +4,7 @@
_KEI_LIB="$(dirname "$0")/_lib/gate.sh"; if [ -r "$_KEI_LIB" ]; then . "$_KEI_LIB"; kei_hook_gate "no-python-without-approval" || exit 0; fi
# Hard block on python/python3/python2 invocations in Bash tool.
# RULE 0.2 (Rust First) — Python requires explicit architectural reason.
# Claude kрoнически нарушает RULE 0.2 inline-вызовами python3 для мелких расчётов.
# Claude кroнически нарушает RULE 0.2 inline-вызовами python3 для мелких расчётов.
# Этот хук форсирует: каждый python-вызов = отдельный approval через интерфейс.
#
# How to approve: user may add a one-off permission via Claude Code's
@ -30,9 +30,9 @@ fi
# Also: uv run python, poetry run python, pipx run python
if echo "$CMD" | grep -qE '(^|[[:space:]/"=(|&;`])(python|python2|python3)([0-9]?\.[0-9]+)?([[:space:]]|$)'; then
cat >&2 <<'EOF'
════════════════════════════════════════════════════════════════
═══════════════════════════════════════════════════════════════════
BLOCKED — Python invocation requires explicit approval (RULE 0.2).
════════════════════════════════════════════════════════════════
═══════════════════════════════════════════════════════════════════
RULE 0.2 Rust First:
Python не разрешается по умолчанию. Для "одноразовых расчётов"
@ -54,7 +54,7 @@ that only exists in Python, one of the RULE 0.2 exceptions 1-7):
This hook installed 2026-04-21 by user request after repeated
repeated inline python3 use where Rust would suffice.
════════════════════════════════════════════════════════════════
═══════════════════════════════════════════════════════════════════
EOF
exit 2
fi

9
hooks/numeric-claims-guard.sh Normal file → Executable file
View file

@ -27,8 +27,7 @@ fi
# Patterns that indicate a numeric claim
# - "~N min/hour/day/week"
# - "N MB/GB/LOC/tests/crates/atomars"
# - "~$N", "$N/mo", "$N.NN", "$NN" (money needs decimal / unit / tilde / 2+ digits
# so shell positionals $1..$9 are NOT flagged)
# - "~$N", "$N/mo"
# - "Nm Ns", "займёт N", "should take N"
NUMERIC_PATTERN='(~\s*[0-9]+(\.[0-9]+)?\s*(min|minute|hour|hr|day|week|month|sec|second|MB|GB|KB|LOC|line|test|crate|atomar|%|µs|ms|ns|TPS|req/s)|[0-9]+m\s*[0-9]+s|\$[0-9]+\.[0-9]+|\$[0-9]+/(mo|hr|day|run)|\$[0-9]{2,}|~\s*\$[0-9]+|should take|will take|takes about|займёт|за ~|estimated at|ETA[: ]|approximately\s+[0-9])'
@ -49,9 +48,9 @@ fi
MATCHED="$(echo "$NEW_CONTENT" | grep -iEo "$NUMERIC_PATTERN" | head -3 | tr '\n' '; ')"
cat >&2 <<EOF
════════════════════════════════════════════════════════════════
═══════════════════════════════════════════════════════════════════
RULE 0.18 — Numeric claim without evidence marker.
════════════════════════════════════════════════════════════════
═══════════════════════════════════════════════════════════════════
Found in Edit/Write content:
$MATCHED
@ -71,7 +70,7 @@ Bypass (visible, per-call):
RULE_017_BYPASS=1 <command>
See: ~/.claude/rules/numeric-claims-evidence.md
════════════════════════════════════════════════════════════════
═══════════════════════════════════════════════════════════════════
EOF
exit 2

0
hooks/rust-first.sh Normal file → Executable file
View file

View file

@ -20,28 +20,6 @@
set -euo pipefail
# --- OS guard (v0.47): friendly message on bare Windows ------------------
_uname_s="$(uname -s 2>/dev/null || echo unknown)"
case "$_uname_s" in
Darwin|Linux) ;; # ok
MINGW*|MSYS*|CYGWIN*)
echo "[install.sh] ERROR: bare Windows ($_uname_s) detected." >&2
echo "" >&2
echo "KeiSeiKit's substrate is Bash-only. Use WSL2 instead:" >&2
echo " 1. PowerShell (admin): wsl --install -d Ubuntu" >&2
echo " 2. Reboot when prompted; launch Ubuntu." >&2
echo " 3. Inside Ubuntu, re-run this installer." >&2
echo "" >&2
echo "See README → 'Platforms' for the full path + MCP-only fallback." >&2
exit 1
;;
*)
echo "[install.sh] ERROR: unsupported OS: $_uname_s (supported: Darwin / Linux / WSL2)" >&2
exit 1
;;
esac
unset _uname_s
# --- paths ----------------------------------------------------------------
KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HOME_DIR="${HOME:?HOME not set}"
@ -52,27 +30,6 @@ MANIFEST="$KIT_DIR/_primitives/MANIFEST.toml"
INSTALLED_FILE="$AGENTS_DIR/_primitives/.installed"
LIB_DIR="$KIT_DIR/install"
# --- v0.49: interactive-prompt cube (Constructor Pattern SSoT) -----------
# ALL interactive logic — `kei_is_interactive`, `kei_prompt`, `kei_prompt_yn`,
# `kei_prompt_secret` — lives in scripts/kei-prompt.sh. NEVER inline
# `[ -t 0 ]` or `read -r` in installer code. Source it BEFORE other libs
# so they can use the helpers.
if [ -r "$KIT_DIR/scripts/kei-prompt.sh" ]; then
# shellcheck source=scripts/kei-prompt.sh
source "$KIT_DIR/scripts/kei-prompt.sh"
elif [ -r "$HOME/.claude/scripts/kei-prompt.sh" ]; then
# shellcheck disable=SC1091
source "$HOME/.claude/scripts/kei-prompt.sh"
else
# Self-contained fallback — same contract as the cube's kei_is_interactive.
kei_is_interactive() {
[ "${KEI_NONINTERACTIVE:-0}" = "1" ] && return 1
if [ -r /dev/tty ] && [ -w /dev/tty ]; then return 0; fi
[ -t 0 ] && return 0
return 1
}
fi
# --- source cubes (order matters: logs -> backup -> profile -> rest) ------
# shellcheck source=install/lib-log.sh
source "$LIB_DIR/lib-log.sh"
@ -288,7 +245,7 @@ if [ "$NO_PATHWAY" != "1" ]; then
# logfile, so -t 1 is false even interactively. Requiring it skipped PATH
# wiring (~/.claude/bin), so the `kei` entry-point was not found after a
# curl|bash install. (Same tee/-t1 trap as the onboarding gates.)
if [ "$WITH_PATHWAY" = "1" ] || kei_is_interactive; then
if [ "$WITH_PATHWAY" = "1" ] || [ -t 0 ]; then
pathway_install
fi
fi

0
install/lib-dev-hub-forgejo-runner.sh Executable file → Normal file
View file

0
install/lib-dev-hub-forgejo.sh Executable file → Normal file
View file

0
install/lib-dev-hub-zoekt.sh Executable file → Normal file
View file

View file

@ -179,17 +179,17 @@ maybe_activate_hooks() {
elif [ ! -f "$settings_file" ]; then
say "no existing settings.json; installing snippet"
activate_hooks && DID_ACTIVATE=1
elif kei_is_interactive; then # /dev/tty-aware: covers curl|bash
local _hooks_q
elif [ -t 0 ]; then # stdin-only: stdout may be tee'd in curl|bash
if [ "$COLOR" = "1" ]; then
_hooks_q=$'\033[1;36m[install]\033[0m activate hooks now?'
printf '\033[1;36m[install]\033[0m activate hooks now? [y/N] '
else
_hooks_q='[install] activate hooks now?'
fi
if kei_prompt_yn "$_hooks_q" "N"; then
activate_hooks && DID_ACTIVATE=1
else
say "skipping hook activation"
printf '[install] activate hooks now? [y/N] '
fi
local reply
read -r reply
case "$reply" in
y|Y|yes|YES) activate_hooks && DID_ACTIVATE=1 ;;
*) say "skipping hook activation" ;;
esac
fi
}

View file

@ -17,7 +17,7 @@ menu_should_skip() {
[ -n "$ADD_LIST" ] && return 0
[ -n "$REMOVE_NAME" ] && return 0
[ "$LIST_MODE" = "1" ] && return 0
kei_is_interactive || return 0 # /dev/tty-aware: covers curl|bash + plain bash
[ ! -t 0 ] && return 0 # interactive stdin only; not -t 1 (curl|bash tees stdout)
return 1
}

View file

@ -43,10 +43,11 @@ REGISTRY_MODELS="$KIT_DIR/_blocks/registries/models.toml"
onboarding_should_run() {
[ -f "$ONBOARDED_FLAG" ] && return 1
[ "${KEISEI_SKIP_ONBOARD:-}" = "1" ] && return 1
# v0.49: delegate to the kei-prompt cube — covers both plain bash AND
# curl|bash (where stdin is the pipe from curl, so [ -t 0 ] is false
# even with the user at a real terminal — only /dev/tty is reliable).
kei_is_interactive || return 1
# Interactive iff stdin is a terminal. We deliberately do NOT require -t 1:
# the curl|bash bootstrapper (web-install.sh) tees stdout to a logfile, so
# -t 1 is false even in an interactive session. Prompts go to stderr, input
# reads from stdin — an interactive stdin is the only real requirement.
[ ! -t 0 ] && return 1
return 0
}
@ -71,8 +72,8 @@ onboarding_run() {
if ! preflight_run "$ONBOARDING_PROVIDER"; then
echo "" >&2
echo "${STR_PREFLIGHT_FAILED:-Preflight failed — provider may not work.}" >&2
if kei_is_interactive; then # /dev/tty-aware: covers curl|bash
_ans=$(kei_prompt " ${STR_PREFLIGHT_CONTINUE:-Continue anyway? [y/N]} " "N")
if [ -t 0 ]; then # stdin-only: stdout may be tee'd in curl|bash
read -r -p " ${STR_PREFLIGHT_CONTINUE:-Continue anyway? [y/N]} " _ans
case "$_ans" in
y|Y|yes|да|Да)
echo " → продолжаю; ключи запишутся но runtime может упасть." >&2

View file

@ -137,7 +137,7 @@ show_confirm_screen() {
local profile_label="$1"
print_plan_body "$profile_label"
[ "$ASSUME_YES" = "1" ] && { echo "(--yes: auto-confirming)"; return 0; }
kei_is_interactive || { echo "(non-TTY: auto-confirming)"; return 0; }
[ ! -t 0 ] && { echo "(non-TTY: auto-confirming)"; return 0; }
if command -v whiptail >/dev/null 2>&1; then
whiptail --yesno "Install ${CONFIRM_TOTAL:-0} primitive(s) for profile '$profile_label'?\n\nTime: ~${CONFIRM_SECS}s, disk: ~${CONFIRM_MB} MB" 14 70
return $?

View file

@ -25,9 +25,9 @@ preflight_offer_install() {
echo "$cli не найден." >&2
echo " Установить: $install_cmd" >&2
echo "" >&2
if kei_is_interactive; then # /dev/tty-aware: covers curl|bash
if [ -t 0 ]; then # stdin-only: stdout may be tee'd in curl|bash
echo " ⓘ команда: $install_cmd" >&2
ans=$(kei_prompt " Поставить сейчас? [y/N/skip] " "N")
read -r -p " Поставить сейчас? [y/N/skip] " ans
case "$ans" in
y|Y|yes)
# bash -c вместо eval — explicit subshell, не word-splitting'тся

View file

@ -10,7 +10,7 @@
run_sleep_wizard() {
local sleep_helper="$AGENTS_DIR/_primitives/kei-sleep-setup.sh"
if [[ -x "$sleep_helper" ]] && kei_is_interactive; then # /dev/tty-aware: covers curl|bash
if [[ -x "$sleep_helper" ]] && [ -t 0 ]; then # stdin only; not -t 1 (curl|bash tees stdout)
say "running sleep-sync setup helper"
"$sleep_helper" || warn "sleep-sync setup did not complete — re-run via /sleep-setup"
else

View file

@ -3,7 +3,7 @@
"name": "keisei",
"displayName": "KeiSei",
"description": "Constructor Pattern multi-LLM agent substrate — 38 agents, 69 skills, 54 hooks, 86 blocks. Cross-CLI policy enforcement (Claude/Grok/Copilot/Agy/Kimi) via kei-mcp + kei_bash/kei_edit/kei_write. Rust primitives via classic ./install.sh.",
"version": "0.49.0",
"version": "0.45.0",
"homepage": "https://keisei.app",
"repository": "https://github.com/KeiSeiLab/KeiSeiKit-1.0.git",
"author": {

View file

@ -145,7 +145,7 @@ backend_invoke() {
printf '[kei-agent-cli] (or pipe via `kimi acp` if you have an ACP client.)\n' >&2
exec "$bin"
;;
codex) exec "$bin" exec "$prompt" ;;
codex) exec "$bin" -p "$prompt" ;;
esac
}

0
scripts/kei-configure.sh Executable file → Normal file
View file

View file

@ -31,15 +31,12 @@ if [ "${KEI_WIRE_CHECK:-0}" = "1" ] || [ "${KEI_WIRE_DRY_RUN:-0}" = "1" ]; then
"mcpServers": {
"kei-mcp": {
"command": "$BIN",
"env": {}
"env": { "CLAUDECODE": "1" }
}
}
}
(v0.46: CLAUDECODE/GROKCODE env-skip was removed — the chain runs
always now. Native PreToolUse hooks fire on tool_name='Bash'/'Edit'/
'Write', but MCP tools are named kei_bash/kei_edit/kei_write, so
native hooks would NOT fire anyway — there is no double-enforcement
to avoid. Empty env block left in case operators add their own vars.)
(CLAUDECODE=1 tells kei-mcp to skip its hook chain — your native hooks
already fire on PreToolUse. Avoids double-enforcement.)
EOF
fi

View file

@ -46,7 +46,7 @@ if [ -n "$KEI_MCP_BIN" ] && [ -x "$KEI_MCP_BIN" ]; then
"mcpServers": {
"kei-mcp": {
"command": "$KEI_MCP_BIN",
"env": {}
"env": { "GROKCODE": "1" }
}
}
}
@ -73,5 +73,5 @@ 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 (v0.46: chain always runs, no env-skip)"
[ -n "$mcp_block" ] && echo " kei-mcp MCP server registered (with GROKCODE=1 guard)"
echo " Same enforcement as Claude Code."

View file

@ -1,147 +0,0 @@
#!/usr/bin/env bash
# kei-prompt — единственный cube для интерактивного ввода (Constructor Pattern).
#
# Source it, then use the functions. NEVER inline `[ -t 0 ]` + `read` in
# installer / bootstrap shell files — call these helpers instead.
#
# Why this exists (2026-05-27 architectural fix):
# - `[ -t 1 ]` fails under curl|bash (stdout tee'd) → rule v1.
# - `[ -t 0 ]` ALSO fails under curl|bash (stdin = pipe from curl) → rule v2.
# - The ONLY reliable interactive signal is /dev/tty accessibility.
# - Spreading that check across 15+ files invites the same bug forever.
# - One cube, one truth: kei_is_interactive(). All callers are downstream.
#
# Public API (alphabetical):
# kei_is_interactive → 0 if user is at a terminal, 1 if headless
# kei_prompt Q [DEFAULT] → echo answer (or DEFAULT) to stdout
# kei_prompt_yn Q [Y|N] → exit 0 if user said yes, 1 otherwise
# kei_prompt_secret Q → echo answer (no echo on terminal) to stdout
#
# Overrides:
# KEI_NONINTERACTIVE=1 → all helpers behave as if headless (CI override)
# Re-source guard — sourcing twice should be a no-op.
[ "${_KEI_PROMPT_SOURCED:-0}" = "1" ] && return 0
_KEI_PROMPT_SOURCED=1
# ---------------------------------------------------------------------------
# kei_is_interactive
#
# Returns 0 (interactive) when ANY of:
# - /dev/tty is readable AND writable (covers curl|bash, where stdin is
# a pipe from curl but the terminal is still attached at fd /dev/tty)
# - stdin is a tty (covers plain `./bootstrap.sh` invocation)
# Returns 1 (headless) when:
# - KEI_NONINTERACTIVE=1 (explicit CI override)
# - none of the above signals are present
#
# Use this EVERYWHERE instead of `[ -t 0 ]` or `[ -t 1 ]`.
kei_is_interactive() {
[ "${KEI_NONINTERACTIVE:-0}" = "1" ] && return 1
if [ -r /dev/tty ] && [ -w /dev/tty ]; then
return 0
fi
if [ -t 0 ]; then
return 0
fi
return 1
}
# ---------------------------------------------------------------------------
# _kei_read_from_tty — internal: read one line from /dev/tty if openable,
# else from stdin. Echoes the line via the variable name passed in $1.
#
# Note: we try to OPEN /dev/tty (not just `[ -r /dev/tty ]`) — in some
# sandboxes the file exists but open() returns ENXIO ("Device not
# configured"). Both stages must be silent on failure so the prompt
# UI stays clean.
_kei_read_from_tty() {
local _varname="$1"
local _line=""
if { exec 3</dev/tty; } 2>/dev/null; then
IFS= read -r _line <&3 || _line=""
exec 3<&-
else
IFS= read -r _line || _line=""
fi
# POSIX-safe assignment to caller's variable.
eval "$_varname=\$_line"
}
# ---------------------------------------------------------------------------
# kei_prompt <question> [default]
#
# Prints `question` to stderr (so it shows even when stdout is captured).
# Reads user input from /dev/tty (with stdin fallback).
# Echoes the answer to stdout — or `default` if user pressed Enter / headless.
# Always returns 0 (never fails the caller).
kei_prompt() {
local q="${1:-}"
local def="${2:-}"
local ans=""
if ! kei_is_interactive; then
printf '%s' "$def"
return 0
fi
printf '%s' "$q" >&2
_kei_read_from_tty ans
printf '%s' "${ans:-$def}"
return 0
}
# ---------------------------------------------------------------------------
# kei_prompt_yn <question> [default=Y|N]
#
# Yes/no convenience. Returns:
# 0 — user said yes (or default was Y and they pressed Enter / headless)
# 1 — user said no (or default was N and they pressed Enter / headless)
# The hint `[Y/n]` / `[y/N]` is appended automatically based on `default`.
kei_prompt_yn() {
local q="${1:-}"
local def="${2:-Y}"
local hint=""
case "$def" in
[Yy]*) hint="[Y/n]"; def="Y" ;;
[Nn]*) hint="[y/N]"; def="N" ;;
*) hint="[y/n]"; def="N" ;;
esac
local ans
ans="$(kei_prompt "$q $hint " "$def")"
case "${ans:-$def}" in
[Yy]*) return 0 ;;
*) return 1 ;;
esac
}
# ---------------------------------------------------------------------------
# kei_prompt_secret <question>
#
# Like kei_prompt but with echo disabled on the terminal (for tokens, keys).
# Returns 1 if no terminal — secret input should not be silently defaulted.
# Echoes the secret to stdout; caller is responsible for not logging it.
kei_prompt_secret() {
local q="${1:-}"
local ans=""
if ! kei_is_interactive; then
return 1
fi
printf '%s' "$q" >&2
# Prefer /dev/tty so the secret never touches stdin pipe.
local _src=/dev/stdin
[ -r /dev/tty ] && _src=/dev/tty
# `read -s` is bash-only; use stty -echo for POSIX portability.
if command -v stty >/dev/null 2>&1; then
local _state
_state="$(stty -g <"$_src" 2>/dev/null || echo)"
stty -echo <"$_src" 2>/dev/null || true
IFS= read -r ans <"$_src" || ans=""
[ -n "$_state" ] && stty "$_state" <"$_src" 2>/dev/null || stty echo <"$_src" 2>/dev/null
printf '\n' >&2
else
IFS= read -r ans <"$_src" || ans=""
fi
printf '%s' "$ans"
return 0
}

0
scripts/keisei-pet-update.sh Executable file → Normal file
View file

0
scripts/keisei-pet.sh Executable file → Normal file
View file

12
web-install.sh Normal file → Executable file
View file

@ -48,13 +48,13 @@ exec > >(tee -a "$LOG") 2>&1
say() { printf "\033[1;36m[web-install]\033[0m %s\n" "$*"; }
die() { printf "\033[1;31m[err]\033[0m %s\n" "$*" >&2; exit 1; }
# ── splash ─────────────────────────────────────────────────────────────────
# ── splash ─────────────────────────────────────────────────────────────────
cat <<'EOF'
╔═════════════════════════════════════════════════════╗
╔═══════════════════════════════════════════════════════
║ KeiSeiKit · Exobrain installer ║
║ Portable Rust agent substrate for AI coding tools ║
╚═════════════════════════════════════════════════════╝
╚═══════════════════════════════════════════════════════
EOF
say "log: $LOG"
@ -62,7 +62,7 @@ say "log: $LOG"
# ── prereq: git (the only thing bootstrap.sh can't self-install) ───────────
command -v git >/dev/null || die "missing: git (brew install git / apt install git)"
# ── auth probe for private repo ─────────────────────────────────────────────────────────
# ── auth probe for private repo ────────────────────────────────────────────
case "$KEISEI_REPO" in
git@github.com:*)
say "checking GitHub SSH auth"
@ -73,7 +73,7 @@ case "$KEISEI_REPO" in
;;
esac
# ── clone or pull (idempotent) ────────────────────────────────────────────────────────────
# ── clone or pull (idempotent) ─────────────────────────────────────────────
mkdir -p "$(dirname "$KEISEI_ROOT")"
if [ -d "$KEISEI_ROOT/.git" ]; then
say "pulling $KEISEI_REF in $KEISEI_ROOT"
@ -92,7 +92,7 @@ else
fi
git -C "$KEISEI_ROOT" submodule update --init --recursive 2>/dev/null || true
# ── delegate to kit's own bootstrap.sh ────────────────────────────────────────────────
# ── delegate to kit's own bootstrap.sh ─────────────────────────────────────
[ -x "$KEISEI_ROOT/bootstrap.sh" ] || die "kit's bootstrap.sh not found in $KEISEI_ROOT"
say "delegating to $KEISEI_ROOT/bootstrap.sh ${PASS_THROUGH[*]:-}"
cd "$KEISEI_ROOT"