use std::collections::HashSet;
use glob::Pattern;
use crate::config::{AccessConfig, RoleConfig};
#[derive(Debug, Clone)]
pub struct RepoFilter {
allowed: Option<HashSet<String>>,
denied: HashSet<String>,
default_allow: bool,
denied_paths: Vec<Pattern>,
}
impl RepoFilter {
pub fn from_config(config: &AccessConfig, role: &str) -> Self {
if config.roles.is_empty() {
return Self::allow_all();
}
let matched = Self::find_matching_role(&config.roles, role);
match matched {
Some(role_config) => {
let allowed = if role_config.allow.is_empty() {
None } else {
Some(role_config.allow.iter().cloned().collect())
};
let denied = role_config.deny.iter().cloned().collect();
let denied_paths = role_config
.deny_paths
.iter()
.filter_map(|p| Pattern::new(p).ok())
.collect();
Self {
allowed,
denied,
default_allow: config.default_allow,
denied_paths,
}
}
None => {
Self {
allowed: None,
denied: HashSet::new(),
default_allow: config.default_allow,
denied_paths: Vec::new(),
}
}
}
}
pub fn allow_all() -> Self {
Self {
allowed: None,
denied: HashSet::new(),
default_allow: true,
denied_paths: Vec::new(),
}
}
pub fn is_allowed(&self, repo_name: &str) -> bool {
if self.matches_any(&self.denied, repo_name) {
return false;
}
if let Some(ref allowed) = self.allowed {
return self.matches_any(allowed, repo_name);
}
self.default_allow
}
pub fn filter_vec<T, F>(&self, items: Vec<T>, repo_extractor: F) -> Vec<T>
where
F: Fn(&T) -> &str,
{
items
.into_iter()
.filter(|item| self.is_allowed(repo_extractor(item)))
.collect()
}
pub fn is_path_allowed(&self, file_path: &str) -> bool {
let repo = Self::repo_from_path(file_path);
if !self.is_allowed(repo) {
return false;
}
if !self.denied_paths.is_empty() {
let rel_path = Self::path_after_repo(file_path);
for pattern in &self.denied_paths {
if pattern.matches(rel_path) {
return false;
}
}
}
true
}
pub fn filter_vec_by_path<T, F>(&self, items: Vec<T>, path_extractor: F) -> Vec<T>
where
F: Fn(&T) -> &str,
{
items
.into_iter()
.filter(|item| self.is_path_allowed(path_extractor(item)))
.collect()
}
pub fn repo_from_path(path: &str) -> &str {
if let Some(rest) = path.strip_prefix("beads:") {
return rest.split(':').next().unwrap_or(rest);
}
if let Some(idx) = path.find("/repos/") {
let after = &path[idx + 7..]; if let Some(slash) = after.find('/') {
return &after[..slash];
}
return after;
}
if let Some(after) = path.strip_prefix("repos/") {
if let Some(slash) = after.find('/') {
return &after[..slash];
}
return after;
}
path.split('/').next().unwrap_or(path)
}
fn path_after_repo(path: &str) -> &str {
if let Some(idx) = path.find("/repos/") {
let after = &path[idx + 7..]; if let Some(slash) = after.find('/') {
return &after[slash + 1..];
}
return "";
}
if let Some(after) = path.strip_prefix("repos/") {
if let Some(slash) = after.find('/') {
return &after[slash + 1..];
}
return "";
}
if let Some(slash) = path.find('/') {
return &path[slash + 1..];
}
path
}
pub fn resolve_role(explicit: Option<&str>) -> String {
if let Some(role) = explicit {
if !role.is_empty() {
return role.to_string();
}
}
if let Ok(role) = std::env::var("BOBBIN_ROLE") {
if !role.is_empty() {
return role;
}
}
if let Ok(role) = std::env::var("GT_ROLE") {
if !role.is_empty() {
return role;
}
}
if let Ok(role) = std::env::var("BD_ACTOR") {
if !role.is_empty() {
return role;
}
}
"default".to_string()
}
fn find_matching_role<'a>(roles: &'a [RoleConfig], role: &str) -> Option<&'a RoleConfig> {
if let Some(r) = roles.iter().find(|r| r.name == role) {
return Some(r);
}
let mut best_match: Option<&RoleConfig> = None;
let mut best_len = 0;
for r in roles {
if let Some(prefix) = r.name.strip_suffix("/*") {
if role.starts_with(prefix)
&& (role.len() == prefix.len()
|| role.as_bytes().get(prefix.len()) == Some(&b'/'))
{
if prefix.len() > best_len {
best_len = prefix.len();
best_match = Some(r);
}
}
}
}
if best_match.is_some() {
return best_match;
}
roles.iter().find(|r| r.name == "default")
}
fn matches_any(&self, patterns: &HashSet<String>, repo_name: &str) -> bool {
if patterns.contains("*") {
return true;
}
patterns.contains(repo_name)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::RoleConfig;
fn make_config(default_allow: bool, roles: Vec<RoleConfig>) -> AccessConfig {
AccessConfig {
default_allow,
roles,
}
}
fn role(name: &str, allow: &[&str], deny: &[&str]) -> RoleConfig {
RoleConfig {
name: name.to_string(),
allow: allow.iter().map(|s| s.to_string()).collect(),
deny: deny.iter().map(|s| s.to_string()).collect(),
deny_paths: vec![],
}
}
fn role_with_paths(
name: &str,
allow: &[&str],
deny: &[&str],
deny_paths: &[&str],
) -> RoleConfig {
RoleConfig {
name: name.to_string(),
allow: allow.iter().map(|s| s.to_string()).collect(),
deny: deny.iter().map(|s| s.to_string()).collect(),
deny_paths: deny_paths.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn test_no_config_allows_everything() {
let config = make_config(true, vec![]);
let filter = RepoFilter::from_config(&config, "anything");
assert!(filter.is_allowed("aegis"));
assert!(filter.is_allowed("personal-planning"));
assert!(filter.is_allowed("cv"));
}
#[test]
fn test_default_role_deny() {
let config = make_config(
true,
vec![role("default", &[], &["personal-planning", "cv", "resume"])],
);
let filter = RepoFilter::from_config(&config, "unknown-agent");
assert!(filter.is_allowed("aegis"));
assert!(filter.is_allowed("bobbin"));
assert!(!filter.is_allowed("personal-planning"));
assert!(!filter.is_allowed("cv"));
assert!(!filter.is_allowed("resume"));
}
#[test]
fn test_human_sees_everything() {
let config = make_config(
true,
vec![
role("default", &[], &["personal-planning", "cv"]),
role("human", &["*"], &[]),
],
);
let filter = RepoFilter::from_config(&config, "human");
assert!(filter.is_allowed("personal-planning"));
assert!(filter.is_allowed("cv"));
assert!(filter.is_allowed("aegis"));
}
#[test]
fn test_deny_overrides_allow() {
let config = make_config(true, vec![role("test", &["*"], &["secret-repo"])]);
let filter = RepoFilter::from_config(&config, "test");
assert!(filter.is_allowed("aegis"));
assert!(!filter.is_allowed("secret-repo"));
}
#[test]
fn test_wildcard_role_matching() {
let config = make_config(
false,
vec![role("aegis/*", &["aegis", "bobbin", "gastown"], &[])],
);
let filter = RepoFilter::from_config(&config, "aegis/crew/ian");
assert!(filter.is_allowed("aegis"));
assert!(filter.is_allowed("bobbin"));
assert!(!filter.is_allowed("personal-planning"));
}
#[test]
fn test_most_specific_wildcard_wins() {
let config = make_config(
false,
vec![
role("aegis/*", &["aegis", "bobbin"], &[]),
role(
"aegis/crew/*",
&["aegis", "bobbin", "gastown", "homelab-mcp"],
&[],
),
],
);
let filter = RepoFilter::from_config(&config, "aegis/crew/ian");
assert!(filter.is_allowed("homelab-mcp"));
let filter2 = RepoFilter::from_config(&config, "aegis/polecats/alpha");
assert!(!filter2.is_allowed("homelab-mcp"));
assert!(filter2.is_allowed("bobbin"));
}
#[test]
fn test_exact_match_over_wildcard() {
let config = make_config(
false,
vec![
role("aegis/*", &["aegis"], &[]),
role(
"aegis/crew/ian",
&["aegis", "bobbin", "personal-planning"],
&[],
),
],
);
let filter = RepoFilter::from_config(&config, "aegis/crew/ian");
assert!(filter.is_allowed("personal-planning"));
}
#[test]
fn test_default_allow_false_no_match() {
let config = make_config(false, vec![role("human", &["*"], &[])]);
let filter = RepoFilter::from_config(&config, "random-agent");
assert!(!filter.is_allowed("aegis"));
}
#[test]
fn test_default_allow_true_no_match() {
let config = make_config(true, vec![role("human", &["*"], &[])]);
let filter = RepoFilter::from_config(&config, "random-agent");
assert!(filter.is_allowed("aegis"));
}
#[test]
fn test_filter_vec() {
let config = make_config(true, vec![role("default", &[], &["secret"])]);
let filter = RepoFilter::from_config(&config, "default");
let items = vec!["aegis/main.rs", "secret/passwords.txt", "bobbin/lib.rs"];
let filtered = filter.filter_vec(items, |item| item.split('/').next().unwrap());
assert_eq!(filtered, vec!["aegis/main.rs", "bobbin/lib.rs"]);
}
#[test]
fn test_repo_from_path() {
assert_eq!(
RepoFilter::repo_from_path("/var/lib/bobbin/repos/aegis/src/main.rs"),
"aegis"
);
assert_eq!(
RepoFilter::repo_from_path("/var/lib/bobbin/repos/homelab-mcp/tools.go"),
"homelab-mcp"
);
assert_eq!(RepoFilter::repo_from_path("bobbin/src/lib.rs"), "bobbin");
assert_eq!(
RepoFilter::repo_from_path("repos/reckoning/packages/client/src/main.ts"),
"reckoning"
);
assert_eq!(RepoFilter::repo_from_path("repos/cv/resume.md"), "cv");
assert_eq!(
RepoFilter::repo_from_path("beads:pixelsrc:bo-x"),
"pixelsrc"
);
assert_eq!(RepoFilter::repo_from_path("beads:aegis:aegis-h8x"), "aegis");
assert_eq!(RepoFilter::repo_from_path("beads:hq:hq-9f2"), "hq");
}
#[test]
fn test_bead_access_scoped_to_rig() {
let config = make_config(true, vec![role("aegis", &[], &["pixelsrc"])]);
let filter = RepoFilter::from_config(&config, "aegis");
assert!(!filter.is_path_allowed("/var/lib/bobbin/repos/pixelsrc/src/x.rs"));
assert!(!filter.is_path_allowed("beads:pixelsrc:bo-x"));
assert!(filter.is_path_allowed("beads:aegis:aegis-h8x"));
}
#[test]
fn test_bead_hq_human_only_deny() {
let config = make_config(
true,
vec![role("human", &["*"], &[]), role("default", &[], &["hq"])],
);
let agent = RepoFilter::from_config(&config, "default");
assert!(!agent.is_path_allowed("beads:hq:hq-secret"));
let human = RepoFilter::from_config(&config, "human");
assert!(human.is_path_allowed("beads:hq:hq-secret"));
}
#[test]
fn test_resolve_role_explicit() {
assert_eq!(RepoFilter::resolve_role(Some("human")), "human");
}
#[test]
fn test_resolve_role_default() {
std::env::remove_var("BOBBIN_ROLE");
std::env::remove_var("GT_ROLE");
std::env::remove_var("BD_ACTOR");
assert_eq!(RepoFilter::resolve_role(None), "default");
}
#[test]
fn test_config_toml_roundtrip() {
let config = AccessConfig {
default_allow: true,
roles: vec![
RoleConfig {
name: "human".to_string(),
allow: vec!["*".to_string()],
deny: vec![],
deny_paths: vec![],
},
RoleConfig {
name: "default".to_string(),
allow: vec![],
deny: vec!["cv".to_string(), "resume".to_string()],
deny_paths: vec![],
},
],
};
let serialized = toml::to_string_pretty(&config).unwrap();
let deserialized: AccessConfig = toml::from_str(&serialized).unwrap();
assert_eq!(deserialized.default_allow, true);
assert_eq!(deserialized.roles.len(), 2);
assert_eq!(deserialized.roles[0].name, "human");
assert_eq!(deserialized.roles[1].deny, vec!["cv", "resume"]);
}
#[test]
fn test_backward_compat_no_access_section() {
let toml_str = r#"
[embedding]
model = "all-MiniLM-L6-v2"
"#;
let config: crate::config::Config = toml::from_str(toml_str).unwrap();
assert!(config.access.default_allow);
assert!(config.access.roles.is_empty());
let filter = RepoFilter::from_config(&config.access, "anything");
assert!(filter.is_allowed("any-repo"));
}
#[test]
fn test_deny_paths_filters_files() {
let config = make_config(
true,
vec![role_with_paths(
"default",
&[],
&["cv"],
&["harnesses/*/CLAUDE.md"],
)],
);
let filter = RepoFilter::from_config(&config, "agent");
assert!(!filter.is_path_allowed("/var/lib/bobbin/repos/cv/resume.md"));
assert!(!filter.is_path_allowed("/var/lib/bobbin/repos/aegis/harnesses/ian/CLAUDE.md"));
assert!(filter.is_path_allowed("/var/lib/bobbin/repos/aegis/src/main.rs"));
}
#[test]
fn test_deny_paths_no_patterns_allows_all() {
let config = make_config(true, vec![role("default", &[], &[])]);
let filter = RepoFilter::from_config(&config, "agent");
assert!(filter.is_path_allowed("/var/lib/bobbin/repos/aegis/harnesses/ian/CLAUDE.md"));
assert!(filter.is_path_allowed("/var/lib/bobbin/repos/aegis/src/main.rs"));
}
#[test]
fn test_filter_vec_by_path() {
let config = make_config(
true,
vec![role_with_paths(
"default",
&[],
&[],
&["harnesses/*/CLAUDE.md"],
)],
);
let filter = RepoFilter::from_config(&config, "agent");
let items = vec![
"/var/lib/bobbin/repos/aegis/src/main.rs",
"/var/lib/bobbin/repos/aegis/harnesses/ian/CLAUDE.md",
"/var/lib/bobbin/repos/aegis/docs/design.md",
];
let filtered = filter.filter_vec_by_path(items, |item| item);
assert_eq!(
filtered,
vec![
"/var/lib/bobbin/repos/aegis/src/main.rs",
"/var/lib/bobbin/repos/aegis/docs/design.md",
]
);
}
#[test]
fn test_deny_paths_config_roundtrip() {
let toml_str = r#"
default_allow = true
[[roles]]
name = "default"
deny = ["cv"]
deny_paths = ["harnesses/*/CLAUDE.md", "crew/*/CLAUDE.md"]
"#;
let config: AccessConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.roles[0].deny_paths.len(), 2);
assert_eq!(config.roles[0].deny_paths[0], "harnesses/*/CLAUDE.md");
let filter = RepoFilter::from_config(&config, "agent");
assert!(!filter.is_path_allowed("/var/lib/bobbin/repos/aegis/harnesses/ian/CLAUDE.md"));
assert!(filter.is_path_allowed("/var/lib/bobbin/repos/aegis/src/main.rs"));
}
}