Compare commits

...

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

41 changed files with 1357 additions and 834 deletions

View file

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

View file

@ -13,4 +13,78 @@ All notable changes are tagged via `git tag v*`. This file tracks unreleased wor
## Released ## Released
Release notes per tag are kept in the GitHub Releases UI. See `git tag --sort=-creatordate`. 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.

165
README.md
View file

@ -1,23 +1,81 @@
# KeiSeiKit # KeiSeiKit
A **multi-LLM substrate** that gives any agentic coding tool persistent A **multi-LLM substrate** for agentic coding. Same agent definition,
memory, deterministic agent identity, and self-maintaining orchestration. any LLM backend — Claude Code, Grok, Antigravity (Gemini), GitHub
Works first-class with Claude Code; MCP-compatible bridges generate Copilot, or Kimi. Pick your orchestrator with `kei pick`; agents
context for Cursor / Continue / Zed / Aider / Windsurf / Cline / spawn sub-agents on other LLMs via MCP `spawn_agent`; safety hooks
OpenClaw / Kimi from the same source-of-truth. enforce on every backend through a 3-tier model. Three-phase nightly
sleep consolidates 30-session windows into morning markdown reports.
**Apache 2.0** — explicit patent grant + retaliation clause. 105 Rust **Apache 2.0** — explicit patent grant + retaliation clause.
crates [REAL: `grep -E '^\s*"[a-z-]+",' _primitives/_rust/Cargo.toml | wc -l`],
69 skills [REAL: `ls skills/ | wc -l`], 54 hooks ## Highlights
[REAL: `ls hooks/*.sh | wc -l`], 38 agent manifests
[REAL: `ls _manifests/*.toml | wc -l`], 85 substrate blocks - **5 LLM CLIs unified.** Claude Code (native hooks), Grok (port to
[REAL: `find _blocks/ -name '*.md' | wc -l`], 18 capability atoms `~/.grok/settings.json`), Antigravity/Gemini, GitHub Copilot, Kimi.
[REAL: `find _capabilities/ -mindepth 2 -maxdepth 2 -type d | wc -l`], DNA-routed: each agent's manifest declares a `provider`;
7 substrate roles [REAL: `ls _roles/*.toml | wc -l`]. Self-indexing `kei agent <name>` resolves DNA → primary → claude fallback.
via kei-registry SQLite (565 active DNAs - **Sub-agents on any backend.** Agents call `spawn_agent` (built-in MCP
[REAL: `head -3 docs/DNA-INDEX.md | grep "Total blocks:"`] as of tool in `kei-mcp`) to dispatch other agents to whichever LLM fits
2026-05-03). Three-phase nightly consolidation. Foreign-project the task. Cross-CLI orchestration without lock-in — Grok can spawn
ingestion runtime (`kei-import <repo-url>`). 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.
## Maturity matrix ## Maturity matrix
@ -93,6 +151,54 @@ duplicated install logic.
into client-native config — those are bridge targets, not separate into client-native config — those are bridge targets, not separate
profiles. 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) ### Outcome-only — try just the outcome loop (5 files, ~200 LOC)
If you want to try only the outcome-tracking primitive without If you want to try only the outcome-tracking primitive without
@ -280,25 +386,8 @@ covered by their contributions lose their license to the work.
Pre-2026-04-30 versions remain available under their original MIT Pre-2026-04-30 versions remain available under their original MIT
terms (irrevocable). See [LICENSE](./LICENSE) and [NOTICE](./NOTICE). terms (irrevocable). See [LICENSE](./LICENSE) and [NOTICE](./NOTICE).
## Author & collaboration <!--
Author / collaboration section removed — to be written by hand.
TODO: replace this comment with the section you want.
-->
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 - 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) - 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 - 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 EC2 i-0a8b747023809d451 with tip-platform, marketing-ai-agent, psychology-tests - Shared-infra risk flagging — e.g. Recruiter shares an EC2 with tip-platform, marketing-ai-agent, psychology-tests
**Out (hand off):** **Out (hand off):**
- `code-implementer` — deploy pipeline requires new application code / binary / library (not infra definition) - `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-rules}/dev-workflow.md`
- `{path::user-memory}/security-restricted-projects.md` - `{path::user-memory}/security-restricted-projects.md`
- `Compute Cost Incident: $98.78 Modal overrun — no dashboard check, unverified prices.` - `Compute Cost Incident: $98.78 Modal overrun — no dashboard check, unverified prices.`
- `Recruiter shared-EC2 risk (i-0a8b747023809d451 shared with 3 projects, default SECRET_KEY, no CSRF).` - `Recruiter shared-EC2 risk (<ec2-instance-id> 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.` - `CloudSync 146 GB bloat: two duplicate LaunchAgents both writing logs. Scan for duplicates before adding infra.`
## Output Footer (RULE 0.16) ## Output Footer (RULE 0.16)

