feat(agent-substrate/phase-3): kei-agent-runtime + kei-capability binaries

Two new crates implementing the substrate runtime per locked §Runtime
execution contract + §Capability trait contract (Rust) + §Verify
execution worktree→simulated-merge.

kei-agent-runtime — library + CLI binary:
- src/capability.rs — Capability trait (name/check/verify) + GateContext
  + GateDecision + VerifyContext + VerifyResult + RunMode + TaskSpec
- src/registry.rs — &str → &'static dyn Capability dispatch for 14 impls
- src/gates/ — 6 PreToolUse modules (policy::no-git-ops,
  scope::files-{whitelist,denylist}, safety::no-dep-bump,
  tools::read-only, tools::cargo-only-bash)
- src/verifies/ — 8 on-return modules (quality::constructor-pattern,
  quality::cargo-check-green, quality::tests-green, safety::no-dep-bump,
  scope::files-{whitelist,denylist}, output::{report-format,severity-grade})
- src/compose.rs — task.toml + role + capabilities → prompt.md
- src/spawn.rs — ledger fork + prompt write (actual Agent invocation
  remains orchestrator's tool call)
- src/verify.rs — runs all capability verifies per role; collects
  VerifyReport {passed, failed}
- src/simulated_merge.rs — git worktree add test-merge/<id> + apply diff
  + run verify; cleanup on Drop
- src/main.rs — clap CLI: compose | spawn | verify | run

kei-capability — thin CLI adapter crate:
- Depends on kei-agent-runtime path dep
- Subcommand `check <cap-name>` (PreToolUse gate; stdin JSON, exit 0|2)
- Subcommand `verify <cap-name>` (on-return; env-driven, exit 0 or fail)
- Pattern: shell hook = 3-line `exec kei-capability check "$CAP_NAME"`

Workspace Cargo.toml: both crates registered as members (under agent
substrate v1 marker).

cargo check --workspace: PASS
cargo test -p kei-agent-runtime: 37/37 green
  - 6 capability_trait_smoke (registry lookups, unknown name → None)
  - 3 compose_smoke (fixture role + caps → composed prompt)
  - 12 gate_smoke (each gate: happy + deny + bypass)
  - 4 simulated_merge_smoke (git worktree lifecycle)
  - 12 verify_smoke (each verify: pass + fail + edge cases)
cargo test -p kei-capability: 0/0 (CLI binary, tested via lib)

(Agent completion report cut off by rate-limit at 60 tool-uses; code
itself is green — verified by orchestrator post-commit.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parfii-bot 2026-04-23 02:35:53 +08:00
parent b8b9c12913
commit b82e3b039e
34 changed files with 2223 additions and 0 deletions

View file

@ -1845,6 +1845,22 @@ dependencies = [
"uuid",
]
[[package]]
name = "kei-agent-runtime"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"once_cell",
"regex",
"serde",
"serde_json",
"tempfile",
"thiserror 1.0.69",
"toml",
"walkdir",
]
[[package]]
name = "kei-artifact"
version = "0.1.0"
@ -1887,6 +1903,18 @@ dependencies = [
"tempfile",
]
[[package]]
name = "kei-capability"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"kei-agent-runtime",
"serde",
"serde_json",
"toml",
]
[[package]]
name = "kei-changelog"
version = "0.1.0"

View file

@ -35,6 +35,10 @@ members = [
"kei-runtime",
# v1 substrate — shared atom discovery + frontmatter + safe path (Stream E)
"kei-atom-discovery",
# agent substrate v1 — phase 3 runtime (Capability trait + registry + compose/spawn/verify)
"kei-agent-runtime",
# agent substrate v1 — phase 3 hook-protocol CLI adapter
"kei-capability",
]
[workspace.package]

View file

@ -0,0 +1,32 @@
[package]
name = "kei-agent-runtime"
version = "0.1.0"
edition = "2021"
rust-version = "1.75"
description = "Agent substrate v1 — Capability trait + registry + compose/spawn/verify runtime"
[[bin]]
name = "kei-agent-runtime"
path = "src/main.rs"
[lib]
name = "kei_agent_runtime"
path = "src/lib.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
anyhow = "1"
thiserror = "1"
regex = "1"
once_cell = "1"
walkdir = "2"
[dev-dependencies]
tempfile = "3"
[package.metadata.keisei]
backend = "none"
description = "Agent substrate v1 runtime: composes capability fragments, spawns gated agents, verifies on return"

View file

