M0: scaffold Cargo workspace, core types, event bus, permission engine, storage actor

Seven-crate workspace per docs/01-architecture.md. harness-core gets the
domain types (Session/Message/Part/ToolState), a broadcast EventBus, the
last-match-wins wildcard permission evaluator, and a SQLite storage actor
(dedicated thread + mpsc, JSON-blob rows) with roundtrip tests. All other
crates are compiling stubs. CI runs fmt/clippy -D warnings/test.
This commit is contained in:
Erik Simon
2026-07-08 16:28:42 +02:00
commit 8b16348b4c
44 changed files with 2988 additions and 0 deletions
@@ -0,0 +1,3 @@
pub mod rule;
pub use rule::{evaluate, Action, Rule, Ruleset};
+115
View File
@@ -0,0 +1,115 @@
use globset::GlobBuilder;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Action {
Allow,
Deny,
Ask,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Rule {
pub permission: String,
pub pattern: String,
pub action: Action,
}
pub type Ruleset = Vec<Rule>;
/// Wildcard match, `*` crosses separators (opencode/globset `literal_separator(false)`).
fn glob_match(pattern: &str, value: &str) -> bool {
if pattern == "*" {
return true;
}
GlobBuilder::new(pattern)
.literal_separator(false)
.build()
.map(|g| g.compile_matcher().is_match(value))
.unwrap_or(false)
}
/// Flatten `stack` (lowest to highest precedence) and return the action of the *last*
/// rule whose `permission` and `pattern` both wildcard-match. Defaults to `Ask`.
pub fn evaluate(stack: &[&Ruleset], permission: &str, pattern: &str) -> Action {
let mut result = Action::Ask;
for ruleset in stack {
for rule in ruleset.iter() {
if glob_match(&rule.permission, permission) && glob_match(&rule.pattern, pattern) {
result = rule.action;
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
fn rule(permission: &str, pattern: &str, action: Action) -> Rule {
Rule {
permission: permission.to_string(),
pattern: pattern.to_string(),
action,
}
}
#[test]
fn defaults_to_ask_when_nothing_matches() {
let rules: Ruleset = vec![rule("bash", "ls*", Action::Allow)];
assert_eq!(evaluate(&[&rules], "bash", "rm -rf /"), Action::Ask);
}
#[test]
fn exact_match_wins() {
let rules: Ruleset = vec![rule("edit", "*.lock", Action::Deny)];
assert_eq!(evaluate(&[&rules], "edit", "Cargo.lock"), Action::Deny);
assert_eq!(evaluate(&[&rules], "edit", "main.rs"), Action::Ask);
}
#[test]
fn wildcard_crosses_path_separators() {
let rules: Ruleset = vec![rule("read", "src/*", Action::Allow)];
assert_eq!(evaluate(&[&rules], "read", "src/a/b/c.rs"), Action::Allow);
}
#[test]
fn last_match_within_a_ruleset_wins() {
let rules: Ruleset = vec![
rule("bash", "git *", Action::Allow),
rule("bash", "git push*", Action::Deny),
];
assert_eq!(evaluate(&[&rules], "bash", "git status"), Action::Allow);
assert_eq!(evaluate(&[&rules], "bash", "git push origin"), Action::Deny);
}
#[test]
fn later_ruleset_in_the_stack_overrides_earlier_ones() {
let config: Ruleset = vec![rule("edit", "*", Action::Ask)];
let agent: Ruleset = vec![rule("edit", "*", Action::Deny)];
let session: Ruleset = vec![rule("edit", "main.rs", Action::Allow)];
// config < agent < session precedence, matching engine's [config, agent, session] stack.
assert_eq!(
evaluate(&[&config, &agent, &session], "edit", "main.rs"),
Action::Allow
);
assert_eq!(
evaluate(&[&config, &agent, &session], "edit", "other.rs"),
Action::Deny
);
}
#[test]
fn permission_field_itself_can_wildcard_match() {
let rules: Ruleset = vec![rule("mcp", "*", Action::Ask)];
assert_eq!(evaluate(&[&rules], "mcp", "context7_search"), Action::Ask);
}
#[test]
fn empty_stack_defaults_to_ask() {
assert_eq!(evaluate(&[], "bash", "ls"), Action::Ask);
}
}