View file

@ -1,738 +0,0 @@
//! 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

@ -0,0 +1,159 @@
//! 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

@ -0,0 +1,79 @@
//! 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

@ -0,0 +1,222 @@
//! 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

@ -0,0 +1,99 @@
//! 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

@ -0,0 +1,166 @@
//! 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,17 +217,30 @@ splash() {
as="$(active_sessions)" as="$(active_sessions)"
# Only color if stdout is a tty. Brand palette: голубой (sky-blue) + жёлтый (gold). # Only color if stdout is a tty. Brand palette: голубой (sky-blue) + жёлтый (gold).
local C0= C1= C2= C3= CV= local C0= C1= C2= C3= CV= CS= GO_BACK= SHADOW_BLOCK=
if [ -t 1 ]; then if [ -t 1 ]; then
C0=$'\033[0m' C0=$'\033[0m'
C1=$'\033[1;38;5;39m' # голубой (sky-blue) — logo C1=$'\033[1;38;5;39m' # голубой (sky-blue) — logo
C2=$'\033[1;38;5;220m' # жёлтый (gold) — brand line C2=$'\033[1;38;5;220m' # жёлтый (gold) — brand line
C3=$'\033[2;38;5;39m' # dim blue — separators C3=$'\033[2;38;5;39m' # dim blue — separators
CV=$'\033[1;38;5;220m' # жёлтый — field values 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 fi
cat <<EOF cat <<EOF
${SHADOW_BLOCK}
${C1} ██╗ ██╗███████╗██╗███████╗███████╗██╗${C0} ${C1} ██╗ ██╗███████╗██╗███████╗███████╗██╗${C0}
${C1} ██║ ██╔╝██╔════╝██║██╔════╝██╔════╝██║${C0} ${C1} ██║ ██╔╝██╔════╝██║██╔════╝██╔════╝██║${C0}
${C1} █████╔╝ █████╗ ██║███████╗█████╗ ██║${C0} ${C1} █████╔╝ █████╗ ██║███████╗█████╗ ██║${C0}
@ -235,7 +248,7 @@ ${C1} ██╔═██╗ ██╔══╝ ██║╚════█
${C1} ██║ ██╗███████╗██║███████║███████╗██║${C0} ${C1} ██║ ██╗███████╗██║███████║███████╗██║${C0}
${C1} ╚═╝ ╚═╝╚══════╝╚═╝╚══════╝╚══════╝╚═╝${C0} ${C1} ╚═╝ ╚═╝╚══════╝╚═╝╚══════╝╚══════╝╚═╝${C0}
${C2} KeiSeiKit · substrate v0.45${C0} ${C2} KeiSeiKit · substrate v0.49${C0}
${C3} ─────────────────────────────────────${C0} ${C3} ─────────────────────────────────────${C0}
primary CLI : ${CV}${PRIMARY}${C0} primary CLI : ${CV}${PRIMARY}${C0}
profile : ${CV}${p}${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 # 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 # (was "cortex" here → divergent install vs direct install.sh). Opt up with
# --profile=cortex/full-hub. # --profile=cortex/full-hub.
if [ ! -t 0 ]; then PROFILE="minimal"; return 0; fi if ! kei_is_interactive; then PROFILE="minimal"; return 0; fi
cat <<'WIZARD' cat <<'WIZARD'
╔═══════════════════════════════════════════════════════════════════╗ ╔═══════════════════════════════════════════════════════════════════╗
@ -115,14 +115,86 @@ log() { echo "[bootstrap] $*"; }
err() { echo "[bootstrap] ERROR: $*" >&2; } err() { echo "[bootstrap] ERROR: $*" >&2; }
have() { command -v "$1" >/dev/null 2>&1; } 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)" OS="$(uname -s)"
# --- 1. OS detection ----------------------------------------------------- # --- 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 case "$OS" in
Darwin|Linux) ;; Darwin|Linux)
*) err "unsupported OS: $OS (only Darwin / Linux for now)"; exit 1 ;; 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
;;
esac esac
log "OS: $OS"
# --- 2. install jq ------------------------------------------------------- # --- 2. install jq -------------------------------------------------------
install_jq() { install_jq() {
@ -179,6 +251,19 @@ log "checkout: $KIT_DIR"
# --- 5. run install ------------------------------------------------------ # --- 5. run install ------------------------------------------------------
log "running install.sh --profile=$PROFILE $YES_FLAG ${EXTRA_FLAGS[*]:-}" log "running install.sh --profile=$PROFILE $YES_FLAG ${EXTRA_FLAGS[*]:-}"
cd "$KIT_DIR" 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 # Defensive: invoke via `bash` not `./install.sh` because GitHub's contents
# API does NOT preserve the executable bit on `gh api -X PUT` updates # 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 # (only the git Data API does). Older clones may have install.sh with
@ -205,11 +290,12 @@ log "===========================================================================
log "DONE — KeiSeiKit installed (profile: $PROFILE)" log "DONE — KeiSeiKit installed (profile: $PROFILE)"
log "===========================================================================" log "==========================================================================="
# v0.45: post-install onboarding wizard. # v0.48: post-install onboarding wizard.
# Auto-triggers if stdin is a TTY (real terminal). Wizard itself re-checks # stdin already reattached to /dev/tty above (when present), so [ -t 0 ]
# and exits cleanly if non-interactive — so curl|bash one-liner runs work too. # inside this scope correctly reports interactive vs headless. Wizard
# itself re-checks and exits cleanly if non-interactive.
ONBOARD_SH="$HOME/.claude/scripts/kei-onboard.sh" ONBOARD_SH="$HOME/.claude/scripts/kei-onboard.sh"
if [ -x "$ONBOARD_SH" ] && [ -t 0 ] && [ "${KEI_NO_ONBOARD:-0}" != "1" ]; then if [ -x "$ONBOARD_SH" ] && kei_is_interactive && [ "${KEI_NO_ONBOARD:-0}" != "1" ]; then
log "" log ""
log "Starting post-install onboarding (pick primary CLI + wire MCP)..." log "Starting post-install onboarding (pick primary CLI + wire MCP)..."
log "Skip with KEI_NO_ONBOARD=1; re-run anytime with 'kei onboard'." log "Skip with KEI_NO_ONBOARD=1; re-run anytime with 'kei onboard'."
@ -230,3 +316,23 @@ log " - Or source the rc file the installer wrote (Bash: ~/.bashrc, Zsh: ~/.zsh
log " - Run kei-doctor for a full health diagnostic." log " - Run kei-doctor for a full health diagnostic."
log " - For cortex profile: run /cortex-setup inside Claude Code." log " - For cortex profile: run /cortex-setup inside Claude Code."
log " - For sleep layer: run /sleep-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,6 +116,40 @@ The chain runs against the same hook scripts Claude uses; identical input
shape, identical decisions. On block, the hook's stderr surfaces as the MCP shape, identical decisions. On block, the hook's stderr surfaces as the MCP
error message so the calling agent sees exactly why. error message so the calling agent sees exactly why.
**v0.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): **v0.42 hardening** (post 4-CLI re-audit, supersedes v0.41):
- **Fail-CLOSED everywhere** — missing config, missing hook, OR empty - **Fail-CLOSED everywhere** — missing config, missing hook, OR empty

View file

@ -108,6 +108,18 @@ strengths; the substrate is agnostic about which you pick. Pick by:
- **Independent second opinion** — same agent, different model, see if - **Independent second opinion** — same agent, different model, see if
conclusions diverge. 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 ## Orchestrator picker — `kei` no longer hardcodes claude
Without args, `kei` reads `~/.claude/config/primary.toml` and execs that CLI. Without args, `kei` reads `~/.claude/config/primary.toml` and execs that CLI.
@ -125,6 +137,21 @@ 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 will start. If the chosen primary isn't installed, `kei` prints the install
command and offers `kei pick` as recovery. 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` ## Cross-CLI sub-agent spawn via MCP — `spawn_agent`
`kei-mcp` exposes a built-in `spawn_agent` MCP tool. Any CLI that connects `kei-mcp` exposes a built-in `spawn_agent` MCP tool. Any CLI that connects

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

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

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

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

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

8
hooks/no-python-without-approval.sh Executable file → Normal 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 _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. # Hard block on python/python3/python2 invocations in Bash tool.
# RULE 0.2 (Rust First) — Python requires explicit architectural reason. # RULE 0.2 (Rust First) — Python requires explicit architectural reason.
# Claude кroнически нарушает RULE 0.2 inline-вызовами python3 для мелких расчётов. # Claude kрoнически нарушает RULE 0.2 inline-вызовами python3 для мелких расчётов.
# Этот хук форсирует: каждый python-вызов = отдельный approval через интерфейс. # Этот хук форсирует: каждый python-вызов = отдельный approval через интерфейс.
# #
# How to approve: user may add a one-off permission via Claude Code's # 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 # 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 if echo "$CMD" | grep -qE '(^|[[:space:]/"=(|&;`])(python|python2|python3)([0-9]?\.[0-9]+)?([[:space:]]|$)'; then
cat >&2 <<'EOF' cat >&2 <<'EOF'
═══════════════════════════════════════════════════════════════════ ════════════════════════════════════════════════════════════════
BLOCKED — Python invocation requires explicit approval (RULE 0.2). BLOCKED — Python invocation requires explicit approval (RULE 0.2).
═══════════════════════════════════════════════════════════════════ ════════════════════════════════════════════════════════════════
RULE 0.2 Rust First: RULE 0.2 Rust First:
Python не разрешается по умолчанию. Для "одноразовых расчётов" 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 This hook installed 2026-04-21 by user request after repeated
repeated inline python3 use where Rust would suffice. repeated inline python3 use where Rust would suffice.
═══════════════════════════════════════════════════════════════════ ════════════════════════════════════════════════════════════════
EOF EOF
exit 2 exit 2
fi fi

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

