1use std::collections::BTreeSet;
14
15use serde::{Deserialize, Serialize};
16
17use crate::coordinate::{Coordinate, EnvSegment};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
21pub enum Operation {
22 Metadata,
24 Inject,
27 Reveal,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Surface {
34 Cli,
36 WebUi,
38 Mcp,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "kebab-case")]
45pub enum Origin {
46 Agent,
48 Human,
50}
51
52impl Origin {
53 pub fn as_str(&self) -> &'static str {
55 match self {
56 Origin::Agent => "agent",
57 Origin::Human => "human",
58 }
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum Filter {
65 Any,
67 Only(BTreeSet<String>),
69}
70
71impl Filter {
72 pub fn only<I, S>(values: I) -> Self
74 where
75 I: IntoIterator<Item = S>,
76 S: Into<String>,
77 {
78 Filter::Only(values.into_iter().map(Into::into).collect())
79 }
80
81 pub fn allows(&self, value: &str) -> bool {
83 match self {
84 Filter::Any => true,
85 Filter::Only(set) => set.contains(value),
86 }
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct AgentScope {
93 pub operations: BTreeSet<Operation>,
95 pub projects: Filter,
97 pub environments: Filter,
99}
100
101impl AgentScope {
102 pub fn full() -> Self {
105 Self {
106 operations: [Operation::Metadata, Operation::Inject, Operation::Reveal]
107 .into_iter()
108 .collect(),
109 projects: Filter::Any,
110 environments: Filter::Any,
111 }
112 }
113
114 pub fn metadata_only() -> Self {
116 Self {
117 operations: [Operation::Metadata].into_iter().collect(),
118 projects: Filter::Any,
119 environments: Filter::Any,
120 }
121 }
122
123 pub fn permits(&self, operation: Operation) -> bool {
125 self.operations.contains(&operation)
126 }
127
128 pub fn addresses(&self, coord: &Coordinate, project: Option<&str>) -> bool {
132 let env_ok = match &coord.environment {
133 EnvSegment::Literal(env) => self.environments.allows(env),
134 EnvSegment::Placeholder => false,
135 };
136 let project_ok = match project {
139 Some(name) => self.projects.allows(name),
140 None => matches!(self.projects, Filter::Any),
141 };
142 env_ok && project_ok
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use std::str::FromStr;
150
151 fn coord(s: &str) -> Coordinate {
152 Coordinate::from_str(s).unwrap()
153 }
154
155 #[test]
156 fn full_scope_addresses_everything() {
157 let s = AgentScope::full();
158 assert!(s.addresses(&coord("secret:prod/db/password"), Some("api")));
159 assert!(s.addresses(&coord("secret:dev/app/key"), None));
160 assert!(s.permits(Operation::Reveal));
161 }
162
163 #[test]
164 fn env_filter_excludes_out_of_scope_env() {
165 let s = AgentScope {
166 operations: [Operation::Metadata].into_iter().collect(),
167 projects: Filter::Any,
168 environments: Filter::only(["dev", "test"]),
169 };
170 assert!(s.addresses(&coord("secret:dev/app/key"), None));
171 assert!(!s.addresses(&coord("secret:prod/db/password"), None));
172 }
173
174 #[test]
175 fn project_allowlist_excludes_global_and_other_projects() {
176 let s = AgentScope {
177 operations: [Operation::Metadata].into_iter().collect(),
178 projects: Filter::only(["api"]),
179 environments: Filter::Any,
180 };
181 assert!(s.addresses(&coord("secret:dev/app/key"), Some("api")));
182 assert!(!s.addresses(&coord("secret:dev/app/key"), Some("billing")));
183 assert!(!s.addresses(&coord("secret:dev/app/key"), None));
185 }
186
187 #[test]
188 fn placeholder_env_is_not_addressable() {
189 let s = AgentScope::full();
190 assert!(!s.addresses(&coord("secret:${ENV}/db/password"), Some("api")));
191 }
192
193 #[test]
194 fn metadata_only_scope_forbids_reveal_and_inject() {
195 let s = AgentScope::metadata_only();
196 assert!(s.permits(Operation::Metadata));
197 assert!(!s.permits(Operation::Reveal));
198 assert!(!s.permits(Operation::Inject));
199 }
200}