From b82e3b039ec85fa2df36e53853d5d3ff0ca4522c Mon Sep 17 00:00:00 2001 From: Parfii-bot Date: Thu, 23 Apr 2026 02:35:53 +0800 Subject: [PATCH] feat(agent-substrate/phase-3): kei-agent-runtime + kei-capability binaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ + 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 ` (PreToolUse gate; stdin JSON, exit 0|2) - Subcommand `verify ` (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) --- _primitives/_rust/Cargo.lock | 28 +++ _primitives/_rust/Cargo.toml | 4 + .../_rust/kei-agent-runtime/Cargo.toml | 32 +++ .../_rust/kei-agent-runtime/src/capability.rs | 141 +++++++++++ .../_rust/kei-agent-runtime/src/compose.rs | 74 ++++++ .../_rust/kei-agent-runtime/src/gates/mod.rs | 11 + .../src/gates/policy_no_git_ops.rs | 49 ++++ .../src/gates/safety_no_dep_bump.rs | 35 +++ .../src/gates/scope_files_denylist.rs | 35 +++ .../src/gates/scope_files_whitelist.rs | 37 +++ .../src/gates/tools_cargo_only_bash.rs | 54 +++++ .../src/gates/tools_read_only.rs | 23 ++ .../_rust/kei-agent-runtime/src/lib.rs | 22 ++ .../_rust/kei-agent-runtime/src/main.rs | 160 +++++++++++++ .../_rust/kei-agent-runtime/src/registry.rs | 93 ++++++++ .../kei-agent-runtime/src/simulated_merge.rs | 107 +++++++++ .../_rust/kei-agent-runtime/src/spawn.rs | 53 +++++ .../kei-agent-runtime/src/verifies/mod.rs | 13 ++ .../src/verifies/output_report_format.rs | 57 +++++ .../src/verifies/output_severity_grade.rs | 47 ++++ .../src/verifies/quality_cargo_check_green.rs | 41 ++++ .../verifies/quality_constructor_pattern.rs | 102 ++++++++ .../src/verifies/quality_tests_green.rs | 75 ++++++ .../src/verifies/safety_no_dep_bump.rs | 39 ++++ .../src/verifies/scope_files_denylist.rs | 42 ++++ .../src/verifies/scope_files_whitelist.rs | 42 ++++ .../_rust/kei-agent-runtime/src/verify.rs | 89 +++++++ .../tests/capability_trait_smoke.rs | 55 +++++ .../kei-agent-runtime/tests/compose_smoke.rs | 69 ++++++ .../kei-agent-runtime/tests/gate_smoke.rs | 155 ++++++++++++ .../tests/simulated_merge_smoke.rs | 65 ++++++ .../kei-agent-runtime/tests/verify_smoke.rs | 221 ++++++++++++++++++ _primitives/_rust/kei-capability/Cargo.toml | 22 ++ _primitives/_rust/kei-capability/src/main.rs | 131 +++++++++++ 34 files changed, 2223 insertions(+) create mode 100644 _primitives/_rust/kei-agent-runtime/Cargo.toml create mode 100644 _primitives/_rust/kei-agent-runtime/src/capability.rs create mode 100644 _primitives/_rust/kei-agent-runtime/src/compose.rs create mode 100644 _primitives/_rust/kei-agent-runtime/src/gates/mod.rs create mode 100644 _primitives/_rust/kei-agent-runtime/src/gates/policy_no_git_ops.rs create mode 100644 _primitives/_rust/kei-agent-runtime/src/gates/safety_no_dep_bump.rs create mode 100644 _primitives/_rust/kei-agent-runtime/src/gates/scope_files_denylist.rs create mode 100644 _primitives/_rust/kei-agent-runtime/src/gates/scope_files_whitelist.rs create mode 100644 _primitives/_rust/kei-agent-runtime/src/gates/tools_cargo_only_bash.rs create mode 100644 _primitives/_rust/kei-agent-runtime/src/gates/tools_read_only.rs create mode 100644 _primitives/_rust/kei-agent-runtime/src/lib.rs create mode 100644 _primitives/_rust/kei-agent-runtime/src/main.rs create mode 100644 _primitives/_rust/kei-agent-runtime/src/registry.rs create mode 100644 _primitives/_rust/kei-agent-runtime/src/simulated_merge.rs create mode 100644 _primitives/_rust/kei-agent-runtime/src/spawn.rs create mode 100644 _primitives/_rust/kei-agent-runtime/src/verifies/mod.rs create mode 100644 _primitives/_rust/kei-agent-runtime/src/verifies/output_report_format.rs create mode 100644 _primitives/_rust/kei-agent-runtime/src/verifies/output_severity_grade.rs create mode 100644 _primitives/_rust/kei-agent-runtime/src/verifies/quality_cargo_check_green.rs create mode 100644 _primitives/_rust/kei-agent-runtime/src/verifies/quality_constructor_pattern.rs create mode 100644 _primitives/_rust/kei-agent-runtime/src/verifies/quality_tests_green.rs create mode 100644 _primitives/_rust/kei-agent-runtime/src/verifies/safety_no_dep_bump.rs create mode 100644 _primitives/_rust/kei-agent-runtime/src/verifies/scope_files_denylist.rs create mode 100644 _primitives/_rust/kei-agent-runtime/src/verifies/scope_files_whitelist.rs create mode 100644 _primitives/_rust/kei-agent-runtime/src/verify.rs create mode 100644 _primitives/_rust/kei-agent-runtime/tests/capability_trait_smoke.rs create mode 100644 _primitives/_rust/kei-agent-runtime/tests/compose_smoke.rs create mode 100644 _primitives/_rust/kei-agent-runtime/tests/gate_smoke.rs create mode 100644 _primitives/_rust/kei-agent-runtime/tests/simulated_merge_smoke.rs create mode 100644 _primitives/_rust/kei-agent-runtime/tests/verify_smoke.rs create mode 100644 _primitives/_rust/kei-capability/Cargo.toml create mode 100644 _primitives/_rust/kei-capability/src/main.rs diff --git a/_primitives/_rust/Cargo.lock b/_primitives/_rust/Cargo.lock index 4842936..e982ccd 100644 --- a/_primitives/_rust/Cargo.lock +++ b/_primitives/_rust/Cargo.lock @@ -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" diff --git a/_primitives/_rust/Cargo.toml b/_primitives/_rust/Cargo.toml index 8cfff5c..c7354d5 100644 --- a/_primitives/_rust/Cargo.toml +++ b/_primitives/_rust/Cargo.toml @@ -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] diff --git a/_primitives/_rust/kei-agent-runtime/Cargo.toml b/_primitives/_rust/kei-agent-runtime/Cargo.toml new file mode 100644 index 0000000..ef1a68f --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/Cargo.toml @@ -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" diff --git a/_primitives/_rust/kei-agent-runtime/src/capability.rs b/_primitives/_rust/kei-agent-runtime/src/capability.rs new file mode 100644 index 0000000..14748fd --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/capability.rs @@ -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: `::` (e.g. `policy::no-git-ops`). + fn name(&self) -> &'static str; + + /// PreToolUse gate; called by `kei-capability check `. + fn check(&self, _ctx: &GateContext) -> GateDecision { + GateDecision::NotApplicable + } + + /// On-return verify; called by `kei-capability verify `. + 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, +} + +/// 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, +} + +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, + }, +} + +/// 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, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct TaskScope { + #[serde(default, rename = "files-whitelist")] + pub files_whitelist: Vec, + #[serde(default, rename = "files-denylist")] + pub files_denylist: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct TaskVerification { + #[serde(default, rename = "cargo-check-crates")] + pub cargo_check_crates: Vec, + #[serde(default, rename = "cargo-test-crates")] + pub cargo_test_crates: Vec, + #[serde(default, rename = "test-count-min")] + pub test_count_min: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct TaskOutput { + #[serde(default, rename = "report-fields-required")] + pub report_fields_required: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct TaskBody { + #[serde(default)] + pub text: String, +} diff --git a/_primitives/_rust/kei-agent-runtime/src/compose.rs b/_primitives/_rust/kei-agent-runtime/src/compose.rs new file mode 100644 index 0000000..fb859a2 --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/compose.rs @@ -0,0 +1,74 @@ +//! Compose capability-fragment prompt for an agent invocation. +//! +//! Flow: +//! 1. Parse `task.toml` → `TaskSpec` (caller does this). +//! 2. Load `_roles/.toml`. +//! 3. For each capability in `role.capabilities.required`, read the +//! `_capabilities///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, +} + +/// 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 { + 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 = 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 { + 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 { + 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 ::")), + } +} diff --git a/_primitives/_rust/kei-agent-runtime/src/gates/mod.rs b/_primitives/_rust/kei-agent-runtime/src/gates/mod.rs new file mode 100644 index 0000000..d9a795e --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/gates/mod.rs @@ -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; diff --git a/_primitives/_rust/kei-agent-runtime/src/gates/policy_no_git_ops.rs b/_primitives/_rust/kei-agent-runtime/src/gates/policy_no_git_ops.rs new file mode 100644 index 0000000..680f2c4 --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/gates/policy_no_git_ops.rs @@ -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> = 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 + } +} diff --git a/_primitives/_rust/kei-agent-runtime/src/gates/safety_no_dep_bump.rs b/_primitives/_rust/kei-agent-runtime/src/gates/safety_no_dep_bump.rs new file mode 100644 index 0000000..dd29384 --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/gates/safety_no_dep_bump.rs @@ -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) +} diff --git a/_primitives/_rust/kei-agent-runtime/src/gates/scope_files_denylist.rs b/_primitives/_rust/kei-agent-runtime/src/gates/scope_files_denylist.rs new file mode 100644 index 0000000..fc382ed --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/gates/scope_files_denylist.rs @@ -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") +} diff --git a/_primitives/_rust/kei-agent-runtime/src/gates/scope_files_whitelist.rs b/_primitives/_rust/kei-agent-runtime/src/gates/scope_files_whitelist.rs new file mode 100644 index 0000000..647d06e --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/gates/scope_files_whitelist.rs @@ -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") +} diff --git a/_primitives/_rust/kei-agent-runtime/src/gates/tools_cargo_only_bash.rs b/_primitives/_rust/kei-agent-runtime/src/gates/tools_cargo_only_bash.rs new file mode 100644 index 0000000..7324a63 --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/gates/tools_cargo_only_bash.rs @@ -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> = 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() + } +} diff --git a/_primitives/_rust/kei-agent-runtime/src/gates/tools_read_only.rs b/_primitives/_rust/kei-agent-runtime/src/gates/tools_read_only.rs new file mode 100644 index 0000000..c8e1db7 --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/gates/tools_read_only.rs @@ -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, + } + } +} diff --git a/_primitives/_rust/kei-agent-runtime/src/lib.rs b/_primitives/_rust/kei-agent-runtime/src/lib.rs new file mode 100644 index 0000000..52f3283 --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/lib.rs @@ -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//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; diff --git a/_primitives/_rust/kei-agent-runtime/src/main.rs b/_primitives/_rust/kei-agent-runtime/src/main.rs new file mode 100644 index 0000000..24ed8ca --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/main.rs @@ -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//prompt.md. + Compose { + task: PathBuf, + #[arg(long)] + kit_root: Option, + }, + /// Prepare spawn dir (tasks//) — orchestrator invokes Agent tool. + Spawn { + task: PathBuf, + #[arg(long)] + kit_root: Option, + }, + /// Run every verify capability declared by the task's role. + Verify { + task: PathBuf, + #[arg(long)] + worktree: PathBuf, + #[arg(long)] + kit_root: Option, + #[arg(long)] + main_repo: Option, + #[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, + }, +} + +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 { + arg.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))) +} + +fn run_compose(task_path: PathBuf, kit_root: Option) -> 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) -> 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, + main_repo: Option, + 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) -> 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) +} diff --git a/_primitives/_rust/kei-agent-runtime/src/registry.rs b/_primitives/_rust/kei-agent-runtime/src/registry.rs new file mode 100644 index 0000000..1b6e1b7 --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/registry.rs @@ -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 `::` 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", + ] +} diff --git a/_primitives/_rust/kei-agent-runtime/src/simulated_merge.rs b/_primitives/_rust/kei-agent-runtime/src/simulated_merge.rs new file mode 100644 index 0000000..dce76bc --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/simulated_merge.rs @@ -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 { + 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 ` in `dir`, return stdout as UTF-8 string. +pub fn run_git(dir: &Path, args: &[&str]) -> Result { + 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 +} diff --git a/_primitives/_rust/kei-agent-runtime/src/spawn.rs b/_primitives/_rust/kei-agent-runtime/src/spawn.rs new file mode 100644 index 0000000..a643b6a --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/spawn.rs @@ -0,0 +1,53 @@ +//! Prepare an agent invocation: write `tasks//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 { + let text = fs::read_to_string(path) + .with_context(|| format!("read task file {}", path.display()))?; + toml::from_str::(&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 ` (or the Rust API) with the path returned. +pub fn prepare_agent(task: &TaskSpec, kit_root: &Path) -> Result { + 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 { + 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")) +} diff --git a/_primitives/_rust/kei-agent-runtime/src/verifies/mod.rs b/_primitives/_rust/kei-agent-runtime/src/verifies/mod.rs new file mode 100644 index 0000000..94d40da --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/verifies/mod.rs @@ -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; diff --git a/_primitives/_rust/kei-agent-runtime/src/verifies/output_report_format.rs b/_primitives/_rust/kei-agent-runtime/src/verifies/output_report_format.rs new file mode 100644 index 0000000..71ce1ff --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/verifies/output_report_format.rs @@ -0,0 +1,57 @@ +//! `output::report-format` verify — reads agent's final report (env var +//! `AGENT_REPORT_PATH` or `.claude/agents//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::>() + .join(", "), + ), + } + } + } +} + +fn load_report(ctx: &VerifyContext) -> Result { + 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())) +} diff --git a/_primitives/_rust/kei-agent-runtime/src/verifies/output_severity_grade.rs b/_primitives/_rust/kei-agent-runtime/src/verifies/output_severity_grade.rs new file mode 100644 index 0000000..9e1a2f3 --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/verifies/output_severity_grade.rs @@ -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 { + 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())) +} diff --git a/_primitives/_rust/kei-agent-runtime/src/verifies/quality_cargo_check_green.rs b/_primitives/_rust/kei-agent-runtime/src/verifies/quality_cargo_check_green.rs new file mode 100644 index 0000000..8b1a7d0 --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/verifies/quality_cargo_check_green.rs @@ -0,0 +1,41 @@ +//! `quality::cargo-check-green` — runs `cargo check --workspace` in +//! `/_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") +} diff --git a/_primitives/_rust/kei-agent-runtime/src/verifies/quality_constructor_pattern.rs b/_primitives/_rust/kei-agent-runtime/src/verifies/quality_constructor_pattern.rs new file mode 100644 index 0000000..3d2e253 --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/verifies/quality_constructor_pattern.rs @@ -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 = 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) { + 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 { + 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()) +} diff --git a/_primitives/_rust/kei-agent-runtime/src/verifies/quality_tests_green.rs b/_primitives/_rust/kei-agent-runtime/src/verifies/quality_tests_green.rs new file mode 100644 index 0000000..3a51d26 --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/verifies/quality_tests_green.rs @@ -0,0 +1,75 @@ +//! `quality::tests-green` — runs `cargo test -p ` 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 = + 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 { + 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::().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") +} diff --git a/_primitives/_rust/kei-agent-runtime/src/verifies/safety_no_dep_bump.rs b/_primitives/_rust/kei-agent-runtime/src/verifies/safety_no_dep_bump.rs new file mode 100644 index 0000000..8a1b7c2 --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/verifies/safety_no_dep_bump.rs @@ -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 = + 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 = 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")), + } + } + } +} diff --git a/_primitives/_rust/kei-agent-runtime/src/verifies/scope_files_denylist.rs b/_primitives/_rust/kei-agent-runtime/src/verifies/scope_files_denylist.rs new file mode 100644 index 0000000..ed54d73 --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/verifies/scope_files_denylist.rs @@ -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")), + } + } + } +} diff --git a/_primitives/_rust/kei-agent-runtime/src/verifies/scope_files_whitelist.rs b/_primitives/_rust/kei-agent-runtime/src/verifies/scope_files_whitelist.rs new file mode 100644 index 0000000..ec2aa35 --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/verifies/scope_files_whitelist.rs @@ -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")), + } + } + } +} diff --git a/_primitives/_rust/kei-agent-runtime/src/verify.rs b/_primitives/_rust/kei-agent-runtime/src/verify.rs new file mode 100644 index 0000000..3230cbf --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/verify.rs @@ -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, + pub failed: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FailedEntry { + pub capability: String, + pub reason: String, + pub detail: Option, +} + +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/.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, +) -> Result { + 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> { + #[derive(serde::Deserialize)] + struct Role { + #[serde(default)] + capabilities: Caps, + } + #[derive(serde::Deserialize, Default)] + struct Caps { + #[serde(default)] + required: Vec, + } + 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) +} diff --git a/_primitives/_rust/kei-agent-runtime/tests/capability_trait_smoke.rs b/_primitives/_rust/kei-agent-runtime/tests/capability_trait_smoke.rs new file mode 100644 index 0000000..419945e --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/tests/capability_trait_smoke.rs @@ -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); +} diff --git a/_primitives/_rust/kei-agent-runtime/tests/compose_smoke.rs b/_primitives/_rust/kei-agent-runtime/tests/compose_smoke.rs new file mode 100644 index 0000000..605d55b --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/tests/compose_smoke.rs @@ -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")); +} diff --git a/_primitives/_rust/kei-agent-runtime/tests/gate_smoke.rs b/_primitives/_rust/kei-agent-runtime/tests/gate_smoke.rs new file mode 100644 index 0000000..043d593 --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/tests/gate_smoke.rs @@ -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, +) -> GateContext<'a> { + GateContext { tool_name: tool, tool_input: input, task, env } +} + +fn env_empty() -> HashMap { + HashMap::new() +} + +fn env_with(key: &str, val: &str) -> HashMap { + 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); +} diff --git a/_primitives/_rust/kei-agent-runtime/tests/simulated_merge_smoke.rs b/_primitives/_rust/kei-agent-runtime/tests/simulated_merge_smoke.rs new file mode 100644 index 0000000..45688c5 --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/tests/simulated_merge_smoke.rs @@ -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")); +} diff --git a/_primitives/_rust/kei-agent-runtime/tests/verify_smoke.rs b/_primitives/_rust/kei-agent-runtime/tests/verify_smoke.rs new file mode 100644 index 0000000..ae802a4 --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/tests/verify_smoke.rs @@ -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::>().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::>().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); +} diff --git a/_primitives/_rust/kei-capability/Cargo.toml b/_primitives/_rust/kei-capability/Cargo.toml new file mode 100644 index 0000000..0444a6f --- /dev/null +++ b/_primitives/_rust/kei-capability/Cargo.toml @@ -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 ` / `kei-capability verify `" diff --git a/_primitives/_rust/kei-capability/src/main.rs b/_primitives/_rust/kei-capability/src/main.rs new file mode 100644 index 0000000..f32bb0e --- /dev/null +++ b/_primitives/_rust/kei-capability/src/main.rs @@ -0,0 +1,131 @@ +//! kei-capability — hook-protocol CLI adapter. +//! +//! Subcommands: +//! - `check ` — reads tool-use JSON from stdin, runs registry +//! gate, emits permissionDecision JSON, exits 0 or 2. +//! - `verify ` — 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 = 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 { + 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 { + let p = std::env::var("TASK_TOML").ok()?; + let text = std::fs::read_to_string(&p).ok()?; + toml::from_str::(&text).ok() +}