feat(convergence/p2): PatternGate + CommandVerify unified traits
Layer C + D. 6 gate modules + 3 verify modules → 1 generic struct per
family + const declarations. Data-driven, not module-per-capability.
New:
- src/gates/pattern_gate.rs (192 LOC) — PatternGate struct with
GateMode::{DenyIfMatch, AllowIfMatch}, static regex compilation via
once_cell, bypass env support, Capability trait impl
- src/verifies/command_verify.rs (142 LOC) — CommandVerify struct with
WorkDir::{WorkspaceRoot, CrateDir, MainRepoSub}, subprocess exec +
exit-code check, extra_env support, Capability trait impl
Converted (const declarations, ~15-27 LOC each):
- gates/policy_no_git_ops.rs (49→22)
- gates/safety_no_dep_bump.rs (35→19)
- gates/scope_files_whitelist.rs (37→16)
- gates/scope_files_denylist.rs (35→16)
- gates/tools_bash_allowlist.rs (58→27)
- verifies/quality_cargo_check_green.rs (41→18)
- verifies/quality_tests_green.rs (75→80, folded common shape)
- verifies/safety_no_dep_bump.rs (39→47)
Kept separate (different shape, not PatternGate/CommandVerify):
- gates/tools_deny_tools.rs (tool-name match, not pattern)
- verifies/quality_constructor_pattern.rs (LOC walker)
- verifies/output/report_format.rs + severity_grade.rs (text parsers)
- verifies/scope_files_{whitelist,denylist}.rs (diff-walkers)
Registry.rs preserves alias table + deprecation warnings + all_names().
Tests: 57/57 green (was 41, +16: 10 pattern_gate_smoke + 6 command_verify_smoke).
LOC net: 5 gates 214→100 (-53%), shared PatternGate+CommandVerify 334
LOC absorb duplication. Amortization breaks even around 3-4 new gates
added later.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
652d6a369b
commit
360a20a942
15 changed files with 800 additions and 320 deletions
|
|
@ -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;
|
||||
|
|
|
|||
192
_primitives/_rust/kei-agent-runtime/src/gates/pattern_gate.rs
Normal file
192
_primitives/_rust/kei-agent-runtime/src/gates/pattern_gate.rs
Normal file
|
|
@ -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<String, String>) -> 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<String> {
|
||||
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, "<none>") }
|
||||
}
|
||||
}
|
||||
|
||||
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, "<unmatched>") }
|
||||
}
|
||||
}
|
||||
|
||||
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, "<whitelist>") }
|
||||
}
|
||||
}
|
||||
|
||||
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<Mutex<HashMap<String, Regex>>> = 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Vec<Regex>> = Lazy::new(|| {
|
||||
vec![
|
||||
Regex::new(r"(?m)(?:^|[;&|]|\s)git(?:\s|$)").unwrap(),
|
||||
Regex::new(r"(?m)(?:^|[;&|]|\s)gh\s+repo").unwrap(),
|
||||
Regex::new(r"(?m)(?:^|[;&|]|\s)gh\s+api\s+/?repos").unwrap(),
|
||||
]
|
||||
});
|
||||
|
||||
impl Capability for NoGitOps {
|
||||
fn name(&self) -> &'static str {
|
||||
"policy::no-git-ops"
|
||||
}
|
||||
|
||||
fn check(&self, ctx: &GateContext) -> GateDecision {
|
||||
if ctx.tool_name != "Bash" {
|
||||
return GateDecision::NotApplicable;
|
||||
}
|
||||
if ctx.env.get("ORCHESTRATOR_META").map(|v| v == "1").unwrap_or(false) {
|
||||
return GateDecision::Allow;
|
||||
}
|
||||
let cmd = ctx
|
||||
.tool_input
|
||||
.get("command")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
for pat in GIT_PATTERNS.iter() {
|
||||
if pat.is_match(cmd) {
|
||||
return GateDecision::Deny {
|
||||
reason: format!(
|
||||
"RULE 0.13 — git operation blocked (pattern {})",
|
||||
pat.as_str()
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
GateDecision::Allow
|
||||
}
|
||||
}
|
||||
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})",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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})",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<Vec<Regex>> = Lazy::new(|| {
|
||||
vec![
|
||||
Regex::new(r"^\s*cargo(\s|$)").unwrap(),
|
||||
Regex::new(r"^\s*rustc(\s|$)").unwrap(),
|
||||
Regex::new(r"^\s*rustup(\s|$)").unwrap(),
|
||||
Regex::new(r"^\s*mkdir(\s|$)").unwrap(),
|
||||
Regex::new(r"^\s*rm\s+-rf\s+/tmp/").unwrap(),
|
||||
Regex::new(r"^\s*ls(\s|$)").unwrap(),
|
||||
Regex::new(r"^\s*pwd(\s|$)").unwrap(),
|
||||
]
|
||||
});
|
||||
|
||||
impl Capability for 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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
/// `<run_dir>/_primitives/_rust` if it exists, else `<run_dir>`.
|
||||
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<String>;
|
||||
|
||||
/// 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<fn(&CommandVerify, &VerifyContext) -> VerifyResult>,
|
||||
/// If set, overrides `default_args`.
|
||||
pub arg_builder: Option<ArgBuilder>,
|
||||
/// If set, overrides `default_result_mapper`.
|
||||
pub result_mapper: Option<ResultMapper>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<Output, String> {
|
||||
Command::new(program)
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,41 +1,18 @@
|
|||
//! `quality::cargo-check-green` — runs `cargo check --workspace` in
|
||||
//! `<run_dir>/_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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,54 +1,66 @@
|
|||
//! `quality::tests-green` — runs `cargo test -p <crate>` for each crate in
|
||||
//! `task.verification.cargo-test-crates`; parses `test result: ok. N passed`
|
||||
//! line; asserts count ≥ `test_count_min` when set.
|
||||
//! 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<Regex> =
|
||||
Lazy::new(|| Regex::new(r"test result: ok\. (\d+) passed").unwrap());
|
||||
|
||||
impl Capability for TestsGreen {
|
||||
fn name(&self) -> &'static str {
|
||||
"quality::tests-green"
|
||||
fn 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<u64, String> {
|
||||
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<u64, String> {
|
||||
let out = Command::new("cargo")
|
||||
.arg("test")
|
||||
.arg("-p")
|
||||
|
|
@ -66,10 +78,3 @@ fn run_test(dir: &std::path::Path, crate_name: &str) -> Result<u64, String> {
|
|||
.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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Regex> =
|
||||
Lazy::new(|| Regex::new(r#"(?m)^[-+]\s*version\s*=\s*".+""#).unwrap());
|
||||
|
||||
impl Capability for NoDepBumpVerify {
|
||||
fn name(&self) -> &'static str {
|
||||
"safety::no-dep-bump"
|
||||
}
|
||||
|
||||
fn verify(&self, ctx: &VerifyContext) -> VerifyResult {
|
||||
let targets = ["Cargo.toml", "Cargo.lock"];
|
||||
let mut hits: Vec<String> = Vec::new();
|
||||
for t in targets.iter() {
|
||||
let args = ["diff", "main", "--", &format!("**/{t}"), t];
|
||||
if let Ok(diff) = run_git(ctx.worktree_path, &args) {
|
||||
for m in VERSION_LINE.find_iter(&diff) {
|
||||
hits.push(format!("{t}: {}", m.as_str()));
|
||||
}
|
||||
fn run_no_dep_bump(_cv: &CommandVerify, ctx: &VerifyContext) -> VerifyResult {
|
||||
let targets = ["Cargo.toml", "Cargo.lock"];
|
||||
let mut hits: Vec<String> = Vec::new();
|
||||
for t in targets.iter() {
|
||||
let args = ["diff", "main", "--", &format!("**/{t}"), t];
|
||||
if let Ok(diff) = run_git(ctx.worktree_path, &args) {
|
||||
for m in VERSION_LINE.find_iter(&diff) {
|
||||
hits.push(format!("{t}: {}", m.as_str()));
|
||||
}
|
||||
}
|
||||
if hits.is_empty() {
|
||||
VerifyResult::Pass
|
||||
} else {
|
||||
VerifyResult::Fail {
|
||||
reason: format!("{} dep-bump line(s) detected", hits.len()),
|
||||
detail: Some(hits.join("\n")),
|
||||
}
|
||||
}
|
||||
if hits.is_empty() {
|
||||
VerifyResult::Pass
|
||||
} else {
|
||||
VerifyResult::Fail {
|
||||
reason: format!("{} dep-bump line(s) detected", hits.len()),
|
||||
detail: Some(hits.join("\n")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,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");
|
||||
}
|
||||
140
_primitives/_rust/kei-agent-runtime/tests/pattern_gate_smoke.rs
Normal file
140
_primitives/_rust/kei-agent-runtime/tests/pattern_gate_smoke.rs
Normal file
|
|
@ -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<String, String>,
|
||||
) -> 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);
|
||||
}
|
||||
Loading…
Reference in a new issue