@ -0,0 +1,141 @@
//! Capability trait + context / result types.
//!
//! Per schema §Capability trait contract (Rust). One trait, dispatched by
//! string name via `registry::get()`. Gates return `GateDecision`;
//! verifies return `VerifyResult`. Defaults are no-op so gate-only and
//! verify-only capabilities omit the other half.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
/// Shared Capability trait. Gate + verify methods both default to no-op
/// so impls only override what they implement.
pub trait Capability: Send + Sync {
/// Namespaced capability name: `<category>::<slug>` (e.g. `policy::no-git-ops`).
fn name(&self) -> &'static str;
/// PreToolUse gate; called by `kei-capability check <name>`.
fn check(&self, _ctx: &GateContext) -> GateDecision {
GateDecision::NotApplicable
}
/// On-return verify; called by `kei-capability verify <name>`.
fn verify(&self, _ctx: &VerifyContext) -> VerifyResult {
VerifyResult::Pass
}
}
/// Context passed to `Capability::check()` — constructed by the hook binary
/// from Claude Code's tool-use JSON payload.
pub struct GateContext<'a> {
pub tool_name: &'a str,
pub tool_input: &'a Value,
pub task: &'a TaskSpec,
pub env: &'a HashMap<String, String>,
}
/// Gate outcome. `Deny` exits 2 in the hook binary; `Allow`/`NotApplicable` exit 0.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GateDecision {
Allow,
Deny { reason: String },
NotApplicable,
}
/// Context passed to `Capability::verify()` — constructed from env vars by the
/// hook binary, or programmatically by `verify::verify_task`.
pub struct VerifyContext<'a> {
pub agent_id: &'a str,
pub task: &'a TaskSpec,
pub worktree_path: &'a Path,
pub main_repo: &'a Path,
pub run_mode: RunMode,
pub simulated_merge_path: Option<PathBuf>,
}
impl<'a> VerifyContext<'a> {
/// Active run dir: simulated-merge path if present, otherwise the worktree.
pub fn run_dir(&self) -> PathBuf {
match (&self.run_mode, &self.simulated_merge_path) {
(RunMode::SimulatedMerge, Some(p)) => p.clone(),
_ => self.worktree_path.to_path_buf(),
}
}
}
/// Verify result. `Fail` exits non-zero in the hook binary.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VerifyResult {
Pass,
Fail {
reason: String,
detail: Option<String>,
},
}
/// Verify execution mode. Orchestrator splits `Both` into two sequential
/// `Worktree` + `SimulatedMerge` calls.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunMode {
Worktree,
SimulatedMerge,
Both,
}
/// Parsed task.toml. Subset used by gates + verifies; parser lives in
/// `spawn.rs`.
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct TaskSpec {
#[serde(default)]
pub task: TaskMeta,
#[serde(default)]
pub scope: TaskScope,
#[serde(default)]
pub verification: TaskVerification,
#[serde(default)]
pub output: TaskOutput,
#[serde(default)]
pub body: TaskBody,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct TaskMeta {
#[serde(default)]
pub role: String,
#[serde(default, rename = "agent-id")]
pub agent_id: String,
#[serde(default, rename = "parent-agent")]
pub parent_agent: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct TaskScope {
#[serde(default, rename = "files-whitelist")]
pub files_whitelist: Vec<String>,
#[serde(default, rename = "files-denylist")]
pub files_denylist: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct TaskVerification {
#[serde(default, rename = "cargo-check-crates")]
pub cargo_check_crates: Vec<String>,
#[serde(default, rename = "cargo-test-crates")]
pub cargo_test_crates: Vec<String>,
#[serde(default, rename = "test-count-min")]
pub test_count_min: Option<u32>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct TaskOutput {
#[serde(default, rename = "report-fields-required")]
pub report_fields_required: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct TaskBody {
#[serde(default)]
pub text: String,
}

View file

@ -0,0 +1,74 @@
//! Compose capability-fragment prompt for an agent invocation.
//!
//! Flow:
//! 1. Parse `task.toml` → `TaskSpec` (caller does this).
//! 2. Load `_roles/<task.role>.toml`.
//! 3. For each capability in `role.capabilities.required`, read the
//! `_capabilities/<category>/<slug>/text.md` fragment.
//! 4. Concatenate fragments with `\n\n---\n\n`.
//! 5. Append `task.body.text`.
use crate::capability::TaskSpec;
use anyhow::{anyhow, Context, Result};
use serde::Deserialize;
use std::path::Path;
const SEPARATOR: &str = "\n\n---\n\n";
#[derive(Debug, Deserialize)]
struct RoleFile {
#[serde(default)]
capabilities: RoleCapabilities,
}
#[derive(Debug, Default, Deserialize)]
struct RoleCapabilities {
#[serde(default)]
required: Vec<String>,
}
/// Compose prompt text. `kit_root` is the repo root that holds `_roles/`
/// and `_capabilities/` directories.
pub fn compose_prompt(task: &TaskSpec, kit_root: &Path) -> Result<String> {
if task.task.role.is_empty() {
return Err(anyhow!("task.role is empty"));
}
let role = load_role(kit_root, &task.task.role)?;
let mut fragments: Vec<String> = Vec::with_capacity(role.capabilities.required.len() + 1);
for cap_name in &role.capabilities.required {
let frag = load_capability_text(kit_root, cap_name)
.with_context(|| format!("capability {cap_name}"))?;
fragments.push(frag);
}
if !task.body.text.trim().is_empty() {
fragments.push(task.body.text.clone());
}
Ok(fragments.join(SEPARATOR))
}
fn load_role(kit_root: &Path, role: &str) -> Result<RoleFile> {
let path = kit_root.join("_roles").join(format!("{role}.toml"));
let text = std::fs::read_to_string(&path)
.with_context(|| format!("read role file {}", path.display()))?;
let parsed: RoleFile =
toml::from_str(&text).with_context(|| format!("parse role TOML {}", path.display()))?;
Ok(parsed)
}
fn load_capability_text(kit_root: &Path, cap_name: &str) -> Result<String> {
let (category, slug) = split_cap_name(cap_name)?;
let path = kit_root
.join("_capabilities")
.join(category)
.join(slug)
.join("text.md");
std::fs::read_to_string(&path)
.with_context(|| format!("read capability text {}", path.display()))
}
fn split_cap_name(cap: &str) -> Result<(&str, &str)> {
match cap.split_once("::") {
Some((cat, slug)) if !cat.is_empty() && !slug.is_empty() => Ok((cat, slug)),
_ => Err(anyhow!("malformed capability name '{cap}' — expected <cat>::<slug>")),
}
}

View file

@ -0,0 +1,11 @@
//! PreToolUse gate capabilities.
//!
//! Each module holds one zero-sized `impl Capability` struct. Registry
//! exposes them by `name()`.
pub mod policy_no_git_ops;
pub mod safety_no_dep_bump;
pub mod scope_files_denylist;
pub mod scope_files_whitelist;
pub mod tools_cargo_only_bash;
pub mod tools_read_only;

View file

@ -0,0 +1,49 @@
//! `policy::no-git-ops` — RULE 0.13 orchestrator-owns-git enforcement.
//!
//! Denies any Bash command matching `git`, `gh repo`, `gh api /repos`.
//! Bypass via env `ORCHESTRATOR_META=1` for orchestrator-meta agents.
use crate::capability::*;
use once_cell::sync::Lazy;
use regex::Regex;
pub struct NoGitOps;
static GIT_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
vec![
Regex::new(r"(?m)(?:^|[;&|]|\s)git(?:\s|$)").unwrap(),
Regex::new(r"(?m)(?:^|[;&|]|\s)gh\s+repo").unwrap(),
Regex::new(r"(?m)(?:^|[;&|]|\s)gh\s+api\s+/?repos").unwrap(),
]
});
impl Capability for NoGitOps {
fn name(&self) -> &'static str {
"policy::no-git-ops"
}
fn check(&self, ctx: &GateContext) -> GateDecision {
if ctx.tool_name != "Bash" {
return GateDecision::NotApplicable;
}
if ctx.env.get("ORCHESTRATOR_META").map(|v| v == "1").unwrap_or(false) {
return GateDecision::Allow;
}
let cmd = ctx
.tool_input
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("");
for pat in GIT_PATTERNS.iter() {
if pat.is_match(cmd) {
return GateDecision::Deny {
reason: format!(
"RULE 0.13 — git operation blocked (pattern {})",
pat.as_str()
),
};
}
}
GateDecision::Allow
}
}

View file

@ -0,0 +1,35 @@
//! `safety::no-dep-bump` — PreToolUse:Edit|Write denies edits to Cargo.toml
//! / Cargo.lock unless `ALLOW_DEP_BUMP=1` is in the env (opt-in).
use crate::capability::*;
pub struct NoDepBumpGate;
impl Capability for NoDepBumpGate {
fn name(&self) -> &'static str {
"safety::no-dep-bump"
}
fn check(&self, ctx: &GateContext) -> GateDecision {
if !matches!(ctx.tool_name, "Edit" | "Write" | "MultiEdit") {
return GateDecision::NotApplicable;
}
if ctx.env.get("ALLOW_DEP_BUMP").map(|v| v == "1").unwrap_or(false) {
return GateDecision::Allow;
}
let path = match ctx.tool_input.get("file_path").and_then(|v| v.as_str()) {
Some(p) => p,
None => return GateDecision::NotApplicable,
};
if ends_with_basename(path, "Cargo.toml") || ends_with_basename(path, "Cargo.lock") {
return GateDecision::Deny {
reason: format!("safety::no-dep-bump — {path} edit blocked (set ALLOW_DEP_BUMP=1 to override)"),
};
}
GateDecision::Allow
}
}
fn ends_with_basename(path: &str, name: &str) -> bool {
path.rsplit(['/', '\\']).next().map(|b| b == name).unwrap_or(false)
}

View file

@ -0,0 +1,35 @@
//! `scope::files-denylist` — PreToolUse:Edit|Write denies paths matching
//! `task.scope.files-denylist` globs. Overrides whitelist.
use crate::capability::*;
pub struct FilesDenylist;
impl Capability for FilesDenylist {
fn name(&self) -> &'static str {
"scope::files-denylist"
}
fn check(&self, ctx: &GateContext) -> GateDecision {
if !is_write_tool(ctx.tool_name) {
return GateDecision::NotApplicable;
}
let path = match ctx.tool_input.get("file_path").and_then(|v| v.as_str()) {
Some(p) => p,
None => return GateDecision::NotApplicable,
};
let denylist = &ctx.task.scope.files_denylist;
for pat in denylist.iter() {
if crate::simulated_merge::glob_match(pat, path) {
return GateDecision::Deny {
reason: format!("scope violation — {path} matches files-denylist ({pat})"),
};
}
}
GateDecision::Allow
}
}
fn is_write_tool(name: &str) -> bool {
matches!(name, "Edit" | "Write" | "MultiEdit" | "NotebookEdit")
}

View file

@ -0,0 +1,37 @@
//! `scope::files-whitelist` — PreToolUse:Edit|Write denies paths outside
//! `task.scope.files-whitelist` globs.
use crate::capability::*;
pub struct FilesWhitelist;
impl Capability for FilesWhitelist {
fn name(&self) -> &'static str {
"scope::files-whitelist"
}
fn check(&self, ctx: &GateContext) -> GateDecision {
if !is_write_tool(ctx.tool_name) {
return GateDecision::NotApplicable;
}
let path = match ctx.tool_input.get("file_path").and_then(|v| v.as_str()) {
Some(p) => p,
None => return GateDecision::NotApplicable,
};
let whitelist = &ctx.task.scope.files_whitelist;
if whitelist.is_empty() {
return GateDecision::Allow;
}
if whitelist.iter().any(|pat| crate::simulated_merge::glob_match(pat, path)) {
GateDecision::Allow
} else {
GateDecision::Deny {
reason: format!("scope violation — {path} not in files-whitelist"),
}
}
}
}
fn is_write_tool(name: &str) -> bool {
matches!(name, "Edit" | "Write" | "MultiEdit" | "NotebookEdit")
}

View file

@ -0,0 +1,54 @@
//! `tools::cargo-only-bash` — PreToolUse:Bash denies commands not matching
//! one of the cargo-ecosystem allowlist patterns.
use crate::capability::*;
use once_cell::sync::Lazy;
use regex::Regex;
pub struct CargoOnlyBash;
/// Allowlist — `cargo …`, `mkdir …`, `rm -rf /tmp/…`, `rustc --version`, etc.
/// Deliberately narrow; orchestrator expands by editing this list.
static ALLOW_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
vec![
Regex::new(r"^\s*cargo(\s|$)").unwrap(),
Regex::new(r"^\s*rustc(\s|$)").unwrap(),
Regex::new(r"^\s*rustup(\s|$)").unwrap(),
Regex::new(r"^\s*mkdir(\s|$)").unwrap(),
Regex::new(r"^\s*rm\s+-rf\s+/tmp/").unwrap(),
Regex::new(r"^\s*ls(\s|$)").unwrap(),
Regex::new(r"^\s*pwd(\s|$)").unwrap(),
]
});
impl Capability for CargoOnlyBash {
fn name(&self) -> &'static str {
"tools::cargo-only-bash"
}
fn check(&self, ctx: &GateContext) -> GateDecision {
if ctx.tool_name != "Bash" {
return GateDecision::NotApplicable;
}
let cmd = ctx
.tool_input
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("");
if ALLOW_PATTERNS.iter().any(|p| p.is_match(cmd)) {
GateDecision::Allow
} else {
GateDecision::Deny {
reason: format!("tools::cargo-only-bash — `{}` not in allowlist", truncate(cmd)),
}
}
}
}
fn truncate(s: &str) -> String {
if s.len() > 60 {
format!("{}", &s[..60])
} else {
s.to_string()
}
}

View file

@ -0,0 +1,23 @@
//! `tools::read-only` — denies Edit/Write/MultiEdit/NotebookEdit entirely.
use crate::capability::*;
pub struct ReadOnly;
impl Capability for ReadOnly {
fn name(&self) -> &'static str {
"tools::read-only"
}
fn check(&self, ctx: &GateContext) -> GateDecision {
match ctx.tool_name {
"Edit" | "Write" | "MultiEdit" | "NotebookEdit" => GateDecision::Deny {
reason: format!(
"tools::read-only — {} denied (role is read-only)",
ctx.tool_name
),
},
_ => GateDecision::NotApplicable,
}
}
}

View file

