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:
parent
b8b9c12913
commit
b82e3b039e
34 changed files with 2223 additions and 0 deletions
28
_primitives/_rust/Cargo.lock
generated
28
_primitives/_rust/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
32
_primitives/_rust/kei-agent-runtime/Cargo.toml
Normal file
32
_primitives/_rust/kei-agent-runtime/Cargo.toml
Normal 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"
|
||||
141
_primitives/_rust/kei-agent-runtime/src/capability.rs
Normal file
141
_primitives/_rust/kei-agent-runtime/src/capability.rs
Normal 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,
|
||||
}
|
||||
74
_primitives/_rust/kei-agent-runtime/src/compose.rs
Normal file
74
_primitives/_rust/kei-agent-runtime/src/compose.rs
Normal 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>")),
|
||||
}
|
||||
}
|
||||
11
_primitives/_rust/kei-agent-runtime/src/gates/mod.rs
Normal file
11
_primitives/_rust/kei-agent-runtime/src/gates/mod.rs
Normal 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;
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
22
_primitives/_rust/kei-agent-runtime/src/lib.rs
Normal file
22
_primitives/_rust/kei-agent-runtime/src/lib.rs
Normal 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;
|
||||
160
_primitives/_rust/kei-agent-runtime/src/main.rs
Normal file
160
_primitives/_rust/kei-agent-runtime/src/main.rs
Normal 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)
|
||||
}
|
||||
93
_primitives/_rust/kei-agent-runtime/src/registry.rs
Normal file
93
_primitives/_rust/kei-agent-runtime/src/registry.rs
Normal 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",
|
||||
]
|
||||
}
|
||||
107
_primitives/_rust/kei-agent-runtime/src/simulated_merge.rs
Normal file
107
_primitives/_rust/kei-agent-runtime/src/simulated_merge.rs
Normal 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
|
||||
}
|
||||
53
_primitives/_rust/kei-agent-runtime/src/spawn.rs
Normal file
53
_primitives/_rust/kei-agent-runtime/src/spawn.rs
Normal 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"))
|
||||
}
|
||||
13
_primitives/_rust/kei-agent-runtime/src/verifies/mod.rs
Normal file
13
_primitives/_rust/kei-agent-runtime/src/verifies/mod.rs
Normal 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;
|
||||
|
|
@ -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()))
|
||||
}
|
||||
|
|
@ -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()))
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
89
_primitives/_rust/kei-agent-runtime/src/verify.rs
Normal file
89
_primitives/_rust/kei-agent-runtime/src/verify.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
69
_primitives/_rust/kei-agent-runtime/tests/compose_smoke.rs
Normal file
69
_primitives/_rust/kei-agent-runtime/tests/compose_smoke.rs
Normal 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"));
|
||||
}
|
||||
155
_primitives/_rust/kei-agent-runtime/tests/gate_smoke.rs
Normal file
155
_primitives/_rust/kei-agent-runtime/tests/gate_smoke.rs
Normal 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);
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
221
_primitives/_rust/kei-agent-runtime/tests/verify_smoke.rs
Normal file
221
_primitives/_rust/kei-agent-runtime/tests/verify_smoke.rs
Normal 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);
|
||||
}
|
||||
22
_primitives/_rust/kei-capability/Cargo.toml
Normal file
22
_primitives/_rust/kei-capability/Cargo.toml
Normal 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>`"
|
||||
131
_primitives/_rust/kei-capability/src/main.rs
Normal file
131
_primitives/_rust/kei-capability/src/main.rs
Normal 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()
|
||||
}
|
||||
Loading…
Reference in a new issue