@ -27,7 +27,8 @@ fi
# Patterns that indicate a numeric claim # Patterns that indicate a numeric claim
# - "~N min/hour/day/week" # - "~N min/hour/day/week"
# - "N MB/GB/LOC/tests/crates/atomars" # - "N MB/GB/LOC/tests/crates/atomars"
# - "~$N", "$N/mo" # - "~$N", "$N/mo", "$N.NN", "$NN" (money needs decimal / unit / tilde / 2+ digits
# so shell positionals $1..$9 are NOT flagged)
# - "Nm Ns", "займёт N", "should take N" # - "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])' 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])'
@ -48,9 +49,9 @@ fi
MATCHED="$(echo "$NEW_CONTENT" | grep -iEo "$NUMERIC_PATTERN" | head -3 | tr '\n' '; ')" MATCHED="$(echo "$NEW_CONTENT" | grep -iEo "$NUMERIC_PATTERN" | head -3 | tr '\n' '; ')"
cat >&2 <<EOF cat >&2 <<EOF
═══════════════════════════════════════════════════════════════════ ════════════════════════════════════════════════════════════════
RULE 0.18 — Numeric claim without evidence marker. RULE 0.18 — Numeric claim without evidence marker.
═══════════════════════════════════════════════════════════════════ ════════════════════════════════════════════════════════════════
Found in Edit/Write content: Found in Edit/Write content:
$MATCHED $MATCHED
@ -70,7 +71,7 @@ Bypass (visible, per-call):
RULE_017_BYPASS=1 <command> RULE_017_BYPASS=1 <command>
See: ~/.claude/rules/numeric-claims-evidence.md See: ~/.claude/rules/numeric-claims-evidence.md
═══════════════════════════════════════════════════════════════════ ════════════════════════════════════════════════════════════════
EOF EOF
exit 2 exit 2

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