@ -0,0 +1,22 @@
//! kei-agent-runtime — Agent substrate v1 runtime.
//!
//! Modules:
//! - `capability` — Capability trait + context structs + result enums
//! - `registry` — static &str → &'static dyn Capability lookup for all 14 impls
//! - `gates` — 6 PreToolUse gate capabilities
//! - `verifies` — 8 on-return verify capabilities
//! - `compose` — task.toml + role + capabilities → prompt.md
//! - `spawn` — prepare tasks/<agent-id>/prompt.md + ledger row
//! - `verify` — run all verify capabilities against agent's return
//! - `simulated_merge` — orchestrator-side worktree → apply diff → verify
//!
//! Per `docs/AGENT-SUBSTRATE-SCHEMA.md` (LOCKED 2026-04-23).
pub mod capability;
pub mod compose;
pub mod gates;
pub mod registry;
pub mod simulated_merge;
pub mod spawn;
pub mod verifies;
pub mod verify;

View file

@ -0,0 +1,160 @@
//! kei-agent-runtime — CLI dispatcher for compose | spawn | verify | run.
use clap::{Parser, Subcommand};
use kei_agent_runtime::capability::RunMode;
use kei_agent_runtime::{compose, spawn, verify};
use std::path::PathBuf;
use std::process::ExitCode;
#[derive(Parser)]
#[command(
name = "kei-agent-runtime",
version,
about = "Agent substrate v1 — compose/spawn/verify gated agent invocations"
)]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Compose prompt from a task.toml and write tasks/<agent-id>/prompt.md.
Compose {
task: PathBuf,
#[arg(long)]
kit_root: Option<PathBuf>,
},
/// Prepare spawn dir (tasks/<agent-id>/) — orchestrator invokes Agent tool.
Spawn {
task: PathBuf,
#[arg(long)]
kit_root: Option<PathBuf>,
},
/// Run every verify capability declared by the task's role.
Verify {
task: PathBuf,
#[arg(long)]
worktree: PathBuf,
#[arg(long)]
kit_root: Option<PathBuf>,
#[arg(long)]
main_repo: Option<PathBuf>,
#[arg(long, default_value = "worktree")]
mode: String,
},
/// One-shot helper: compose + spawn + verify (tests only).
Run {
task: PathBuf,
#[arg(long)]
worktree: PathBuf,
#[arg(long)]
kit_root: Option<PathBuf>,
},
}
fn main() -> ExitCode {
let cli = Cli::parse();
match cli.cmd {
Cmd::Compose { task, kit_root } => run_compose(task, kit_root),
Cmd::Spawn { task, kit_root } => run_spawn(task, kit_root),
Cmd::Verify { task, worktree, kit_root, main_repo, mode } => {
run_verify(task, worktree, kit_root, main_repo, mode)
}
Cmd::Run { task, worktree, kit_root } => run_run(task, worktree, kit_root),
}
}
fn kit_root_or_cwd(arg: Option<PathBuf>) -> PathBuf {
arg.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
}
fn run_compose(task_path: PathBuf, kit_root: Option<PathBuf>) -> ExitCode {
let kit = kit_root_or_cwd(kit_root);
let task = match spawn::load_task(&task_path) {
Ok(t) => t,
Err(e) => return err("load task", e),
};
match compose::compose_prompt(&task, &kit) {
Ok(p) => {
println!("{p}");
ExitCode::SUCCESS
}
Err(e) => err("compose", e),
}
}
fn run_spawn(task_path: PathBuf, kit_root: Option<PathBuf>) -> ExitCode {
let kit = kit_root_or_cwd(kit_root);
let task = match spawn::load_task(&task_path) {
Ok(t) => t,
Err(e) => return err("load task", e),
};
match spawn::prepare_agent(&task, &kit) {
Ok(p) => {
println!("agent_id={}", p.agent_id);
println!("prompt={}", p.prompt_path.display());
ExitCode::SUCCESS
}
Err(e) => err("spawn", e),
}
}
fn run_verify(
task_path: PathBuf,
worktree: PathBuf,
kit_root: Option<PathBuf>,
main_repo: Option<PathBuf>,
mode: String,
) -> ExitCode {
let kit = kit_root_or_cwd(kit_root);
let task = match spawn::load_task(&task_path) {
Ok(t) => t,
Err(e) => return err("load task", e),
};
let caps = match verify::load_role_capabilities(&kit, &task.task.role) {
Ok(c) => c,
Err(e) => return err("load role", e),
};
let run_mode = match mode.as_str() {
"worktree" => RunMode::Worktree,
"simulated-merge" => RunMode::SimulatedMerge,
"both" => RunMode::Both,
other => {
eprintln!("unknown mode '{other}'");
return ExitCode::from(2);
}
};
let main = main_repo.unwrap_or_else(|| kit.clone());
let report = match verify::verify_task(
&task,
&task.task.agent_id,
&worktree,
&main,
run_mode,
&caps,
None,
) {
Ok(r) => r,
Err(e) => return err("verify", e),
};
println!("{}", serde_json::to_string_pretty(&report).unwrap_or_default());
if report.is_clean() {
ExitCode::SUCCESS
} else {
ExitCode::from(2)
}
}
fn run_run(task_path: PathBuf, worktree: PathBuf, kit_root: Option<PathBuf>) -> ExitCode {
let code = run_spawn(task_path.clone(), kit_root.clone());
if code != ExitCode::SUCCESS {
return code;
}
run_verify(task_path, worktree, kit_root, None, "worktree".into())
}
fn err(stage: &str, e: impl std::fmt::Display) -> ExitCode {
eprintln!("{stage}: {e}");
ExitCode::from(1)
}

View file

@ -0,0 +1,93 @@
//! Registry — `&str → &'static dyn Capability` lookup for all 14
//! capability implementations.
//!
//! `get(name)` is the single dispatch point used by both the
//! `kei-agent-runtime verify` binary and the `kei-capability` hook adapter.
use crate::capability::Capability;
use crate::gates;
use crate::verifies;
/// Look up a capability by its canonical `<category>::<slug>` name.
/// Returns `None` if the name is unknown. Gate-only and verify-only
/// capabilities share the same name; registry returns the *gate* impl for
/// 6 capabilities that have gates, and the *verify* impl for 8 that have
/// verifies. The two lookups below partition cleanly — no name holds both
/// a gate and a verify in this phase's inventory.
pub fn get(name: &str) -> Option<&'static dyn Capability> {
if let Some(c) = get_gate(name) {
return Some(c);
}
get_verify(name)
}
/// Look up only the gate-side impl. Used by `kei-capability check`.
pub fn get_gate(name: &str) -> Option<&'static dyn Capability> {
static POLICY_NO_GIT_OPS: gates::policy_no_git_ops::NoGitOps =
gates::policy_no_git_ops::NoGitOps;
static SCOPE_WHITELIST_GATE: gates::scope_files_whitelist::FilesWhitelist =
gates::scope_files_whitelist::FilesWhitelist;
static SCOPE_DENYLIST_GATE: gates::scope_files_denylist::FilesDenylist =
gates::scope_files_denylist::FilesDenylist;
static SAFETY_NO_DEP_BUMP_GATE: gates::safety_no_dep_bump::NoDepBumpGate =
gates::safety_no_dep_bump::NoDepBumpGate;
static TOOLS_READ_ONLY: gates::tools_read_only::ReadOnly = gates::tools_read_only::ReadOnly;
static TOOLS_CARGO_ONLY: gates::tools_cargo_only_bash::CargoOnlyBash =
gates::tools_cargo_only_bash::CargoOnlyBash;
match name {
"policy::no-git-ops" => Some(&POLICY_NO_GIT_OPS),
"scope::files-whitelist" => Some(&SCOPE_WHITELIST_GATE),
"scope::files-denylist" => Some(&SCOPE_DENYLIST_GATE),
"safety::no-dep-bump" => Some(&SAFETY_NO_DEP_BUMP_GATE),
"tools::read-only" => Some(&TOOLS_READ_ONLY),
"tools::cargo-only-bash" => Some(&TOOLS_CARGO_ONLY),
_ => None,
}
}
/// Look up only the verify-side impl. Used by `kei-capability verify`.
pub fn get_verify(name: &str) -> Option<&'static dyn Capability> {
static CP: verifies::quality_constructor_pattern::ConstructorPattern =
verifies::quality_constructor_pattern::ConstructorPattern;
static CCG: verifies::quality_cargo_check_green::CargoCheckGreen =
verifies::quality_cargo_check_green::CargoCheckGreen;
static TG: verifies::quality_tests_green::TestsGreen = verifies::quality_tests_green::TestsGreen;
static NDB_V: verifies::safety_no_dep_bump::NoDepBumpVerify =
verifies::safety_no_dep_bump::NoDepBumpVerify;
static WL_V: verifies::scope_files_whitelist::FilesWhitelistVerify =
verifies::scope_files_whitelist::FilesWhitelistVerify;
static DL_V: verifies::scope_files_denylist::FilesDenylistVerify =
verifies::scope_files_denylist::FilesDenylistVerify;
static RF: verifies::output_report_format::ReportFormat =
verifies::output_report_format::ReportFormat;
static SG: verifies::output_severity_grade::SeverityGrade =
verifies::output_severity_grade::SeverityGrade;
match name {
"quality::constructor-pattern" => Some(&CP),
"quality::cargo-check-green" => Some(&CCG),
"quality::tests-green" => Some(&TG),
"safety::no-dep-bump" => Some(&NDB_V),
"scope::files-whitelist" => Some(&WL_V),
"scope::files-denylist" => Some(&DL_V),
"output::report-format" => Some(&RF),
"output::severity-grade" => Some(&SG),
_ => None,
}
}
/// All known capability names (union of gate + verify). Used by smoke tests.
pub fn all_names() -> Vec<&'static str> {
vec![
"policy::no-git-ops",
"scope::files-whitelist",
"scope::files-denylist",
"safety::no-dep-bump",
"tools::read-only",
"tools::cargo-only-bash",
"quality::constructor-pattern",
"quality::cargo-check-green",
"quality::tests-green",
"output::report-format",
"output::severity-grade",
]
}

