Compare commits
No commits in common. "main" and "v0.49.0" have entirely different histories.
41 changed files with 1357 additions and 834 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
76
CHANGELOG.md
76
CHANGELOG.md
|
|
@ -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
165
README.md
|
|
@ -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
|
|
||||||
4–8 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.
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"))
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
222
_primitives/_rust/kei-mcp/src/handlers/safe_tools/exec.rs
Normal file
222
_primitives/_rust/kei-mcp/src/handlers/safe_tools/exec.rs
Normal 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 }
|
||||||
99
_primitives/_rust/kei-mcp/src/handlers/safe_tools/mod.rs
Normal file
99
_primitives/_rust/kei-mcp/src/handlers/safe_tools/mod.rs
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
166
_primitives/_rust/kei-mcp/src/handlers/safe_tools/path_guard.rs
Normal file
166
_primitives/_rust/kei-mcp/src/handlers/safe_tools/path_guard.rs
Normal 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
19
bin/kei
|
|
@ -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}
|
||||||
|
|
|
||||||
122
bootstrap.sh
122
bootstrap.sh
|
|
@ -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
|
||||||
esac
|
log "OS: WSL2 (Linux inside Windows) — full substrate path available"
|
||||||
|
else
|
||||||
log "OS: $OS"
|
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
|
||||||
|
|
||||||
# --- 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
|
||||||
|
|
|
||||||
|
|
@ -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.42–v0.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
|
||||||
|
|
|
||||||
|
|
@ -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
0
hooks/alignment-check.sh
Executable file → Normal file
0
hooks/chat-numeric-postflag.sh
Executable file → Normal file
0
hooks/chat-numeric-postflag.sh
Executable file → Normal file
0
hooks/chat-numeric-prewarn.sh
Executable file → Normal file
0
hooks/chat-numeric-prewarn.sh
Executable file → Normal file
0
hooks/citation-verify.sh
Executable file → Normal file
0
hooks/citation-verify.sh
Executable file → Normal file
0
hooks/no-downgrade.sh
Executable file → Normal file
0
hooks/no-downgrade.sh
Executable file → Normal file
8
hooks/no-python-without-approval.sh
Executable file → Normal file
8
hooks/no-python-without-approval.sh
Executable file → Normal 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
9
hooks/numeric-claims-guard.sh
Executable file → Normal 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
0
hooks/rust-first.sh
Executable file → Normal file
45
install.sh
45
install.sh
|
|
@ -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
0
install/lib-dev-hub-forgejo-runner.sh
Normal file → Executable file
0
install/lib-dev-hub-forgejo.sh
Normal file → Executable file
0
install/lib-dev-hub-forgejo.sh
Normal file → Executable file
0
install/lib-dev-hub-zoekt.sh
Normal file → Executable file
0
install/lib-dev-hub-zoekt.sh
Normal file → Executable 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 $?
|
||||||
|
|
|
||||||
|
|
@ -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'тся
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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
0
scripts/kei-configure.sh
Normal file → Executable 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
|
||||||
|
|
|
||||||
|
|
@ -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
147
scripts/kei-prompt.sh
Executable 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
0
scripts/keisei-pet-update.sh
Normal file → Executable file
0
scripts/keisei-pet.sh
Normal file → Executable file
0
scripts/keisei-pet.sh
Normal file → Executable file
12
web-install.sh
Executable file → Normal file
12
web-install.sh
Executable file → Normal 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"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue