use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use crate::coordinate::{Coordinate, EnvSegment};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Operation {
Metadata,
Inject,
Reveal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Surface {
Cli,
WebUi,
Mcp,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Origin {
Agent,
Human,
}
impl Origin {
pub fn as_str(&self) -> &'static str {
match self {
Origin::Agent => "agent",
Origin::Human => "human",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Filter {
Any,
Only(BTreeSet<String>),
}
impl Filter {
pub fn only<I, S>(values: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Filter::Only(values.into_iter().map(Into::into).collect())
}
pub fn allows(&self, value: &str) -> bool {
match self {
Filter::Any => true,
Filter::Only(set) => set.contains(value),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentScope {
pub operations: BTreeSet<Operation>,
pub projects: Filter,
pub environments: Filter,
}
impl AgentScope {
pub fn full() -> Self {
Self {
operations: [Operation::Metadata, Operation::Inject, Operation::Reveal]
.into_iter()
.collect(),
projects: Filter::Any,
environments: Filter::Any,
}
}
pub fn metadata_only() -> Self {
Self {
operations: [Operation::Metadata].into_iter().collect(),
projects: Filter::Any,
environments: Filter::Any,
}
}
pub fn permits(&self, operation: Operation) -> bool {
self.operations.contains(&operation)
}
pub fn addresses(&self, coord: &Coordinate, project: Option<&str>) -> bool {
let env_ok = match &coord.environment {
EnvSegment::Literal(env) => self.environments.allows(env),
EnvSegment::Placeholder => false,
};
let project_ok = match project {
Some(name) => self.projects.allows(name),
None => matches!(self.projects, Filter::Any),
};
env_ok && project_ok
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
fn coord(s: &str) -> Coordinate {
Coordinate::from_str(s).unwrap()
}
#[test]
fn full_scope_addresses_everything() {
let s = AgentScope::full();
assert!(s.addresses(&coord("secret:prod/db/password"), Some("api")));
assert!(s.addresses(&coord("secret:dev/app/key"), None));
assert!(s.permits(Operation::Reveal));
}
#[test]
fn env_filter_excludes_out_of_scope_env() {
let s = AgentScope {
operations: [Operation::Metadata].into_iter().collect(),
projects: Filter::Any,
environments: Filter::only(["dev", "test"]),
};
assert!(s.addresses(&coord("secret:dev/app/key"), None));
assert!(!s.addresses(&coord("secret:prod/db/password"), None));
}
#[test]
fn project_allowlist_excludes_global_and_other_projects() {
let s = AgentScope {
operations: [Operation::Metadata].into_iter().collect(),
projects: Filter::only(["api"]),
environments: Filter::Any,
};
assert!(s.addresses(&coord("secret:dev/app/key"), Some("api")));
assert!(!s.addresses(&coord("secret:dev/app/key"), Some("billing")));
assert!(!s.addresses(&coord("secret:dev/app/key"), None));
}
#[test]
fn placeholder_env_is_not_addressable() {
let s = AgentScope::full();
assert!(!s.addresses(&coord("secret:${ENV}/db/password"), Some("api")));
}
#[test]
fn metadata_only_scope_forbids_reveal_and_inject() {
let s = AgentScope::metadata_only();
assert!(s.permits(Operation::Metadata));
assert!(!s.permits(Operation::Reveal));
assert!(!s.permits(Operation::Inject));
}
}