1use std::{
32 collections::{BTreeMap, BTreeSet},
33 fs,
34 path::{Path, PathBuf},
35};
36
37use agent_domain::{
38 ApprovalProfileSlug, ApprovalRule, Capability, DependencyPolicy, EffectivePolicy, ModeSlug,
39 PatchBudget, PathPattern, ReportingPolicy, ValidationMinimums,
40};
41use serde::{Deserialize, Serialize};
42use thiserror::Error;
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct ModeSpec {
47 pub slug: ModeSlug,
49 pub purpose: String,
51 #[serde(default)]
53 pub allowed_capabilities: BTreeSet<Capability>,
54 #[serde(default)]
56 pub approval_rules: BTreeMap<Capability, ApprovalRule>,
57 #[serde(default)]
59 pub validation_minimums: ValidationMinimums,
60 #[serde(default)]
62 pub reporting: ReportingPolicy,
63 #[serde(default)]
65 pub patch_budget: PatchBudget,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct ApprovalProfile {
71 pub slug: ApprovalProfileSlug,
73 #[serde(default)]
75 pub approval_rules: BTreeMap<Capability, ApprovalRule>,
76 #[serde(default)]
78 pub forbidden_paths: Vec<PathPattern>,
79 #[serde(default)]
81 pub dependency_policy: DependencyPolicy,
82 #[serde(default)]
84 pub reporting: ReportingPolicy,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct PolicyRepository {
90 modes: BTreeMap<ModeSlug, ModeSpec>,
91 approval_profiles: BTreeMap<ApprovalProfileSlug, ApprovalProfile>,
92}
93
94impl PolicyRepository {
95 pub fn load_from_root(root: &Path) -> Result<Self, PolicyError> {
102 let modes_dir = root.join(".agent").join("modes");
103 let approvals_dir = root.join(".agent").join("approvals");
104
105 let modes = load_yaml_directory::<ModeSpec>(&modes_dir)?
106 .into_iter()
107 .map(|mode| (mode.slug.clone(), mode))
108 .collect();
109 let approval_profiles = load_yaml_directory::<ApprovalProfile>(&approvals_dir)?
110 .into_iter()
111 .map(|profile| (profile.slug.clone(), profile))
112 .collect();
113
114 Ok(Self {
115 modes,
116 approval_profiles,
117 })
118 }
119
120 pub fn mode(&self, slug: &str) -> Result<&ModeSpec, PolicyError> {
127 self.modes
128 .get(slug)
129 .ok_or_else(|| PolicyError::MissingMode(slug.to_owned()))
130 }
131
132 pub fn approval_profile(
139 &self,
140 slug: &ApprovalProfileSlug,
141 ) -> Result<&ApprovalProfile, PolicyError> {
142 self.approval_profiles
143 .get(slug)
144 .ok_or_else(|| PolicyError::MissingApprovalProfile(slug.to_string()))
145 }
146}
147
148#[derive(Debug, Clone)]
150pub struct PolicyEngine {
151 repository: PolicyRepository,
152}
153
154impl PolicyEngine {
155 #[must_use]
157 pub fn new(repository: PolicyRepository) -> Self {
158 Self { repository }
159 }
160
161 #[must_use]
163 pub fn repository(&self) -> &PolicyRepository {
164 &self.repository
165 }
166
167 pub fn resolve(
177 &self,
178 mode_slug: &str,
179 approval_profile_slug: &ApprovalProfileSlug,
180 ) -> Result<ResolvedMode, PolicyError> {
181 let mode = self.repository.mode(mode_slug)?.clone();
182 let approval_profile = self
183 .repository
184 .approval_profile(approval_profile_slug)?
185 .clone();
186
187 let mut approval_rules = default_approval_rules();
188 approval_rules.extend(approval_profile.approval_rules.clone());
189 approval_rules.extend(mode.approval_rules.clone());
190
191 let effective = EffectivePolicy {
192 allowed_capabilities: mode.allowed_capabilities.clone(),
193 approval_rules,
194 forbidden_paths: approval_profile.forbidden_paths.clone(),
195 dependency_policy: approval_profile.dependency_policy,
196 reporting_policy: mode.reporting.clone(),
197 validation_minimums: mode.validation_minimums.clone(),
198 };
199
200 Ok(ResolvedMode {
201 spec: mode,
202 effective_policy: effective,
203 })
204 }
205
206 pub fn authorize(
214 &self,
215 policy: &EffectivePolicy,
216 capability: &Capability,
217 approvals: &BTreeSet<Capability>,
218 ) -> Result<(), PolicyError> {
219 if !policy.allowed_capabilities.contains(capability) {
220 return Err(PolicyError::CapabilityNotAllowed(capability.clone()));
221 }
222
223 let rule = policy
224 .approval_rules
225 .get(capability)
226 .copied()
227 .unwrap_or(ApprovalRule::Deny);
228
229 match rule {
230 ApprovalRule::Allow => Ok(()),
231 ApprovalRule::Ask | ApprovalRule::AskIfRisky if approvals.contains(capability) => {
232 Ok(())
233 }
234 ApprovalRule::Ask | ApprovalRule::AskIfRisky => {
235 Err(PolicyError::ApprovalRequired(capability.clone()))
236 }
237 ApprovalRule::Deny => Err(PolicyError::CapabilityDenied(capability.clone())),
238 }
239 }
240
241 pub fn authorize_transition(
248 &self,
249 from: &EffectivePolicy,
250 to: &EffectivePolicy,
251 approvals: &BTreeSet<Capability>,
252 ) -> Result<(), PolicyError> {
253 for capability in to
254 .allowed_capabilities
255 .difference(&from.allowed_capabilities)
256 {
257 self.authorize(to, capability, approvals)?;
258 }
259
260 Ok(())
261 }
262}
263
264#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct ResolvedMode {
267 pub spec: ModeSpec,
269 pub effective_policy: EffectivePolicy,
271}
272
273#[derive(Debug, Error)]
275pub enum PolicyError {
276 #[error("failed to access policy files: {0}")]
278 Io(#[from] std::io::Error),
279 #[error("failed to parse policy file {path}: {source}")]
281 Yaml {
282 path: PathBuf,
284 source: serde_yaml::Error,
286 },
287 #[error("missing mode `{0}`")]
289 MissingMode(String),
290 #[error("missing approval profile `{0}`")]
292 MissingApprovalProfile(String),
293 #[error("capability `{0:?}` is not allowed in this mode")]
295 CapabilityNotAllowed(Capability),
296 #[error("capability `{0:?}` requires approval")]
298 ApprovalRequired(Capability),
299 #[error("capability `{0:?}` is denied")]
301 CapabilityDenied(Capability),
302}
303
304fn load_yaml_directory<T>(directory: &Path) -> Result<Vec<T>, PolicyError>
305where
306 T: for<'de> Deserialize<'de>,
307{
308 let mut entries = fs::read_dir(directory)?
309 .collect::<Result<Vec<_>, _>>()?
310 .into_iter()
311 .map(|entry| entry.path())
312 .collect::<Vec<_>>();
313 entries.sort();
314
315 entries
316 .into_iter()
317 .filter(|path| matches!(path.extension().and_then(|ext| ext.to_str()), Some("yaml")))
318 .map(|path| {
319 let raw = fs::read_to_string(&path)?;
320 serde_yaml::from_str(&raw).map_err(|source| PolicyError::Yaml {
321 path: path.clone(),
322 source,
323 })
324 })
325 .collect()
326}
327
328fn default_approval_rules() -> BTreeMap<Capability, ApprovalRule> {
329 BTreeMap::from([
330 (Capability::ReadWorkspace, ApprovalRule::Allow),
331 (Capability::RunChecks, ApprovalRule::Allow),
332 (Capability::EditWorkspace, ApprovalRule::Ask),
333 (Capability::RunArbitraryCommand, ApprovalRule::AskIfRisky),
334 (Capability::DeleteFiles, ApprovalRule::AskIfRisky),
335 (Capability::NetworkAccess, ApprovalRule::Deny),
336 (Capability::SwitchMode, ApprovalRule::Allow),
337 ])
338}
339
340#[cfg(test)]
341mod tests {
342 use std::{collections::BTreeSet, fs};
343
344 use agent_domain::{ApprovalProfileSlug, Capability};
345 use tempfile::tempdir;
346
347 use super::{PolicyEngine, PolicyRepository};
348
349 fn approval_profile_slug(value: &str) -> ApprovalProfileSlug {
350 match ApprovalProfileSlug::new(value) {
351 Ok(slug) => slug,
352 Err(error) => panic!("approval profile slug should be valid in test: {error}"),
353 }
354 }
355
356 #[test]
357 fn policy_engine_requires_approval_for_widening_transition() {
358 let tempdir = tempdir().expect("tempdir should be created for policy test");
359 let root = tempdir.path();
360
361 fs::create_dir_all(root.join(".agent").join("modes"))
362 .expect("mode directory should be created for policy test");
363 fs::create_dir_all(root.join(".agent").join("approvals"))
364 .expect("approval directory should be created for policy test");
365
366 fs::write(
367 root.join(".agent").join("modes").join("architect.yaml"),
368 "slug: architect\npurpose: read only\nallowed_capabilities:\n - ReadWorkspace\n - SwitchMode\napproval_rules:\n SwitchMode: Allow\n",
369 )
370 .expect("architect mode should be written for policy test");
371 fs::write(
372 root.join(".agent").join("modes").join("implementer.yaml"),
373 "slug: implementer\npurpose: edits\nallowed_capabilities:\n - ReadWorkspace\n - EditWorkspace\n - SwitchMode\napproval_rules:\n EditWorkspace: Ask\n SwitchMode: Allow\n",
374 )
375 .expect("implementer mode should be written for policy test");
376 fs::write(
377 root.join(".agent").join("approvals").join("default.yaml"),
378 "slug: default\napproval_rules:\n EditWorkspace: Ask\n SwitchMode: Allow\n",
379 )
380 .expect("approval profile should be written for policy test");
381
382 let repository =
383 PolicyRepository::load_from_root(root).expect("policy repository should load");
384 let engine = PolicyEngine::new(repository);
385 let default_profile = approval_profile_slug("default");
386 let architect = engine
387 .resolve("architect", &default_profile)
388 .expect("architect policy should resolve");
389 let implementer = engine
390 .resolve("implementer", &default_profile)
391 .expect("implementer policy should resolve");
392
393 let denied = engine.authorize_transition(
394 &architect.effective_policy,
395 &implementer.effective_policy,
396 &BTreeSet::new(),
397 );
398 assert!(denied.is_err());
399
400 let mut approvals = BTreeSet::new();
401 approvals.insert(Capability::EditWorkspace);
402 let allowed = engine.authorize_transition(
403 &architect.effective_policy,
404 &implementer.effective_policy,
405 &approvals,
406 );
407 assert!(allowed.is_ok());
408 }
409}