View file

@ -20,6 +20,28 @@
set -euo pipefail 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 ---------------------------------------------------------------- # --- paths ----------------------------------------------------------------
KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HOME_DIR="${HOME:?HOME not set}" HOME_DIR="${HOME:?HOME not set}"
@ -30,6 +52,27 @@ MANIFEST="$KIT_DIR/_primitives/MANIFEST.toml"
INSTALLED_FILE="$AGENTS_DIR/_primitives/.installed" INSTALLED_FILE="$AGENTS_DIR/_primitives/.installed"
LIB_DIR="$KIT_DIR/install" 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) ------ # --- source cubes (order matters: logs -> backup -> profile -> rest) ------
# shellcheck source=install/lib-log.sh # shellcheck source=install/lib-log.sh
source "$LIB_DIR/lib-log.sh" source "$LIB_DIR/lib-log.sh"
@ -245,7 +288,7 @@ if [ "$NO_PATHWAY" != "1" ]; then
# logfile, so -t 1 is false even interactively. Requiring it skipped PATH # 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 # wiring (~/.claude/bin), so the `kei` entry-point was not found after a
# curl|bash install. (Same tee/-t1 trap as the onboarding gates.) # curl|bash install. (Same tee/-t1 trap as the onboarding gates.)
if [ "$WITH_PATHWAY" = "1" ] || [ -t 0 ]; then if [ "$WITH_PATHWAY" = "1" ] || kei_is_interactive; then
pathway_install pathway_install
fi fi
fi fi

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,7 +3,7 @@
"name": "keisei", "name": "keisei",
"displayName": "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.", "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.45.0", "version": "0.49.0",
"homepage": "https://keisei.app", "homepage": "https://keisei.app",
"repository": "https://github.com/KeiSeiLab/KeiSeiKit-1.0.git", "repository": "https://github.com/KeiSeiLab/KeiSeiKit-1.0.git",
"author": { "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 printf '[kei-agent-cli] (or pipe via `kimi acp` if you have an ACP client.)\n' >&2
exec "$bin" exec "$bin"
;; ;;
codex) exec "$bin" -p "$prompt" ;; codex) exec "$bin" exec "$prompt" ;;
esac esac
} }

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

View file

@ -31,12 +31,15 @@ if [ "${KEI_WIRE_CHECK:-0}" = "1" ] || [ "${KEI_WIRE_DRY_RUN:-0}" = "1" ]; then
"mcpServers": { "mcpServers": {
"kei-mcp": { "kei-mcp": {
"command": "$BIN", "command": "$BIN",
"env": { "CLAUDECODE": "1" } "env": {}
} }
} }
} }
(CLAUDECODE=1 tells kei-mcp to skip its hook chain — your native hooks (v0.46: CLAUDECODE/GROKCODE env-skip was removed — the chain runs
already fire on PreToolUse. Avoids double-enforcement.) 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.)
EOF EOF
fi fi

View file

@ -46,7 +46,7 @@ if [ -n "$KEI_MCP_BIN" ] && [ -x "$KEI_MCP_BIN" ]; then
"mcpServers": { "mcpServers": {
"kei-mcp": { "kei-mcp": {
"command": "$KEI_MCP_BIN", "command": "$KEI_MCP_BIN",
"env": { "GROKCODE": "1" } "env": {}
} }
} }
} }
@ -73,5 +73,5 @@ mv "$tmp" "$CFG"
echo " grok: wired PreToolUse hooks → $CFG" echo " grok: wired PreToolUse hooks → $CFG"
echo " 5 hook entries (Bash×3 + Edit×2 + Write×2)" echo " 5 hook entries (Bash×3 + Edit×2 + Write×2)"
[ -n "$mcp_block" ] && echo " kei-mcp MCP server registered (with GROKCODE=1 guard)" [ -n "$mcp_block" ] && echo " kei-mcp MCP server registered (v0.46: chain always runs, no env-skip)"
echo " Same enforcement as Claude Code." echo " Same enforcement as Claude Code."

147
scripts/kei-prompt.sh Executable file
View file

@ -0,0 +1,147 @@
#!/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 Normal file → Executable file
View file

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

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

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