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:
Parfii-bot 2026-04-23 04:46:47 +08:00
parent 652d6a369b
commit 360a20a942
15 changed files with 800 additions and 320 deletions

View file

@ -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;

View 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()
}
}

View file

@ -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})",
};

View file

@ -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)",
};

View file

@ -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})",
};

View file

@ -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",
};

View file

@ -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",
};

View file

@ -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),

View file

@ -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())
}

View file

@ -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;

View file

@ -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,
};

View file

@ -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")
}

View file

@ -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")),
}
}
}

View file

@ -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");
}

View 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);
}