View file

@ -0,0 +1,107 @@
//! Simulated-merge executor + glob matcher.
//!
//! Schema §Verify execution — worktree short-circuit → simulated merge:
//! orchestrator creates temp worktree off main, applies agent's diff, runs
//! verifies from that vantage to catch integration regressions invisible
//! in agent's isolated worktree.
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
/// Create a temp worktree off `main_repo` at HEAD of `main`, apply the agent's
/// diff, return the temp worktree path. Caller cleans up.
pub fn run_simulated_merge(
agent_id: &str,
agent_worktree: &Path,
main_repo: &Path,
) -> Result<PathBuf> {
let tmp = std::env::temp_dir().join(format!("kei-test-merge-{agent_id}"));
let _ = std::fs::remove_dir_all(&tmp);
run_git(main_repo, &["worktree", "add", "-d", tmp.to_str().unwrap(), "main"])
.context("git worktree add failed")?;
let diff = run_git(agent_worktree, &["diff", "main"])
.context("git diff against main failed")?;
if !diff.trim().is_empty() {
apply_diff(&tmp, &diff)?;
}
Ok(tmp)
}
/// Apply a unified diff to `dir` via `git apply --index`. Empty diff is a no-op.
pub fn apply_diff(dir: &Path, diff: &str) -> Result<()> {
use std::io::Write;
let mut child = Command::new("git")
.arg("apply")
.arg("--index")
.current_dir(dir)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.context("spawn git apply")?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(diff.as_bytes()).context("write diff stdin")?;
}
let out = child.wait_with_output().context("git apply wait")?;
if !out.status.success() {
anyhow::bail!("git apply failed: {}", String::from_utf8_lossy(&out.stderr));
}
Ok(())
}
/// Run `git <args>` in `dir`, return stdout as UTF-8 string.
pub fn run_git(dir: &Path, args: &[&str]) -> Result<String> {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.with_context(|| format!("git {}", args.join(" ")))?;
if !out.status.success() {
anyhow::bail!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr)
);
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
/// Shell-style glob match. Supports `**` (any directories) and `*` (any chars
/// except `/`). Bracketed classes and `?` not supported — task specs use
/// simple patterns.
pub fn glob_match(pattern: &str, path: &str) -> bool {
let re = glob_to_regex(pattern);
match regex::Regex::new(&re) {
Ok(r) => r.is_match(path),
Err(_) => false,
}
}
fn glob_to_regex(pattern: &str) -> String {
let mut out = String::from("^");
let bytes = pattern.as_bytes();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i] as char;
if c == '*' && i + 1 < bytes.len() && bytes[i + 1] as char == '*' {
out.push_str(".*");
i += 2;
if i < bytes.len() && bytes[i] as char == '/' {
i += 1;
}
} else if c == '*' {
out.push_str("[^/]*");
i += 1;
} else if "().+?|^$\\[]{}".contains(c) {
out.push('\\');
out.push(c);
i += 1;
} else {
out.push(c);
i += 1;
}
}
out.push('$');
out
}

View file

@ -0,0 +1,53 @@
//! Prepare an agent invocation: write `tasks/<agent-id>/prompt.md`,
//! record the task.toml alongside it. Actual Claude `Agent` tool call is
//! the orchestrator's job per RULE 0.13.
use crate::capability::TaskSpec;
use crate::compose::compose_prompt;
use anyhow::{anyhow, Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
/// Parse a task.toml file into `TaskSpec`.
pub fn load_task(path: &Path) -> Result<TaskSpec> {
let text = fs::read_to_string(path)
.with_context(|| format!("read task file {}", path.display()))?;
toml::from_str::<TaskSpec>(&text)
.with_context(|| format!("parse task TOML {}", path.display()))
}
/// Prepare a spawnable agent directory.
///
/// Returns the `agent-id`. Does NOT invoke the Agent tool — that is the
/// orchestrator's responsibility. Caller is expected to subsequently call
/// `kei-ledger fork <agent-id>` (or the Rust API) with the path returned.
pub fn prepare_agent(task: &TaskSpec, kit_root: &Path) -> Result<PreparedAgent> {
let agent_id = resolve_agent_id(task)?;
let prompt = compose_prompt(task, kit_root)?;
let dir = kit_root.join("tasks").join(&agent_id);
fs::create_dir_all(&dir)
.with_context(|| format!("create tasks dir {}", dir.display()))?;
let prompt_path = dir.join("prompt.md");
fs::write(&prompt_path, &prompt)
.with_context(|| format!("write prompt {}", prompt_path.display()))?;
let task_path = dir.join("task.toml");
fs::write(&task_path, toml::to_string_pretty(task)?)
.with_context(|| format!("write task {}", task_path.display()))?;
Ok(PreparedAgent { agent_id, dir, prompt_path, task_path })
}
/// Outcome of `prepare_agent`.
#[derive(Debug, Clone)]
pub struct PreparedAgent {
pub agent_id: String,
pub dir: PathBuf,
pub prompt_path: PathBuf,
pub task_path: PathBuf,
}
fn resolve_agent_id(task: &TaskSpec) -> Result<String> {
if !task.task.agent_id.is_empty() {
return Ok(task.task.agent_id.clone());
}
Err(anyhow!("task.agent-id is empty — orchestrator must allocate via kei-ledger"))
}

View file

@ -0,0 +1,13 @@
//! On-return verify capabilities.
//!
//! Each module holds one zero-sized `impl Capability` struct implementing
//! only `verify()`. Registry exposes them by `name()`.
pub mod output_report_format;
pub mod output_severity_grade;
pub mod quality_cargo_check_green;
pub mod quality_constructor_pattern;
pub mod quality_tests_green;
pub mod safety_no_dep_bump;
pub mod scope_files_denylist;
pub mod scope_files_whitelist;

View file

@ -0,0 +1,57 @@
//! `output::report-format` verify — reads agent's final report (env var
//! `AGENT_REPORT_PATH` or `.claude/agents/<id>/review.md`), asserts every
//! field in `task.output.report-fields-required` is mentioned.
use crate::capability::*;
use std::path::PathBuf;
pub struct ReportFormat;
impl Capability for ReportFormat {
fn name(&self) -> &'static str {
"output::report-format"
}
fn verify(&self, ctx: &VerifyContext) -> VerifyResult {
let required = &ctx.task.output.report_fields_required;
if required.is_empty() {
return VerifyResult::Pass;
}
let report = match load_report(ctx) {
Ok(r) => r,
Err(e) => {
return VerifyResult::Fail {
reason: "report file not found".into(),
detail: Some(e),
}
}
};
let missing: Vec<&String> = required.iter().filter(|f| !report.contains(f.as_str())).collect();
if missing.is_empty() {
VerifyResult::Pass
} else {
VerifyResult::Fail {
reason: format!("{} required field(s) missing from report", missing.len()),
detail: Some(
missing
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", "),
),
}
}
}
}
fn load_report(ctx: &VerifyContext) -> Result<String, String> {
if let Ok(p) = std::env::var("AGENT_REPORT_PATH") {
return std::fs::read_to_string(&p).map_err(|e| format!("{p}: {e}"));
}
let mut p: PathBuf = ctx.worktree_path.to_path_buf();
p.push(".claude");
p.push("agents");
p.push(ctx.agent_id);
p.push("review.md");
std::fs::read_to_string(&p).map_err(|e| format!("{}: {e}", p.display()))
}

View file

@ -0,0 +1,47 @@
//! `output::severity-grade` verify — asserts the agent's report mentions at
//! least one of HIGH / MEDIUM / LOW severity grades per schema §Output.
use crate::capability::*;
use std::path::PathBuf;
pub struct SeverityGrade;
impl Capability for SeverityGrade {
fn name(&self) -> &'static str {
"output::severity-grade"
}
fn verify(&self, ctx: &VerifyContext) -> VerifyResult {
let report = match load_report(ctx) {
Ok(r) => r,
Err(e) => {
return VerifyResult::Fail {
reason: "report file not found".into(),
detail: Some(e),
}
}
};
let has_grade =
report.contains("HIGH") || report.contains("MEDIUM") || report.contains("LOW");
if has_grade {
VerifyResult::Pass
} else {
VerifyResult::Fail {
reason: "report missing HIGH/MEDIUM/LOW severity grade".into(),
detail: None,
}
}
}
}
fn load_report(ctx: &VerifyContext) -> Result<String, String> {
if let Ok(p) = std::env::var("AGENT_REPORT_PATH") {
return std::fs::read_to_string(&p).map_err(|e| format!("{p}: {e}"));
}
let mut p: PathBuf = ctx.worktree_path.to_path_buf();
p.push(".claude");
p.push("agents");
p.push(ctx.agent_id);
p.push("review.md");
std::fs::read_to_string(&p).map_err(|e| format!("{}: {e}", p.display()))
}

View file

