diff --git a/_primitives/_rust/kei-agent-runtime/src/gates/mod.rs b/_primitives/_rust/kei-agent-runtime/src/gates/mod.rs index 2787aa4..bec9c9e 100644 --- a/_primitives/_rust/kei-agent-runtime/src/gates/mod.rs +++ b/_primitives/_rust/kei-agent-runtime/src/gates/mod.rs @@ -1,8 +1,10 @@ //! PreToolUse gate capabilities. //! -//! Each module holds one zero-sized `impl Capability` struct. Registry -//! exposes them by `name()`. +//! After v0.18 convergence wave: 5 of 6 gates are `PatternGate` consts +//! (pattern-driven, regex or glob). `tools::deny-tools` remains its own +//! impl — mechanism is tool-name match, not pattern match. +pub mod pattern_gate; pub mod policy_no_git_ops; pub mod safety_no_dep_bump; pub mod scope_files_denylist; diff --git a/_primitives/_rust/kei-agent-runtime/src/gates/pattern_gate.rs b/_primitives/_rust/kei-agent-runtime/src/gates/pattern_gate.rs new file mode 100644 index 0000000..2c1062e --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/gates/pattern_gate.rs @@ -0,0 +1,192 @@ +//! Generic pattern-based gate (Layer C convergence, 2026-04-23). +//! +//! Absorbs 5 of the 6 gate impls (`policy::no-git-ops`, +//! `scope::files-whitelist`, `scope::files-denylist`, `safety::no-dep-bump`, +//! `tools::bash-allowlist`). Each concrete gate is now a `PatternGate` +//! const declaration in its own file; dispatch logic lives here. +//! +//! `tools::deny-tools` stays in its own module — mechanism is tool-name +//! match, not pattern match. + +use crate::capability::*; +use once_cell::sync::Lazy; +use regex::Regex; +use std::collections::HashMap; +use std::sync::Mutex; + +/// How the gate decides on a match. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GateMode { + /// Deny if any pattern matches (policy::no-git-ops, scope::files-denylist, + /// safety::no-dep-bump). + DenyIfMatch, + /// Allow only if at least one pattern matches (tools::bash-allowlist). + AllowIfMatch, + /// Deny if the value is NOT matched by any pattern AND the patterns + /// list is non-empty (scope::files-whitelist — empty list = skip). + DenyIfUnmatched, +} + +/// Source of match patterns — compile-time static (regex) or dynamic +/// from TaskSpec (glob, used by scope gates). +#[derive(Clone, Copy)] +pub enum PatternSource { + /// Static regex patterns, compiled lazily + cached. + StaticRegex(&'static [&'static str]), + /// `task.scope.files_whitelist`, matched via `simulated_merge::glob_match`. + TaskWhitelist, + /// `task.scope.files_denylist`, matched via `simulated_merge::glob_match`. + TaskDenylist, +} + +/// Generic pattern-driven PreToolUse gate. +pub struct PatternGate { + pub name: &'static str, + /// Tool filter. Empty slice = applies to any tool. + pub tools: &'static [&'static str], + /// JSON field in `tool_input` to read (e.g. "command", "file_path"). + pub field: &'static str, + pub mode: GateMode, + pub patterns: PatternSource, + /// If set and `env[bypass_env] == "1"`, gate allows unconditionally. + pub bypass_env: Option<&'static str>, + /// Human-readable deny reason template (`{name}` / `{cmd}` / `{path}` / `{pat}`). + pub deny_template: &'static str, +} + +impl Capability for PatternGate { + fn name(&self) -> &'static str { + self.name + } + + fn check(&self, ctx: &GateContext) -> GateDecision { + if !self.tool_applies(ctx.tool_name) { + return GateDecision::NotApplicable; + } + if self.bypass_active(ctx.env) { + return GateDecision::Allow; + } + let Some(value) = self.read_field(ctx.tool_input) else { + return GateDecision::NotApplicable; + }; + match self.patterns { + PatternSource::StaticRegex(arr) => self.decide_regex(&value, arr), + PatternSource::TaskWhitelist => { + self.decide_scope(&value, &ctx.task.scope.files_whitelist) + } + PatternSource::TaskDenylist => { + self.decide_scope(&value, &ctx.task.scope.files_denylist) + } + } + } +} + +impl PatternGate { + fn tool_applies(&self, tool: &str) -> bool { + self.tools.is_empty() || self.tools.iter().any(|t| *t == tool) + } + + fn bypass_active(&self, env: &HashMap) -> bool { + self.bypass_env + .and_then(|k| env.get(k)) + .map(|v| v == "1") + .unwrap_or(false) + } + + fn read_field(&self, input: &serde_json::Value) -> Option { + input.get(self.field).and_then(|v| v.as_str()).map(String::from) + } + + fn decide_regex(&self, value: &str, pats: &[&'static str]) -> GateDecision { + match self.mode { + GateMode::DenyIfMatch => self.deny_if_match_regex(value, pats), + GateMode::AllowIfMatch => self.allow_if_match_regex(value, pats), + GateMode::DenyIfUnmatched => self.deny_if_unmatched_regex(value, pats), + } + } + + fn deny_if_match_regex(&self, value: &str, pats: &[&'static str]) -> GateDecision { + for raw in pats { + if compile(raw).is_match(value) { + return GateDecision::Deny { reason: self.render_reason(value, raw) }; + } + } + GateDecision::Allow + } + + fn allow_if_match_regex(&self, value: &str, pats: &[&'static str]) -> GateDecision { + if pats.iter().any(|raw| compile(raw).is_match(value)) { + GateDecision::Allow + } else { + GateDecision::Deny { reason: self.render_reason(value, "") } + } + } + + fn deny_if_unmatched_regex(&self, value: &str, pats: &[&'static str]) -> GateDecision { + if pats.is_empty() { + return GateDecision::Allow; + } + if pats.iter().any(|raw| compile(raw).is_match(value)) { + GateDecision::Allow + } else { + GateDecision::Deny { reason: self.render_reason(value, "") } + } + } + + fn decide_scope(&self, value: &str, pats: &[String]) -> GateDecision { + match self.mode { + GateMode::DenyIfUnmatched if pats.is_empty() => GateDecision::Allow, + GateMode::DenyIfUnmatched => self.scope_whitelist_decide(value, pats), + GateMode::DenyIfMatch => self.scope_denylist_decide(value, pats), + GateMode::AllowIfMatch => GateDecision::NotApplicable, + } + } + + fn scope_whitelist_decide(&self, path: &str, pats: &[String]) -> GateDecision { + if pats.iter().any(|p| crate::simulated_merge::glob_match(p, path)) { + GateDecision::Allow + } else { + GateDecision::Deny { reason: self.render_reason(path, "") } + } + } + + fn scope_denylist_decide(&self, path: &str, pats: &[String]) -> GateDecision { + for pat in pats { + if crate::simulated_merge::glob_match(pat, path) { + return GateDecision::Deny { reason: self.render_reason(path, pat) }; + } + } + GateDecision::Allow + } + + fn render_reason(&self, value: &str, pat: &str) -> String { + self.deny_template + .replace("{name}", self.name) + .replace("{path}", value) + .replace("{cmd}", &truncate(value, 60)) + .replace("{pat}", pat) + } +} + +/// Compile + cache regex across calls. Per-process HashMap guarded by Mutex. +fn compile(raw: &str) -> Regex { + static CACHE: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); + let mut guard = match CACHE.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + if let Some(rx) = guard.get(raw) { + return rx.clone(); + } + let rx = Regex::new(raw).expect("pattern_gate: regex compile failed"); + guard.insert(raw.to_string(), rx.clone()); + rx +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() > max { + format!("{}…", &s[..max]) + } else { + s.to_string() + } +} 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 index 680f2c4..12b99dc 100644 --- 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 @@ -2,48 +2,21 @@ //! //! Denies any Bash command matching `git`, `gh repo`, `gh api /repos`. //! Bypass via env `ORCHESTRATOR_META=1` for orchestrator-meta agents. +//! +//! As of v0.18 convergence wave: thin const wrapper over `PatternGate`. -use crate::capability::*; -use once_cell::sync::Lazy; -use regex::Regex; +use super::pattern_gate::{GateMode, PatternGate, PatternSource}; -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 - } -} +pub const NO_GIT_OPS: PatternGate = PatternGate { + name: "policy::no-git-ops", + tools: &["Bash"], + field: "command", + mode: GateMode::DenyIfMatch, + patterns: PatternSource::StaticRegex(&[ + r"(?m)(?:^|[;&|]|\s)git(?:\s|$)", + r"(?m)(?:^|[;&|]|\s)gh\s+repo", + r"(?m)(?:^|[;&|]|\s)gh\s+api\s+/?repos", + ]), + bypass_env: Some("ORCHESTRATOR_META"), + deny_template: "RULE 0.13 — git operation blocked (pattern {pat})", +}; 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 index dd29384..2a216b3 100644 --- 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 @@ -1,35 +1,19 @@ -//! `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). +//! `safety::no-dep-bump` gate — PreToolUse:Edit|Write denies edits to +//! Cargo.toml / Cargo.lock unless `ALLOW_DEP_BUMP=1` is in the env. +//! +//! As of v0.18 convergence wave: thin const wrapper over `PatternGate`. -use crate::capability::*; +use super::pattern_gate::{GateMode, PatternGate, PatternSource}; -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) -} +pub const NO_DEP_BUMP_GATE: PatternGate = PatternGate { + name: "safety::no-dep-bump", + tools: &["Edit", "Write", "MultiEdit"], + field: "file_path", + mode: GateMode::DenyIfMatch, + patterns: PatternSource::StaticRegex(&[ + r"(^|[/\\])Cargo\.toml$", + r"(^|[/\\])Cargo\.lock$", + ]), + bypass_env: Some("ALLOW_DEP_BUMP"), + deny_template: "safety::no-dep-bump — {path} edit blocked (set ALLOW_DEP_BUMP=1 to override)", +}; 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 index fc382ed..b649ba1 100644 --- a/_primitives/_rust/kei-agent-runtime/src/gates/scope_files_denylist.rs +++ b/_primitives/_rust/kei-agent-runtime/src/gates/scope_files_denylist.rs @@ -1,35 +1,16 @@ //! `scope::files-denylist` — PreToolUse:Edit|Write denies paths matching //! `task.scope.files-denylist` globs. Overrides whitelist. +//! +//! As of v0.18 convergence wave: thin const wrapper over `PatternGate`. -use crate::capability::*; +use super::pattern_gate::{GateMode, PatternGate, PatternSource}; -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") -} +pub const FILES_DENYLIST: PatternGate = PatternGate { + name: "scope::files-denylist", + tools: &["Edit", "Write", "MultiEdit", "NotebookEdit"], + field: "file_path", + mode: GateMode::DenyIfMatch, + patterns: PatternSource::TaskDenylist, + bypass_env: None, + deny_template: "scope violation — {path} matches files-denylist ({pat})", +}; 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 index 647d06e..e618f45 100644 --- a/_primitives/_rust/kei-agent-runtime/src/gates/scope_files_whitelist.rs +++ b/_primitives/_rust/kei-agent-runtime/src/gates/scope_files_whitelist.rs @@ -1,37 +1,16 @@ //! `scope::files-whitelist` — PreToolUse:Edit|Write denies paths outside -//! `task.scope.files-whitelist` globs. +//! `task.scope.files-whitelist` globs. Empty list = not applicable (allow). +//! +//! As of v0.18 convergence wave: thin const wrapper over `PatternGate`. -use crate::capability::*; +use super::pattern_gate::{GateMode, PatternGate, PatternSource}; -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") -} +pub const FILES_WHITELIST: PatternGate = PatternGate { + name: "scope::files-whitelist", + tools: &["Edit", "Write", "MultiEdit", "NotebookEdit"], + field: "file_path", + mode: GateMode::DenyIfUnmatched, + patterns: PatternSource::TaskWhitelist, + bypass_env: None, + deny_template: "scope violation — {path} not in files-whitelist", +}; diff --git a/_primitives/_rust/kei-agent-runtime/src/gates/tools_bash_allowlist.rs b/_primitives/_rust/kei-agent-runtime/src/gates/tools_bash_allowlist.rs index e0e1125..62773b3 100644 --- a/_primitives/_rust/kei-agent-runtime/src/gates/tools_bash_allowlist.rs +++ b/_primitives/_rust/kei-agent-runtime/src/gates/tools_bash_allowlist.rs @@ -1,58 +1,27 @@ //! `tools::bash-allowlist` — PreToolUse:Bash denies commands not matching //! one of the configured allowlist regexes. //! -//! Renamed from `tools::cargo-only-bash` in v0.17. The old name described -//! only the default pattern set; the new name describes the mechanism (an -//! allowlist over the Bash argv). Old name still resolves via registry alias. +//! Renamed from `tools::cargo-only-bash` in v0.17. Old name still resolves +//! via registry alias. +//! +//! As of v0.18 convergence wave: thin const wrapper over `PatternGate`. -use crate::capability::*; -use once_cell::sync::Lazy; -use regex::Regex; +use super::pattern_gate::{GateMode, PatternGate, PatternSource}; -pub struct BashAllowlist; - -/// 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 BashAllowlist { - fn name(&self) -> &'static str { - "tools::bash-allowlist" - } - - 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::bash-allowlist — `{}` not in allowlist", truncate(cmd)), - } - } - } -} - -fn truncate(s: &str) -> String { - if s.len() > 60 { - format!("{}…", &s[..60]) - } else { - s.to_string() - } -} +pub const BASH_ALLOWLIST: PatternGate = PatternGate { + name: "tools::bash-allowlist", + tools: &["Bash"], + field: "command", + mode: GateMode::AllowIfMatch, + patterns: PatternSource::StaticRegex(&[ + r"^\s*cargo(\s|$)", + r"^\s*rustc(\s|$)", + r"^\s*rustup(\s|$)", + r"^\s*mkdir(\s|$)", + r"^\s*rm\s+-rf\s+/tmp/", + r"^\s*ls(\s|$)", + r"^\s*pwd(\s|$)", + ]), + bypass_env: None, + deny_template: "tools::bash-allowlist — `{cmd}` not in allowlist", +}; diff --git a/_primitives/_rust/kei-agent-runtime/src/registry.rs b/_primitives/_rust/kei-agent-runtime/src/registry.rs index 6050bf3..d33cc89 100644 --- a/_primitives/_rust/kei-agent-runtime/src/registry.rs +++ b/_primitives/_rust/kei-agent-runtime/src/registry.rs @@ -16,6 +16,13 @@ //! Alias resolution is transparent: `get()` / `get_gate()` / `get_verify()` //! return the new implementation when queried with the old name. The new //! name is what the impl reports via `Capability::name()`. +//! +//! ## Convergence wave v0.18 +//! +//! 5 of 6 gates + 3 of 8 verifies are now `const PatternGate { … }` / +//! `const CommandVerify { … }` declarations. Registry points at the +//! const by reference (`&POLICY_NO_GIT_OPS_GATE`) — same `&'static dyn Capability` +//! dispatch shape as before. use crate::capability::Capability; use crate::gates; @@ -95,24 +102,14 @@ pub fn get_verify(name: &str) -> Option<&'static dyn Capability> { /// Gate-only lookup by canonical name (no alias resolution, no warning). fn get_gate_canonical(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_DENY_TOOLS: gates::tools_deny_tools::DenyTools = gates::tools_deny_tools::DenyTools; - static TOOLS_BASH_ALLOWLIST: gates::tools_bash_allowlist::BashAllowlist = - gates::tools_bash_allowlist::BashAllowlist; 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), + "policy::no-git-ops" => Some(&gates::policy_no_git_ops::NO_GIT_OPS), + "scope::files-whitelist" => Some(&gates::scope_files_whitelist::FILES_WHITELIST), + "scope::files-denylist" => Some(&gates::scope_files_denylist::FILES_DENYLIST), + "safety::no-dep-bump" => Some(&gates::safety_no_dep_bump::NO_DEP_BUMP_GATE), "tools::deny-tools" => Some(&TOOLS_DENY_TOOLS), - "tools::bash-allowlist" => Some(&TOOLS_BASH_ALLOWLIST), + "tools::bash-allowlist" => Some(&gates::tools_bash_allowlist::BASH_ALLOWLIST), _ => None, } } @@ -121,11 +118,6 @@ fn get_gate_canonical(name: &str) -> Option<&'static dyn Capability> { fn get_verify_canonical(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 = @@ -136,9 +128,9 @@ fn get_verify_canonical(name: &str) -> Option<&'static dyn Capability> { 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), + "quality::cargo-check-green" => Some(&verifies::quality_cargo_check_green::CARGO_CHECK_GREEN), + "quality::tests-green" => Some(&verifies::quality_tests_green::TESTS_GREEN), + "safety::no-dep-bump" => Some(&verifies::safety_no_dep_bump::NO_DEP_BUMP_VERIFY), "scope::files-whitelist" => Some(&WL_V), "scope::files-denylist" => Some(&DL_V), "output::report-format" => Some(&RF), diff --git a/_primitives/_rust/kei-agent-runtime/src/verifies/command_verify.rs b/_primitives/_rust/kei-agent-runtime/src/verifies/command_verify.rs new file mode 100644 index 0000000..551dc15 --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/src/verifies/command_verify.rs @@ -0,0 +1,142 @@ +//! Generic command-driven on-return verify (Layer D convergence, 2026-04-23). +//! +//! Absorbs the "run external command, check exit, optionally parse output" +//! shape shared by `quality::cargo-check-green`, `quality::tests-green`, +//! `safety::no-dep-bump` verify. Each concrete verify is now a +//! `CommandVerify` const declaration in its own file; execution logic lives +//! here. +//! +//! `quality::constructor-pattern` (LOC walker) and the `output::*` verifies +//! (parse agent report, no subprocess) stay in their own modules. + +use crate::capability::*; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +/// Where to run the command from. +#[derive(Clone, Copy)] +pub enum WorkDir { + /// `/_primitives/_rust` if it exists, else ``. + WorkspaceRoot, + /// Raw `ctx.run_dir()`. + RunDir, + /// Raw `ctx.worktree_path` (pre-merge, for git-diff verifies). + WorktreePath, +} + +/// Decides how to build argv from `ctx` + config. +pub type ArgBuilder = fn(&VerifyContext, &CommandVerify) -> Vec; + +/// Post-processes `Output` into a `VerifyResult`. Default = exit-code check. +pub type ResultMapper = fn(&CommandVerify, &VerifyContext, &Output) -> VerifyResult; + +/// Generic command-runner verify capability. +pub struct CommandVerify { + pub name: &'static str, + /// Executable name (e.g. "cargo", "git"). + pub program: &'static str, + /// Literal args joined before per-crate / per-target dispatch. Used by + /// `default_args` to produce the argv. + pub base_args: &'static [&'static str], + pub work_dir: WorkDir, + pub expected_exit: i32, + /// Human-readable failure reason when exit != expected. + pub fail_reason: &'static str, + /// If set, overrides the default "one shot, expected_exit" runner. + /// Used by `quality::tests-green` (per-crate loop) + `safety::no-dep-bump` + /// verify (regex over diff). + pub custom_runner: Option VerifyResult>, + /// If set, overrides `default_args`. + pub arg_builder: Option, + /// If set, overrides `default_result_mapper`. + pub result_mapper: Option, +} + +impl Capability for CommandVerify { + fn name(&self) -> &'static str { + self.name + } + + fn verify(&self, ctx: &VerifyContext) -> VerifyResult { + if let Some(runner) = self.custom_runner { + return runner(self, ctx); + } + self.run_once(ctx) + } +} + +impl CommandVerify { + fn run_once(&self, ctx: &VerifyContext) -> VerifyResult { + let dir = self.resolve_dir(ctx); + let args = self.build_args(ctx); + let out = Command::new(self.program).args(&args).current_dir(&dir).output(); + match out { + Err(e) => VerifyResult::Fail { + reason: format!("{} invocation failed", self.program), + detail: Some(e.to_string()), + }, + Ok(o) => self.map_result(ctx, &o), + } + } + + pub fn resolve_dir(&self, ctx: &VerifyContext) -> PathBuf { + match self.work_dir { + WorkDir::WorkspaceRoot => { + let primitives = ctx.run_dir().join("_primitives/_rust"); + if primitives.is_dir() { + primitives + } else { + ctx.run_dir() + } + } + WorkDir::RunDir => ctx.run_dir(), + WorkDir::WorktreePath => ctx.worktree_path.to_path_buf(), + } + } + + fn build_args(&self, ctx: &VerifyContext) -> Vec { + if let Some(f) = self.arg_builder { + f(ctx, self) + } else { + self.base_args.iter().map(|s| s.to_string()).collect() + } + } + + fn map_result(&self, ctx: &VerifyContext, out: &Output) -> VerifyResult { + if let Some(f) = self.result_mapper { + return f(self, ctx, out); + } + default_exit_mapper(self, out) + } +} + +/// Default result mapper: pass iff exit == `expected_exit`, else Fail with +/// stderr tail. +pub fn default_exit_mapper(cv: &CommandVerify, out: &Output) -> VerifyResult { + let actual = out.status.code().unwrap_or(-1); + if actual == cv.expected_exit { + VerifyResult::Pass + } else { + VerifyResult::Fail { + reason: cv.fail_reason.to_string(), + detail: Some(tail(&out.stderr, 10)), + } + } +} + +/// Utility: last `n` lines of `bytes` as a String (lossy utf-8). +pub 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") +} + +/// Helper: run `cmd args...` in `dir`, return Output or stringified err. +pub fn run_in(dir: &Path, program: &str, args: &[&str]) -> Result { + Command::new(program) + .args(args) + .current_dir(dir) + .output() + .map_err(|e| e.to_string()) +} diff --git a/_primitives/_rust/kei-agent-runtime/src/verifies/mod.rs b/_primitives/_rust/kei-agent-runtime/src/verifies/mod.rs index 94d40da..47a4b9a 100644 --- a/_primitives/_rust/kei-agent-runtime/src/verifies/mod.rs +++ b/_primitives/_rust/kei-agent-runtime/src/verifies/mod.rs @@ -1,8 +1,13 @@ //! On-return verify capabilities. //! -//! Each module holds one zero-sized `impl Capability` struct implementing -//! only `verify()`. Registry exposes them by `name()`. +//! After v0.18 convergence wave: 3 command-driven verifies +//! (`quality::cargo-check-green`, `quality::tests-green`, +//! `safety::no-dep-bump`) are `CommandVerify` const wrappers. The LOC +//! walker (`quality::constructor-pattern`), the two report-parser +//! verifies (`output::*`), and the two git-diff scope verifies stay in +//! their own modules — shape too divergent to fold into `CommandVerify`. +pub mod command_verify; pub mod output_report_format; pub mod output_severity_grade; pub mod quality_cargo_check_green; 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 index 8b1a7d0..455035b 100644 --- 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 @@ -1,41 +1,18 @@ //! `quality::cargo-check-green` — runs `cargo check --workspace` in //! `/_primitives/_rust` and reports failure tail on non-zero exit. +//! +//! As of v0.18 convergence wave: thin const wrapper over `CommandVerify`. -use crate::capability::*; -use std::process::Command; +use super::command_verify::{CommandVerify, WorkDir}; -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") -} +pub const CARGO_CHECK_GREEN: CommandVerify = CommandVerify { + name: "quality::cargo-check-green", + program: "cargo", + base_args: &["check", "--workspace"], + work_dir: WorkDir::WorkspaceRoot, + expected_exit: 0, + fail_reason: "cargo check --workspace FAILED — agent-local green ≠ integration green", + custom_runner: None, + arg_builder: None, + result_mapper: None, +}; 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 index 3a51d26..60e5548 100644 --- a/_primitives/_rust/kei-agent-runtime/src/verifies/quality_tests_green.rs +++ b/_primitives/_rust/kei-agent-runtime/src/verifies/quality_tests_green.rs @@ -1,54 +1,66 @@ //! `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. +//! lines; asserts total count ≥ `test_count_min` when set. +//! +//! As of v0.18 convergence wave: `CommandVerify` wrapper with a custom +//! per-crate runner (default exit-check shape doesn't fit the loop). -use crate::capability::*; +use super::command_verify::{tail, CommandVerify, WorkDir}; +use crate::capability::{VerifyContext, VerifyResult}; use once_cell::sync::Lazy; use regex::Regex; +use std::path::Path; use std::process::Command; -pub struct TestsGreen; +pub const TESTS_GREEN: CommandVerify = CommandVerify { + name: "quality::tests-green", + program: "cargo", + base_args: &[], + work_dir: WorkDir::WorkspaceRoot, + expected_exit: 0, + fail_reason: "cargo test FAILED", + custom_runner: Some(run_tests_green), + arg_builder: None, + result_mapper: None, +}; 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 run_tests_green(cv: &CommandVerify, ctx: &VerifyContext) -> VerifyResult { + let crates = &ctx.task.verification.cargo_test_crates; + if crates.is_empty() { + return VerifyResult::Pass; } - - 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 { + let dir = cv.resolve_dir(ctx); + 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!("test count {total_passed} < min {min}"), - detail: None, + reason: format!("cargo test -p {crate_name} FAILED"), + detail: Some(detail), }; } } - VerifyResult::Pass } + enforce_min(total_passed, ctx) } -fn run_test(dir: &std::path::Path, crate_name: &str) -> Result { +fn enforce_min(total: u64, ctx: &VerifyContext) -> VerifyResult { + if let Some(min) = ctx.task.verification.test_count_min { + if total < min as u64 { + return VerifyResult::Fail { + reason: format!("test count {total} < min {min}"), + detail: None, + }; + } + } + VerifyResult::Pass +} + +fn run_test(dir: &Path, crate_name: &str) -> Result { let out = Command::new("cargo") .arg("test") .arg("-p") @@ -66,10 +78,3 @@ fn run_test(dir: &std::path::Path, crate_name: &str) -> Result { .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 index 8a1b7c2..223f157 100644 --- 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 @@ -1,39 +1,47 @@ //! `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. +//! +//! As of v0.18 convergence wave: `CommandVerify` wrapper with a custom +//! runner (git-diff + regex, not a plain exit-code check). -use crate::capability::*; +use super::command_verify::{CommandVerify, WorkDir}; +use crate::capability::{VerifyContext, VerifyResult}; use crate::simulated_merge::run_git; use once_cell::sync::Lazy; use regex::Regex; -pub struct NoDepBumpVerify; +pub const NO_DEP_BUMP_VERIFY: CommandVerify = CommandVerify { + name: "safety::no-dep-bump", + program: "git", + base_args: &[], + work_dir: WorkDir::WorktreePath, + expected_exit: 0, + fail_reason: "safety::no-dep-bump — version bump detected", + custom_runner: Some(run_no_dep_bump), + arg_builder: None, + result_mapper: None, +}; 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())); - } +fn run_no_dep_bump(_cv: &CommandVerify, 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")), - } + } + 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/tests/command_verify_smoke.rs b/_primitives/_rust/kei-agent-runtime/tests/command_verify_smoke.rs new file mode 100644 index 0000000..e639c4f --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/tests/command_verify_smoke.rs @@ -0,0 +1,131 @@ +//! Smoke tests for the generic `CommandVerify` (Layer D convergence). +//! +//! Covers default exit-code mapper (pass + fail), custom runner path, +//! and WorkDir resolution. + +use kei_agent_runtime::capability::{Capability, RunMode, TaskSpec, VerifyContext, VerifyResult}; +use kei_agent_runtime::verifies::command_verify::{CommandVerify, WorkDir}; +use std::path::Path; +use tempfile::TempDir; + +fn vctx<'a>(task: &'a TaskSpec, worktree: &'a Path, agent_id: &'a str) -> VerifyContext<'a> { + VerifyContext { + agent_id, + task, + worktree_path: worktree, + main_repo: worktree, + run_mode: RunMode::Worktree, + simulated_merge_path: None, + } +} + +const TRUE_VERIFY: CommandVerify = CommandVerify { + name: "test::true", + program: "true", + base_args: &[], + work_dir: WorkDir::RunDir, + expected_exit: 0, + fail_reason: "true somehow failed", + custom_runner: None, + arg_builder: None, + result_mapper: None, +}; + +const FALSE_VERIFY: CommandVerify = CommandVerify { + name: "test::false", + program: "false", + base_args: &[], + work_dir: WorkDir::RunDir, + expected_exit: 0, + fail_reason: "false returned non-zero", + custom_runner: None, + arg_builder: None, + result_mapper: None, +}; + +#[test] +fn default_runner_passes_on_zero_exit() { + let tmp = TempDir::new().unwrap(); + let task = TaskSpec::default(); + let ctx = vctx(&task, tmp.path(), "t"); + assert_eq!(TRUE_VERIFY.verify(&ctx), VerifyResult::Pass); +} + +#[test] +fn default_runner_fails_on_nonzero_exit() { + let tmp = TempDir::new().unwrap(); + let task = TaskSpec::default(); + let ctx = vctx(&task, tmp.path(), "t"); + match FALSE_VERIFY.verify(&ctx) { + VerifyResult::Fail { .. } => {} + other => panic!("expected Fail, got {other:?}"), + } +} + +#[test] +fn custom_runner_is_invoked() { + const MARKER_VERIFY: CommandVerify = CommandVerify { + name: "test::custom", + program: "/does/not/matter", + base_args: &[], + work_dir: WorkDir::RunDir, + expected_exit: 0, + fail_reason: "", + custom_runner: Some(|_cv, _ctx| VerifyResult::Pass), + arg_builder: None, + result_mapper: None, + }; + let tmp = TempDir::new().unwrap(); + let task = TaskSpec::default(); + let ctx = vctx(&task, tmp.path(), "t"); + assert_eq!(MARKER_VERIFY.verify(&ctx), VerifyResult::Pass); +} + +#[test] +fn workspace_root_resolves_to_primitives_rust_when_present() { + let tmp = TempDir::new().unwrap(); + let prims = tmp.path().join("_primitives/_rust"); + std::fs::create_dir_all(&prims).unwrap(); + let cv = CommandVerify { + name: "test::dir", + program: "true", + base_args: &[], + work_dir: WorkDir::WorkspaceRoot, + expected_exit: 0, + fail_reason: "", + custom_runner: None, + arg_builder: None, + result_mapper: None, + }; + let task = TaskSpec::default(); + let ctx = vctx(&task, tmp.path(), "t"); + let resolved = cv.resolve_dir(&ctx); + assert_eq!(resolved, prims); +} + +#[test] +fn workspace_root_falls_back_to_run_dir() { + let tmp = TempDir::new().unwrap(); + let cv = CommandVerify { + name: "test::dir-fallback", + program: "true", + base_args: &[], + work_dir: WorkDir::WorkspaceRoot, + expected_exit: 0, + fail_reason: "", + custom_runner: None, + arg_builder: None, + result_mapper: None, + }; + let task = TaskSpec::default(); + let ctx = vctx(&task, tmp.path(), "t"); + let resolved = cv.resolve_dir(&ctx); + assert_eq!(resolved, tmp.path().to_path_buf()); +} + +#[test] +fn cargo_check_green_const_is_registered() { + use kei_agent_runtime::registry; + let c = registry::get_verify("quality::cargo-check-green").unwrap(); + assert_eq!(c.name(), "quality::cargo-check-green"); +} diff --git a/_primitives/_rust/kei-agent-runtime/tests/pattern_gate_smoke.rs b/_primitives/_rust/kei-agent-runtime/tests/pattern_gate_smoke.rs new file mode 100644 index 0000000..a0ca317 --- /dev/null +++ b/_primitives/_rust/kei-agent-runtime/tests/pattern_gate_smoke.rs @@ -0,0 +1,140 @@ +//! Smoke tests for the generic `PatternGate` (Layer C convergence). +//! +//! Covers DenyIfMatch, AllowIfMatch, DenyIfUnmatched (scope), bypass env, +//! and non-applicable tool short-circuit. + +use kei_agent_runtime::capability::{Capability, GateContext, GateDecision, TaskSpec}; +use kei_agent_runtime::gates::pattern_gate::{GateMode, PatternGate, PatternSource}; +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 } +} + +const TEST_GATE: PatternGate = PatternGate { + name: "test::deny-if-match", + tools: &["Bash"], + field: "command", + mode: GateMode::DenyIfMatch, + patterns: PatternSource::StaticRegex(&[r"\bforbidden\b"]), + bypass_env: Some("TEST_BYPASS"), + deny_template: "{name} — {cmd} matched {pat}", +}; + +#[test] +fn deny_if_match_blocks_forbidden() { + let task = TaskSpec::default(); + let env = HashMap::new(); + let input = json!({"command": "run forbidden action"}); + match TEST_GATE.check(&ctx("Bash", &input, &task, &env)) { + GateDecision::Deny { .. } => {} + other => panic!("expected Deny, got {other:?}"), + } +} + +#[test] +fn deny_if_match_allows_when_no_match() { + let task = TaskSpec::default(); + let env = HashMap::new(); + let input = json!({"command": "echo hello"}); + assert_eq!( + TEST_GATE.check(&ctx("Bash", &input, &task, &env)), + GateDecision::Allow + ); +} + +#[test] +fn allow_if_match_allows_on_match() { + // Mirrors tools::bash-allowlist — use the real registry path. + use kei_agent_runtime::registry; + let g = registry::get_gate("tools::bash-allowlist").unwrap(); + let task = TaskSpec::default(); + let env = HashMap::new(); + let input = json!({"command": "cargo build"}); + assert_eq!(g.check(&ctx("Bash", &input, &task, &env)), GateDecision::Allow); +} + +#[test] +fn allow_if_match_denies_on_miss() { + use kei_agent_runtime::registry; + let g = registry::get_gate("tools::bash-allowlist").unwrap(); + let task = TaskSpec::default(); + let env = HashMap::new(); + let input = json!({"command": "wget http://evil"}); + matches!(g.check(&ctx("Bash", &input, &task, &env)), GateDecision::Deny { .. }); +} + +#[test] +fn deny_if_unmatched_scope_whitelist_empty_allows() { + // Empty whitelist = NotApplicable / Allow per spec. + use kei_agent_runtime::registry; + let g = registry::get_gate("scope::files-whitelist").unwrap(); + let task = TaskSpec::default(); // empty whitelist + let env = HashMap::new(); + let input = json!({"file_path": "anywhere/foo.rs"}); + assert_eq!(g.check(&ctx("Edit", &input, &task, &env)), GateDecision::Allow); +} + +#[test] +fn bypass_env_allows_even_on_match() { + let task = TaskSpec::default(); + let mut env = HashMap::new(); + env.insert("TEST_BYPASS".into(), "1".into()); + let input = json!({"command": "run forbidden action"}); + assert_eq!( + TEST_GATE.check(&ctx("Bash", &input, &task, &env)), + GateDecision::Allow + ); +} + +#[test] +fn non_matching_tool_is_not_applicable() { + let task = TaskSpec::default(); + let env = HashMap::new(); + let input = json!({"command": "run forbidden action"}); + assert_eq!( + TEST_GATE.check(&ctx("Read", &input, &task, &env)), + GateDecision::NotApplicable + ); +} + +#[test] +fn missing_field_is_not_applicable() { + let task = TaskSpec::default(); + let env = HashMap::new(); + let input = json!({"not_the_field": "forbidden"}); + assert_eq!( + TEST_GATE.check(&ctx("Bash", &input, &task, &env)), + GateDecision::NotApplicable + ); +} + +#[test] +fn safety_no_dep_bump_blocks_cargo_toml_path() { + use kei_agent_runtime::registry; + let g = registry::get_gate("safety::no-dep-bump").unwrap(); + let task = TaskSpec::default(); + let env = HashMap::new(); + let input = json!({"file_path": "nested/Cargo.toml"}); + match g.check(&ctx("Edit", &input, &task, &env)) { + GateDecision::Deny { .. } => {} + other => panic!("expected Deny, got {other:?}"), + } +} + +#[test] +fn safety_no_dep_bump_allows_bypass_env() { + use kei_agent_runtime::registry; + let g = registry::get_gate("safety::no-dep-bump").unwrap(); + let task = TaskSpec::default(); + let mut env = HashMap::new(); + env.insert("ALLOW_DEP_BUMP".into(), "1".into()); + let input = json!({"file_path": "Cargo.toml"}); + assert_eq!(g.check(&ctx("Edit", &input, &task, &env)), GateDecision::Allow); +}