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>
155 lines
4.9 KiB
Rust
155 lines
4.9 KiB
Rust
//! 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);
|
|
}
|