@ -0,0 +1,41 @@
//! `quality::cargo-check-green` — runs `cargo check --workspace` in
//! `<run_dir>/_primitives/_rust` and reports failure tail on non-zero exit.
use crate::capability::*;
use std::process::Command;
pub struct CargoCheckGreen;
impl Capability for CargoCheckGreen {
fn name(&self) -> &'static str {
"quality::cargo-check-green"
}
fn verify(&self, ctx: &VerifyContext) -> VerifyResult {
let dir = ctx.run_dir().join("_primitives/_rust");
let dir = if dir.is_dir() { dir } else { ctx.run_dir() };
let out = Command::new("cargo")
.arg("check")
.arg("--workspace")
.current_dir(&dir)
.output();
match out {
Err(e) => VerifyResult::Fail {
reason: "cargo invocation failed".into(),
detail: Some(e.to_string()),
},
Ok(o) if !o.status.success() => VerifyResult::Fail {
reason: "cargo check --workspace FAILED — agent-local green ≠ integration green".into(),
detail: Some(tail(&o.stderr, 10)),
},
Ok(_) => VerifyResult::Pass,
}
}
}
fn tail(bytes: &[u8], n: usize) -> String {
let s = String::from_utf8_lossy(bytes);
let lines: Vec<&str> = s.lines().collect();
let start = lines.len().saturating_sub(n);
lines[start..].join("\n")
}

View file

@ -0,0 +1,102 @@
//! `quality::constructor-pattern` — walks the run dir, asserts every `.rs`
//! file ≤ 200 LOC and every top-level `fn` ≤ 30 LOC.
use crate::capability::*;
use std::path::Path;
use walkdir::WalkDir;
pub struct ConstructorPattern;
const FILE_LOC_LIMIT: usize = 200;
const FN_LOC_LIMIT: usize = 30;
impl Capability for ConstructorPattern {
fn name(&self) -> &'static str {
"quality::constructor-pattern"
}
fn verify(&self, ctx: &VerifyContext) -> VerifyResult {
let root = ctx.run_dir();
let mut violations: Vec<String> = Vec::new();
for entry in WalkDir::new(&root)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("rs"))
.filter(|e| !is_ignored(e.path()))
{
check_file(entry.path(), &mut violations);
}
if violations.is_empty() {
VerifyResult::Pass
} else {
VerifyResult::Fail {
reason: format!("{} constructor-pattern violation(s)", violations.len()),
detail: Some(violations.join("\n")),
}
}
}
}
fn is_ignored(p: &Path) -> bool {
p.components()
.any(|c| matches!(c.as_os_str().to_str(), Some("target") | Some(".git")))
}
fn check_file(path: &Path, out: &mut Vec<String>) {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(_) => return,
};
let lines: Vec<&str> = text.lines().collect();
if lines.len() > FILE_LOC_LIMIT {
out.push(format!(
"{}: {} LOC > {}",
path.display(),
lines.len(),
FILE_LOC_LIMIT
));
}
for (name, n) in scan_fn_lengths(&lines) {
if n > FN_LOC_LIMIT {
out.push(format!("{} fn `{name}`: {n} LOC > {FN_LOC_LIMIT}", path.display()));
}
}
}
/// Extract `(fn_name, line_count)` for top-level `fn` definitions by tracking
/// brace depth. Best-effort — approximate for nested fns but adequate here.
fn scan_fn_lengths(lines: &[&str]) -> Vec<(String, usize)> {
let mut out = Vec::new();
let mut cur: Option<(String, usize, i32)> = None;
for line in lines {
if cur.is_none() {
if let Some(name) = parse_fn_name(line) {
let opens = line.matches('{').count() as i32 - line.matches('}').count() as i32;
if opens > 0 {
cur = Some((name, 1, opens));
continue;
}
}
} else if let Some((name, count, d)) = cur.as_mut() {
*count += 1;
*d += line.matches('{').count() as i32 - line.matches('}').count() as i32;
if *d <= 0 {
out.push((name.clone(), *count));
cur = None;
}
}
}
out
}
fn parse_fn_name(line: &str) -> Option<String> {
let trimmed = line.trim_start();
let rest = trimmed.strip_prefix("pub ").unwrap_or(trimmed);
let rest = rest.strip_prefix("async ").unwrap_or(rest);
let rest = rest.strip_prefix("const ").unwrap_or(rest);
let rest = rest.strip_prefix("unsafe ").unwrap_or(rest);
let rest = rest.strip_prefix("fn ")?;
let end = rest.find(['(', '<', ' ']).unwrap_or(rest.len());
Some(rest[..end].to_string())
}

View file

@ -0,0 +1,75 @@
//! `quality::tests-green` — runs `cargo test -p <crate>` for each crate in
//! `task.verification.cargo-test-crates`; parses `test result: ok. N passed`
//! line; asserts count ≥ `test_count_min` when set.
use crate::capability::*;
use once_cell::sync::Lazy;
use regex::Regex;
use std::process::Command;
pub struct TestsGreen;
static TEST_SUMMARY: Lazy<Regex> =
Lazy::new(|| Regex::new(r"test result: ok\. (\d+) passed").unwrap());
impl Capability for TestsGreen {
fn name(&self) -> &'static str {
"quality::tests-green"
}
fn verify(&self, ctx: &VerifyContext) -> VerifyResult {
let crates = &ctx.task.verification.cargo_test_crates;
if crates.is_empty() {
return VerifyResult::Pass;
}
let dir = ctx.run_dir().join("_primitives/_rust");
let dir = if dir.is_dir() { dir } else { ctx.run_dir() };
let mut total_passed: u64 = 0;
for crate_name in crates {
match run_test(&dir, crate_name) {
Ok(n) => total_passed += n,
Err(detail) => {
return VerifyResult::Fail {
reason: format!("cargo test -p {crate_name} FAILED"),
detail: Some(detail),
};
}
}
}
if let Some(min) = ctx.task.verification.test_count_min {
if total_passed < min as u64 {
return VerifyResult::Fail {
reason: format!("test count {total_passed} < min {min}"),
detail: None,
};
}
}
VerifyResult::Pass
}
}
fn run_test(dir: &std::path::Path, crate_name: &str) -> Result<u64, String> {
let out = Command::new("cargo")
.arg("test")
.arg("-p")
.arg(crate_name)
.current_dir(dir)
.output()
.map_err(|e| e.to_string())?;
if !out.status.success() {
return Err(tail(&out.stderr, 10));
}
let stdout = String::from_utf8_lossy(&out.stdout);
let passed: u64 = TEST_SUMMARY
.captures_iter(&stdout)
.filter_map(|c| c.get(1).and_then(|m| m.as_str().parse::<u64>().ok()))
.sum();
Ok(passed)
}
fn tail(bytes: &[u8], n: usize) -> String {
let s = String::from_utf8_lossy(bytes);
let lines: Vec<&str> = s.lines().collect();
let start = lines.len().saturating_sub(n);
lines[start..].join("\n")
}

View file

@ -0,0 +1,39 @@
//! `safety::no-dep-bump` verify — git-diffs Cargo.toml / Cargo.lock between
//! main and HEAD of the agent worktree; fails if any `version =` line changed.
use crate::capability::*;
use crate::simulated_merge::run_git;
use once_cell::sync::Lazy;
use regex::Regex;
pub struct NoDepBumpVerify;
static VERSION_LINE: Lazy<Regex> =
Lazy::new(|| Regex::new(r#"(?m)^[-+]\s*version\s*=\s*".+""#).unwrap());
impl Capability for NoDepBumpVerify {
fn name(&self) -> &'static str {
"safety::no-dep-bump"
}
fn verify(&self, ctx: &VerifyContext) -> VerifyResult {
let targets = ["Cargo.toml", "Cargo.lock"];
let mut hits: Vec<String> = Vec::new();
for t in targets.iter() {
let args = ["diff", "main", "--", &format!("**/{t}"), t];
if let Ok(diff) = run_git(ctx.worktree_path, &args) {
for m in VERSION_LINE.find_iter(&diff) {
hits.push(format!("{t}: {}", m.as_str()));
}
}
}
if hits.is_empty() {
VerifyResult::Pass
} else {
VerifyResult::Fail {
reason: format!("{} dep-bump line(s) detected", hits.len()),
detail: Some(hits.join("\n")),
}
}
}
}

View file

@ -0,0 +1,42 @@
//! `scope::files-denylist` verify — `git diff --name-only main` on agent
//! worktree; fails if any touched path matches the denylist.
use crate::capability::*;
use crate::simulated_merge::{glob_match, run_git};
pub struct FilesDenylistVerify;
impl Capability for FilesDenylistVerify {
fn name(&self) -> &'static str {
"scope::files-denylist"
}
fn verify(&self, ctx: &VerifyContext) -> VerifyResult {
let denylist = &ctx.task.scope.files_denylist;
if denylist.is_empty() {
return VerifyResult::Pass;
}
let diff = match run_git(ctx.worktree_path, &["diff", "--name-only", "main"]) {
Ok(s) => s,
Err(e) => {
return VerifyResult::Fail {
reason: "git diff --name-only main failed".into(),
detail: Some(e.to_string()),
}
}
};
let hits: Vec<&str> = diff
.lines()
.filter(|p| !p.is_empty())
.filter(|p| denylist.iter().any(|g| glob_match(g, p)))
.collect();
if hits.is_empty() {
VerifyResult::Pass
} else {
VerifyResult::Fail {
reason: format!("{} path(s) in denylist", hits.len()),
detail: Some(hits.join("\n")),
}
}
}
}

View file

@ -0,0 +1,42 @@
//! `scope::files-whitelist` verify — `git diff --name-only main` on agent
//! worktree; fails if any touched path is outside the whitelist.
use crate::capability::*;
use crate::simulated_merge::{glob_match, run_git};
pub struct FilesWhitelistVerify;
impl Capability for FilesWhitelistVerify {
fn name(&self) -> &'static str {
"scope::files-whitelist"
}
fn verify(&self, ctx: &VerifyContext) -> VerifyResult {
let whitelist = &ctx.task.scope.files_whitelist;
if whitelist.is_empty() {
return VerifyResult::Pass;
}
let diff = match run_git(ctx.worktree_path, &["diff", "--name-only", "main"]) {
Ok(s) => s,
Err(e) => {
return VerifyResult::Fail {
reason: "git diff --name-only main failed".into(),
detail: Some(e.to_string()),
}
}
};
let violators: Vec<&str> = diff
.lines()
.filter(|p| !p.is_empty())
.filter(|p| !whitelist.iter().any(|g| glob_match(g, p)))
.collect();
if violators.is_empty() {
VerifyResult::Pass
} else {
VerifyResult::Fail {
reason: format!("{} path(s) outside whitelist", violators.len()),
detail: Some(violators.join("\n")),
}
}
}
}

View file

@ -0,0 +1,89 @@
//! Run every verify-capability declared by the task's role and collect
//! results into a `VerifyReport`.
//!
//! `run-mode` of each capability is not declared in this phase's registry
//! (declarative side is phase 1's `capability.toml`). Runtime defaults to
//! `Worktree`; caller passes `RunMode::Both` to get the simulated-merge
//! pass as well.
use crate::capability::{RunMode, TaskSpec, VerifyContext, VerifyResult};
use crate::registry;
use anyhow::{Context, Result};
use serde::Serialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Default, Clone, Serialize)]
pub struct VerifyReport {
pub passed: Vec<String>,
pub failed: Vec<FailedEntry>,
}
#[derive(Debug, Clone, Serialize)]
pub struct FailedEntry {
pub capability: String,
pub reason: String,
pub detail: Option<String>,
}
impl VerifyReport {
pub fn is_clean(&self) -> bool {
self.failed.is_empty()
}
}
/// Run every verify capability listed in the role's required list, in order.
/// `capability_names` is the ordered role manifest (from `_roles/<role>.toml`).
pub fn verify_task(
task: &TaskSpec,
agent_id: &str,
worktree_path: &Path,
main_repo: &Path,
run_mode: RunMode,
capability_names: &[String],
simulated_merge_path: Option<PathBuf>,
) -> Result<VerifyReport> {
let mut report = VerifyReport::default();
for name in capability_names {
let cap = match registry::get_verify(name) {
Some(c) => c,
None => continue,
};
let ctx = VerifyContext {
agent_id,
task,
worktree_path,
main_repo,
run_mode,
simulated_merge_path: simulated_merge_path.clone(),
};
match cap.verify(&ctx) {
VerifyResult::Pass => report.passed.push(name.clone()),
VerifyResult::Fail { reason, detail } => report.failed.push(FailedEntry {
capability: name.clone(),
reason,
detail,
}),
}
}
Ok(report)
}
/// Extract the ordered capability list from a role.toml file.
pub fn load_role_capabilities(kit_root: &Path, role: &str) -> Result<Vec<String>> {
#[derive(serde::Deserialize)]
struct Role {
#[serde(default)]
capabilities: Caps,
}
#[derive(serde::Deserialize, Default)]
struct Caps {
#[serde(default)]
required: Vec<String>,
}
let path = kit_root.join("_roles").join(format!("{role}.toml"));
let text = std::fs::read_to_string(&path)
.with_context(|| format!("read role {}", path.display()))?;
let r: Role = toml::from_str(&text)
.with_context(|| format!("parse role TOML {}", path.display()))?;
Ok(r.capabilities.required)
}

View file

@ -0,0 +1,55 @@
//! Registry smoke tests — every declared capability name resolves; unknown
//! names return None; gate-only and verify-only capabilities route correctly.
use kei_agent_runtime::registry;
#[test]
fn all_registered_names_resolve() {
for name in registry::all_names() {
assert!(
registry::get(name).is_some(),
"registry::get({name}) returned None"
);
}
}
#[test]
fn unknown_names_return_none() {
assert!(registry::get("bogus::nothing").is_none());
assert!(registry::get_gate("bogus::nothing").is_none());
assert!(registry::get_verify("bogus::nothing").is_none());
assert!(registry::get("").is_none());
}
#[test]
fn gate_only_capabilities_route_to_gate_table() {
let cap = registry::get_gate("tools::read-only").expect("read-only gate");
assert_eq!(cap.name(), "tools::read-only");
// read-only has no verify module — get_verify must miss
assert!(registry::get_verify("tools::read-only").is_none());
}
#[test]
fn verify_only_capabilities_route_to_verify_table() {
let cap = registry::get_verify("quality::cargo-check-green").expect("ccg verify");
assert_eq!(cap.name(), "quality::cargo-check-green");
assert!(registry::get_gate("quality::cargo-check-green").is_none());
}
#[test]
fn dual_capabilities_register_in_both_tables() {
// scope::* have both gate and verify impls under the same name
assert!(registry::get_gate("scope::files-whitelist").is_some());
assert!(registry::get_verify("scope::files-whitelist").is_some());
assert!(registry::get_gate("scope::files-denylist").is_some());
assert!(registry::get_verify("scope::files-denylist").is_some());
assert!(registry::get_gate("safety::no-dep-bump").is_some());
assert!(registry::get_verify("safety::no-dep-bump").is_some());
}
#[test]
fn registry_total_count_matches_spec() {
// 11 unique names in inventory; 3 of them (scope whitelist, scope
// denylist, safety::no-dep-bump) are dual gate+verify.
assert_eq!(registry::all_names().len(), 11);
}

View file

@ -0,0 +1,69 @@
//! Compose smoke test — load fake role + 2 capabilities from a tempdir
//! fixture, assert composed prompt contains both text fragments and the
//! task body.
use kei_agent_runtime::capability::TaskSpec;
use kei_agent_runtime::compose::compose_prompt;
use tempfile::TempDir;
#[test]
fn compose_concatenates_fragments_and_body() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("_capabilities/policy/no-git-ops")).unwrap();
std::fs::write(
root.join("_capabilities/policy/no-git-ops/text.md"),
"## No git\n\nYou must not git.\n",
)
.unwrap();
std::fs::create_dir_all(root.join("_capabilities/output/report-format")).unwrap();
std::fs::write(
root.join("_capabilities/output/report-format/text.md"),
"## Report\n\nEmit a report.\n",
)
.unwrap();
std::fs::create_dir_all(root.join("_roles")).unwrap();
std::fs::write(
root.join("_roles/fake.toml"),
r#"
[role]
name = "fake"
[capabilities]
required = ["policy::no-git-ops", "output::report-format"]
"#,
)
.unwrap();
let mut task = TaskSpec::default();
task.task.role = "fake".into();
task.task.agent_id = "abc123".into();
task.body.text = "Do the thing.".into();
let prompt = compose_prompt(&task, root).expect("compose");
assert!(prompt.contains("You must not git"));
assert!(prompt.contains("Emit a report"));
assert!(prompt.contains("Do the thing."));
assert!(prompt.contains("---")); // separator
}
#[test]
fn compose_missing_role_errors() {
let tmp = TempDir::new().unwrap();
let mut task = TaskSpec::default();
task.task.role = "nonexistent".into();
task.task.agent_id = "x".into();
let err = compose_prompt(&task, tmp.path()).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("role") || msg.contains("nonexistent"));
}
#[test]
fn compose_empty_role_errors() {
let tmp = TempDir::new().unwrap();
let task = TaskSpec::default();
let err = compose_prompt(&task, tmp.path()).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("role"));
}

View file

@ -0,0 +1,155 @@
//! Gate smoke tests — one happy + one deny + one bypass/boundary per gate.
use kei_agent_runtime::capability::{GateContext, GateDecision, TaskSpec};
use kei_agent_runtime::registry;
use serde_json::json;
use std::collections::HashMap;
fn ctx<'a>(
tool: &'a str,
input: &'a serde_json::Value,
task: &'a TaskSpec,
env: &'a HashMap<String, String>,
) -> GateContext<'a> {
GateContext { tool_name: tool, tool_input: input, task, env }
}
fn env_empty() -> HashMap<String, String> {
HashMap::new()
}
fn env_with(key: &str, val: &str) -> HashMap<String, String> {
let mut m = HashMap::new();
m.insert(key.into(), val.into());
m
}
#[test]
fn no_git_ops_denies_git_command() {
let g = registry::get_gate("policy::no-git-ops").unwrap();
let task = TaskSpec::default();
let env = env_empty();
let input = json!({"command": "git commit -m foo"});
match g.check(&ctx("Bash", &input, &task, &env)) {
GateDecision::Deny { .. } => {}
other => panic!("expected Deny, got {other:?}"),
}
}
#[test]
fn no_git_ops_allows_non_git_bash() {
let g = registry::get_gate("policy::no-git-ops").unwrap();
let task = TaskSpec::default();
let env = env_empty();
let input = json!({"command": "cargo build"});
assert_eq!(g.check(&ctx("Bash", &input, &task, &env)), GateDecision::Allow);
}
#[test]
fn no_git_ops_bypass_orchestrator_meta() {
let g = registry::get_gate("policy::no-git-ops").unwrap();
let task = TaskSpec::default();
let env = env_with("ORCHESTRATOR_META", "1");
let input = json!({"command": "git commit -m bypass"});
assert_eq!(g.check(&ctx("Bash", &input, &task, &env)), GateDecision::Allow);
}
#[test]
fn read_only_denies_write() {
let g = registry::get_gate("tools::read-only").unwrap();
let task = TaskSpec::default();
let env = env_empty();
let input = json!({"file_path": "/tmp/foo.rs"});
matches!(g.check(&ctx("Write", &input, &task, &env)), GateDecision::Deny { .. });
matches!(g.check(&ctx("Edit", &input, &task, &env)), GateDecision::Deny { .. });
}
#[test]
fn read_only_allows_read() {
let g = registry::get_gate("tools::read-only").unwrap();
let task = TaskSpec::default();
let env = env_empty();
let input = json!({});
assert_eq!(
g.check(&ctx("Read", &input, &task, &env)),
GateDecision::NotApplicable
);
}
#[test]
fn cargo_only_bash_allows_cargo() {
let g = registry::get_gate("tools::cargo-only-bash").unwrap();
let task = TaskSpec::default();
let env = env_empty();
let input = json!({"command": "cargo test --workspace"});
assert_eq!(g.check(&ctx("Bash", &input, &task, &env)), GateDecision::Allow);
}
#[test]
fn cargo_only_bash_denies_curl() {
let g = registry::get_gate("tools::cargo-only-bash").unwrap();
let task = TaskSpec::default();
let env = env_empty();
let input = json!({"command": "curl example.com"});
matches!(
g.check(&ctx("Bash", &input, &task, &env)),
GateDecision::Deny { .. }
);
}
#[test]
fn scope_whitelist_allows_matching_path() {
let g = registry::get_gate("scope::files-whitelist").unwrap();
let mut task = TaskSpec::default();
task.scope.files_whitelist = vec!["_primitives/_rust/kei-forge/**".into()];
let env = env_empty();
let input = json!({"file_path": "_primitives/_rust/kei-forge/src/lib.rs"});
assert_eq!(g.check(&ctx("Edit", &input, &task, &env)), GateDecision::Allow);
}
#[test]
fn scope_whitelist_denies_outside() {
let g = registry::get_gate("scope::files-whitelist").unwrap();
let mut task = TaskSpec::default();
task.scope.files_whitelist = vec!["_primitives/_rust/kei-forge/**".into()];
let env = env_empty();
let input = json!({"file_path": "hooks/foo.sh"});
matches!(
g.check(&ctx("Edit", &input, &task, &env)),
GateDecision::Deny { .. }
);
}
#[test]
fn scope_denylist_denies_match() {
let g = registry::get_gate("scope::files-denylist").unwrap();
let mut task = TaskSpec::default();
task.scope.files_denylist = vec!["_primitives/_rust/Cargo.toml".into()];
let env = env_empty();
let input = json!({"file_path": "_primitives/_rust/Cargo.toml"});
matches!(
g.check(&ctx("Edit", &input, &task, &env)),
GateDecision::Deny { .. }
);
}
#[test]
fn no_dep_bump_blocks_cargo_toml() {
let g = registry::get_gate("safety::no-dep-bump").unwrap();
let task = TaskSpec::default();
let env = env_empty();
let input = json!({"file_path": "foo/Cargo.toml"});
matches!(
g.check(&ctx("Edit", &input, &task, &env)),
GateDecision::Deny { .. }
);
}
#[test]
fn no_dep_bump_allow_bypass() {
let g = registry::get_gate("safety::no-dep-bump").unwrap();
let task = TaskSpec::default();
let env = env_with("ALLOW_DEP_BUMP", "1");
let input = json!({"file_path": "foo/Cargo.toml"});
assert_eq!(g.check(&ctx("Edit", &input, &task, &env)), GateDecision::Allow);
}

View file

@ -0,0 +1,65 @@
//! Simulated-merge smoke test — initialize a tempdir git repo, create a
//! feature branch with a file change, run the simulated-merge flow, assert
//! the temp worktree contains the agent's change on top of main.
use kei_agent_runtime::simulated_merge::{glob_match, run_simulated_merge};
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
fn sh(dir: &Path, args: &[&str]) {
let out = Command::new("git").args(args).current_dir(dir).output().unwrap();
assert!(
out.status.success(),
"git {}: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn simulated_merge_applies_agent_diff() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
sh(repo, &["init", "-q", "-b", "main"]);
sh(repo, &["config", "user.email", "t@t"]);
sh(repo, &["config", "user.name", "t"]);
std::fs::write(repo.join("README.md"), "seed\n").unwrap();
sh(repo, &["add", "."]);
sh(repo, &["commit", "-q", "-m", "seed"]);
// Agent makes a change on a feature branch
sh(repo, &["checkout", "-q", "-b", "agent/x"]);
std::fs::write(repo.join("new.txt"), "agent wrote this\n").unwrap();
sh(repo, &["add", "."]);
sh(repo, &["commit", "-q", "-m", "agent change"]);
let merged = run_simulated_merge("test123", repo, repo).expect("simulated merge");
let content = std::fs::read_to_string(merged.join("new.txt"))
.expect("agent diff applied in merged worktree");
assert_eq!(content, "agent wrote this\n");
// Cleanup
let _ = Command::new("git")
.args(["worktree", "remove", "--force", merged.to_str().unwrap()])
.current_dir(repo)
.output();
}
#[test]
fn glob_match_handles_double_star() {
assert!(glob_match("_primitives/_rust/kei-forge/**", "_primitives/_rust/kei-forge/src/lib.rs"));
assert!(!glob_match("_primitives/_rust/kei-forge/**", "hooks/foo.sh"));
}
#[test]
fn glob_match_single_star_path_component() {
assert!(glob_match("src/*.rs", "src/main.rs"));
assert!(!glob_match("src/*.rs", "src/mod/main.rs"));
}
#[test]
fn glob_match_exact_path() {
assert!(glob_match("Cargo.toml", "Cargo.toml"));
assert!(!glob_match("Cargo.toml", "src/Cargo.toml"));
}

View file

@ -0,0 +1,221 @@
//! Verify smoke tests — one happy + one fail per verify capability.
//!
//! Git-dependent verifies use an init-ed tempdir with `main` branch.
use kei_agent_runtime::capability::{RunMode, TaskSpec, VerifyContext, VerifyResult};
use kei_agent_runtime::registry;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Mutex;
use tempfile::TempDir;
/// Serialise access to env vars across parallel tests.
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn vctx<'a>(
task: &'a TaskSpec,
worktree: &'a Path,
main: &'a Path,
agent_id: &'a str,
) -> VerifyContext<'a> {
VerifyContext {
agent_id,
task,
worktree_path: worktree,
main_repo: main,
run_mode: RunMode::Worktree,
simulated_merge_path: None,
}
}
fn init_git_repo(dir: &Path) {
Command::new("git").args(["init", "-q", "-b", "main"]).current_dir(dir).output().unwrap();
Command::new("git").args(["config", "user.email", "t@t"]).current_dir(dir).output().unwrap();
Command::new("git").args(["config", "user.name", "t"]).current_dir(dir).output().unwrap();
std::fs::write(dir.join("README.md"), "seed\n").unwrap();
Command::new("git").args(["add", "."]).current_dir(dir).output().unwrap();
Command::new("git").args(["commit", "-q", "-m", "seed"]).current_dir(dir).output().unwrap();
}
fn commit_all(dir: &Path, msg: &str) {
Command::new("git").args(["add", "."]).current_dir(dir).output().unwrap();
Command::new("git").args(["commit", "-q", "-m", msg]).current_dir(dir).output().unwrap();
}
#[test]
fn constructor_pattern_pass_on_small_file() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("small.rs"), "fn x() -> i32 { 1 }\n").unwrap();
let cap = registry::get_verify("quality::constructor-pattern").unwrap();
let task = TaskSpec::default();
let ctx = vctx(&task, tmp.path(), tmp.path(), "t");
assert_eq!(cap.verify(&ctx), VerifyResult::Pass);
}
#[test]
fn constructor_pattern_fails_on_large_file() {
let tmp = TempDir::new().unwrap();
let big = (0..250).map(|i| format!("// line {i}")).collect::<Vec<_>>().join("\n");
std::fs::write(tmp.path().join("big.rs"), big).unwrap();
let cap = registry::get_verify("quality::constructor-pattern").unwrap();
let task = TaskSpec::default();
let ctx = vctx(&task, tmp.path(), tmp.path(), "t");
matches!(cap.verify(&ctx), VerifyResult::Fail { .. });
}
#[test]
fn constructor_pattern_fails_on_long_fn() {
let tmp = TempDir::new().unwrap();
let body = (0..40).map(|_| " let _ = 0;").collect::<Vec<_>>().join("\n");
let src = format!("fn long() {{\n{body}\n}}\n");
std::fs::write(tmp.path().join("longfn.rs"), src).unwrap();
let cap = registry::get_verify("quality::constructor-pattern").unwrap();
let task = TaskSpec::default();
let ctx = vctx(&task, tmp.path(), tmp.path(), "t");
matches!(cap.verify(&ctx), VerifyResult::Fail { .. });
}
#[test]
fn tests_green_passes_with_no_crates_configured() {
let tmp = TempDir::new().unwrap();
let cap = registry::get_verify("quality::tests-green").unwrap();
let task = TaskSpec::default(); // empty cargo-test-crates
let ctx = vctx(&task, tmp.path(), tmp.path(), "t");
assert_eq!(cap.verify(&ctx), VerifyResult::Pass);
}
#[test]
fn scope_whitelist_verify_passes_on_matching_diff() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
std::fs::create_dir_all(tmp.path().join("allowed")).unwrap();
std::fs::write(tmp.path().join("allowed/f.rs"), "fn x() {}\n").unwrap();
commit_all(tmp.path(), "add");
let mut task = TaskSpec::default();
task.scope.files_whitelist = vec!["allowed/**".into()];
let cap = registry::get_verify("scope::files-whitelist").unwrap();
let ctx = vctx(&task, tmp.path(), tmp.path(), "t");
// diff against main: no new changes beyond what's already committed → PASS trivially
assert_eq!(cap.verify(&ctx), VerifyResult::Pass);
}
#[test]
fn scope_whitelist_verify_fails_on_outside_diff() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
// Create branch off main with an outside edit
Command::new("git")
.args(["checkout", "-q", "-b", "feature"])
.current_dir(tmp.path())
.output()
.unwrap();
std::fs::write(tmp.path().join("outside.rs"), "fn x() {}\n").unwrap();
commit_all(tmp.path(), "outside");
let mut task = TaskSpec::default();
task.scope.files_whitelist = vec!["allowed/**".into()];
let cap = registry::get_verify("scope::files-whitelist").unwrap();
let ctx = vctx(&task, tmp.path(), tmp.path(), "t");
matches!(cap.verify(&ctx), VerifyResult::Fail { .. });
}
#[test]
fn scope_denylist_verify_fails_on_denied_path() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
Command::new("git")
.args(["checkout", "-q", "-b", "feature"])
.current_dir(tmp.path())
.output()
.unwrap();
std::fs::write(tmp.path().join("Cargo.toml"), "[package]\n").unwrap();
commit_all(tmp.path(), "bad");
let mut task = TaskSpec::default();
task.scope.files_denylist = vec!["Cargo.toml".into()];
let cap = registry::get_verify("scope::files-denylist").unwrap();
let ctx = vctx(&task, tmp.path(), tmp.path(), "t");
matches!(cap.verify(&ctx), VerifyResult::Fail { .. });
}
#[test]
fn safety_no_dep_bump_verify_passes_with_no_version_diff() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
let task = TaskSpec::default();
let cap = registry::get_verify("safety::no-dep-bump").unwrap();
let ctx = vctx(&task, tmp.path(), tmp.path(), "t");
assert_eq!(cap.verify(&ctx), VerifyResult::Pass);
}
#[test]
fn safety_no_dep_bump_verify_fails_on_version_diff() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
std::fs::write(tmp.path().join("Cargo.toml"), "version = \"0.1.0\"\n").unwrap();
commit_all(tmp.path(), "seed version");
Command::new("git")
.args(["checkout", "-q", "-b", "feature"])
.current_dir(tmp.path())
.output()
.unwrap();
std::fs::write(tmp.path().join("Cargo.toml"), "version = \"0.2.0\"\n").unwrap();
commit_all(tmp.path(), "bump");
let task = TaskSpec::default();
let cap = registry::get_verify("safety::no-dep-bump").unwrap();
let ctx = vctx(&task, tmp.path(), tmp.path(), "t");
matches!(cap.verify(&ctx), VerifyResult::Fail { .. });
}
#[test]
fn output_report_format_passes_when_fields_present() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = TempDir::new().unwrap();
let report_path: PathBuf = tmp.path().join("report.md");
std::fs::write(
&report_path,
"## Summary\n\nfiles-touched: 3\ncargo-check: PASS\n",
)
.unwrap();
std::env::set_var("AGENT_REPORT_PATH", &report_path);
let mut task = TaskSpec::default();
task.output.report_fields_required =
vec!["files-touched".into(), "cargo-check".into()];
let cap = registry::get_verify("output::report-format").unwrap();
let ctx = vctx(&task, tmp.path(), tmp.path(), "t");
let r = cap.verify(&ctx);
std::env::remove_var("AGENT_REPORT_PATH");
assert_eq!(r, VerifyResult::Pass);
}
#[test]
fn output_report_format_fails_when_missing() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = TempDir::new().unwrap();
let report_path: PathBuf = tmp.path().join("report.md");
std::fs::write(&report_path, "only summary").unwrap();
std::env::set_var("AGENT_REPORT_PATH", &report_path);
let mut task = TaskSpec::default();
task.output.report_fields_required = vec!["files-touched".into()];
let cap = registry::get_verify("output::report-format").unwrap();
let ctx = vctx(&task, tmp.path(), tmp.path(), "t");
let r = cap.verify(&ctx);
std::env::remove_var("AGENT_REPORT_PATH");
match r {
VerifyResult::Fail { .. } => {}
other => panic!("expected Fail, got {other:?}"),
}
}
#[test]
fn output_severity_grade_accepts_high() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = TempDir::new().unwrap();
let report_path: PathBuf = tmp.path().join("r.md");
std::fs::write(&report_path, "**HIGH**: foo\n").unwrap();
std::env::set_var("AGENT_REPORT_PATH", &report_path);
let task = TaskSpec::default();
let cap = registry::get_verify("output::severity-grade").unwrap();
let ctx = vctx(&task, tmp.path(), tmp.path(), "t");
let r = cap.verify(&ctx);
std::env::remove_var("AGENT_REPORT_PATH");
assert_eq!(r, VerifyResult::Pass);
}

View file

@ -0,0 +1,22 @@
[package]
name = "kei-capability"
version = "0.1.0"
edition = "2021"
rust-version = "1.75"
description = "Hook-protocol CLI adapter — routes PreToolUse check + on-return verify to kei-agent-runtime capabilities"
[[bin]]
name = "kei-capability"
path = "src/main.rs"
[dependencies]
kei-agent-runtime = { path = "../kei-agent-runtime" }
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
toml = "0.8"
[package.metadata.keisei]
backend = "none"
description = "Hook-protocol CLI — `kei-capability check <name>` / `kei-capability verify <name>`"

View file

@ -0,0 +1,131 @@
//! kei-capability — hook-protocol CLI adapter.
//!
//! Subcommands:
//! - `check <name>` — reads tool-use JSON from stdin, runs registry
//! gate, emits permissionDecision JSON, exits 0 or 2.
//! - `verify <name>` — reads env (AGENT_ID, TASK_TOML, WORKTREE_PATH,
//! MAIN_REPO, RUN_MODE), runs registry verify,
//! exits 0 on pass or non-zero with stderr message.
use clap::{Parser, Subcommand};
use kei_agent_runtime::capability::{
GateContext, GateDecision, RunMode, TaskSpec, VerifyContext, VerifyResult,
};
use kei_agent_runtime::registry;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::io::Read;
use std::path::PathBuf;
use std::process::ExitCode;
#[derive(Parser)]
#[command(name = "kei-capability", version, about = "Capability hook adapter")]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// PreToolUse gate — stdin holds hook payload JSON.
Check { name: String },
/// On-return verify — env carries context.
Verify { name: String },
}
fn main() -> ExitCode {
let cli = Cli::parse();
match cli.cmd {
Cmd::Check { name } => run_check(name),
Cmd::Verify { name } => run_verify(name),
}
}
fn run_check(name: String) -> ExitCode {
let cap = match registry::get_gate(&name) {
Some(c) => c,
None => {
eprintln!("unknown gate capability: {name}");
return ExitCode::from(2);
}
};
let payload = read_stdin_json().unwrap_or_else(|| json!({}));
let tool_name = payload.get("tool_name").and_then(|v| v.as_str()).unwrap_or("");
let tool_input = payload.get("tool_input").cloned().unwrap_or(json!({}));
let env: HashMap<String, String> = std::env::vars().collect();
let task = load_task_from_env().unwrap_or_default();
let ctx = GateContext {
tool_name,
tool_input: &tool_input,
task: &task,
env: &env,
};
match cap.check(&ctx) {
GateDecision::Allow | GateDecision::NotApplicable => {
println!("{}", json!({"permissionDecision": "allow"}));
ExitCode::SUCCESS
}
GateDecision::Deny { reason } => {
eprintln!("{reason}");
println!(
"{}",
json!({"permissionDecision": "deny", "reason": reason})
);
ExitCode::from(2)
}
}
}
fn run_verify(name: String) -> ExitCode {
let cap = match registry::get_verify(&name) {
Some(c) => c,
None => {
eprintln!("unknown verify capability: {name}");
return ExitCode::from(2);
}
};
let agent_id = std::env::var("AGENT_ID").unwrap_or_default();
let worktree_path = PathBuf::from(std::env::var("WORKTREE_PATH").unwrap_or_default());
let main_repo = PathBuf::from(std::env::var("MAIN_REPO").unwrap_or_default());
let run_mode = match std::env::var("RUN_MODE").unwrap_or_else(|_| "worktree".into()).as_str() {
"simulated-merge" => RunMode::SimulatedMerge,
"both" => RunMode::Both,
_ => RunMode::Worktree,
};
let task = load_task_from_env().unwrap_or_default();
let ctx = VerifyContext {
agent_id: &agent_id,
task: &task,
worktree_path: &worktree_path,
main_repo: &main_repo,
run_mode,
simulated_merge_path: None,
};
match cap.verify(&ctx) {
VerifyResult::Pass => ExitCode::SUCCESS,
VerifyResult::Fail { reason, detail } => {
eprintln!("FAIL {name}: {reason}");
if let Some(d) = detail {
eprintln!("{d}");
}
ExitCode::from(2)
}
}
}
fn read_stdin_json() -> Option<Value> {
let mut buf = String::new();
if std::io::stdin().read_to_string(&mut buf).is_err() {
return None;
}
if buf.trim().is_empty() {
return None;
}
serde_json::from_str(&buf).ok()
}
fn load_task_from_env() -> Option<TaskSpec> {
let p = std::env::var("TASK_TOML").ok()?;
let text = std::fs::read_to_string(&p).ok()?;
toml::from_str::<TaskSpec>(&text).ok()
}