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 Write,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Surface {
39 Cli,
41 WebUi,
43 Mcp,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "kebab-case")]
50pub enum Origin {
51 Agent,
53 Human,
55}
56
57impl Origin {
58 pub fn as_str(&self) -> &'static str {
60 match self {
61 Origin::Agent => "agent",
62 Origin::Human => "human",
63 }
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum Filter {
70 Any,
72 Only(BTreeSet<String>),
74}
75
76impl Filter {
77 pub fn only<I, S>(values: I) -> Self
79 where
80 I: IntoIterator<Item = S>,
81 S: Into<String>,
82 {
83 Filter::Only(values.into_iter().map(Into::into).collect())
84 }
85
86 pub fn allows(&self, value: &str) -> bool {
88 match self {
89 Filter::Any => true,
90 Filter::Only(set) => set.contains(value),
91 }
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct AgentScope {
98 pub operations: BTreeSet<Operation>,
100 pub projects: Filter,
102 pub environments: Filter,
104}
105
106impl AgentScope {
107 pub fn full() -> Self {
110 Self {
111 operations: [
112 Operation::Metadata,
113 Operation::Inject,
114 Operation::Reveal,
115 Operation::Write,
116 ]
117 .into_iter()
118 .collect(),
119 projects: Filter::Any,
120 environments: Filter::Any,
121 }
122 }
123
124 pub fn metadata_only() -> Self {
126 Self {
127 operations: [Operation::Metadata].into_iter().collect(),
128 projects: Filter::Any,
129 environments: Filter::Any,
130 }
131 }
132
133 pub fn permits(&self, operation: Operation) -> bool {
135 self.operations.contains(&operation)
136 }
137
138 pub fn addresses(&self, coord: &Coordinate, project: Option<&str>) -> bool {
142 let env_ok = match &coord.environment {
143 EnvSegment::Literal(env) => self.environments.allows(env),
144 EnvSegment::Placeholder => false,
145 };
146 let project_ok = match project {
149 Some(name) => self.projects.allows(name),
150 None => matches!(self.projects, Filter::Any),
151 };
152 env_ok && project_ok
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use std::str::FromStr;
160
161 fn coord(s: &str) -> Coordinate {
162 Coordinate::from_str(s).unwrap()
163 }
164
165 #[test]
166 fn full_scope_addresses_everything() {
167 let s = AgentScope::full();
168 assert!(s.addresses(&coord("secret:prod/db/password"), Some("api")));
169 assert!(s.addresses(&coord("secret:dev/app/key"), None));
170 assert!(s.permits(Operation::Reveal));
171 }
172
173 #[test]
174 fn env_filter_excludes_out_of_scope_env() {
175 let s = AgentScope {
176 operations: [Operation::Metadata].into_iter().collect(),
177 projects: Filter::Any,
178 environments: Filter::only(["dev", "test"]),
179 };
180 assert!(s.addresses(&coord("secret:dev/app/key"), None));
181 assert!(!s.addresses(&coord("secret:prod/db/password"), None));
182 }
183
184 #[test]
185 fn project_allowlist_excludes_global_and_other_projects() {
186 let s = AgentScope {
187 operations: [Operation::Metadata].into_iter().collect(),
188 projects: Filter::only(["api"]),
189 environments: Filter::Any,
190 };
191 assert!(s.addresses(&coord("secret:dev/app/key"), Some("api")));
192 assert!(!s.addresses(&coord("secret:dev/app/key"), Some("billing")));
193 assert!(!s.addresses(&coord("secret:dev/app/key"), None));
195 }
196
197 #[test]
198 fn placeholder_env_is_not_addressable() {
199 let s = AgentScope::full();
200 assert!(!s.addresses(&coord("secret:${ENV}/db/password"), Some("api")));
201 }
202
203 #[test]
204 fn metadata_only_scope_forbids_reveal_and_inject() {
205 let s = AgentScope::metadata_only();
206 assert!(s.permits(Operation::Metadata));
207 assert!(!s.permits(Operation::Reveal));
208 assert!(!s.permits(Operation::Inject));
209 }
210}