Skip to main content

ferrify_policy/
lib.rs

1//! Policy loading, resolution, and authorization.
2//!
3//! `agent-policy` is the governance core for Ferrify. It loads declarative mode
4//! and approval-profile files from `.agent/`, merges them into an
5//! [`EffectivePolicy`], and decides whether a capability or mode transition is
6//! allowed for the current run.
7//!
8//! The crate deliberately separates repository configuration from application
9//! orchestration. That keeps policy versionable, reviewable, and testable
10//! without hardwiring repository-specific rules into the runtime itself.
11//!
12//! # Examples
13//!
14//! ```no_run
15//! use agent_domain::ApprovalProfileSlug;
16//! use agent_policy::{PolicyEngine, PolicyRepository};
17//!
18//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
19//! let repository = PolicyRepository::load_from_root(std::path::Path::new("."))?;
20//! let engine = PolicyEngine::new(repository);
21//! let resolved = engine.resolve("architect", &ApprovalProfileSlug::new("default")?)?;
22//!
23//! assert!(resolved
24//!     .effective_policy
25//!     .allowed_capabilities
26//!     .contains(&agent_domain::Capability::ReadWorkspace));
27//! # Ok(())
28//! # }
29//! ```
30
31use 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/// A declarative execution mode loaded from `.agent/modes/*.yaml`.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct ModeSpec {
47    /// Stable mode identifier.
48    pub slug: ModeSlug,
49    /// The purpose of the mode.
50    pub purpose: String,
51    /// Capabilities the mode is allowed to request.
52    #[serde(default)]
53    pub allowed_capabilities: BTreeSet<Capability>,
54    /// Mode-specific approval overrides.
55    #[serde(default)]
56    pub approval_rules: BTreeMap<Capability, ApprovalRule>,
57    /// Verification rules that must hold for the mode.
58    #[serde(default)]
59    pub validation_minimums: ValidationMinimums,
60    /// Reporting constraints attached to the mode.
61    #[serde(default)]
62    pub reporting: ReportingPolicy,
63    /// The default patch budget for this mode.
64    #[serde(default)]
65    pub patch_budget: PatchBudget,
66}
67
68/// A named approval profile loaded from `.agent/approvals/*.yaml`.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct ApprovalProfile {
71    /// Stable profile identifier.
72    pub slug: ApprovalProfileSlug,
73    /// Base approval rules for the repository.
74    #[serde(default)]
75    pub approval_rules: BTreeMap<Capability, ApprovalRule>,
76    /// Paths that the runtime should never edit.
77    #[serde(default)]
78    pub forbidden_paths: Vec<PathPattern>,
79    /// The repository stance on dependency changes.
80    #[serde(default)]
81    pub dependency_policy: DependencyPolicy,
82    /// Reporting rules shared across modes.
83    #[serde(default)]
84    pub reporting: ReportingPolicy,
85}
86
87/// In-memory policy data loaded from the repository.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct PolicyRepository {
90    modes: BTreeMap<ModeSlug, ModeSpec>,
91    approval_profiles: BTreeMap<ApprovalProfileSlug, ApprovalProfile>,
92}
93
94impl PolicyRepository {
95    /// Loads `.agent/modes` and `.agent/approvals` from the repository root.
96    ///
97    /// # Errors
98    ///
99    /// Returns [`PolicyError`] when the directories cannot be read or when a
100    /// YAML file fails to deserialize into the expected policy type.
101    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    /// Returns a mode by slug.
121    ///
122    /// # Errors
123    ///
124    /// Returns [`PolicyError::MissingMode`] when the requested mode is not
125    /// present in the loaded repository policy.
126    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    /// Returns an approval profile by slug.
133    ///
134    /// # Errors
135    ///
136    /// Returns [`PolicyError::MissingApprovalProfile`] when the requested
137    /// profile was not loaded from `.agent/approvals`.
138    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/// Resolves declarative policy into an effective policy and enforces approvals.
149#[derive(Debug, Clone)]
150pub struct PolicyEngine {
151    repository: PolicyRepository,
152}
153
154impl PolicyEngine {
155    /// Creates a policy engine from the loaded repository data.
156    #[must_use]
157    pub fn new(repository: PolicyRepository) -> Self {
158        Self { repository }
159    }
160
161    /// Returns the loaded repository data.
162    #[must_use]
163    pub fn repository(&self) -> &PolicyRepository {
164        &self.repository
165    }
166
167    /// Resolves the effective policy for a mode and approval profile.
168    ///
169    /// The result is the policy Ferrify actually executes with after mode
170    /// defaults and approval-profile overrides have been merged.
171    ///
172    /// # Errors
173    ///
174    /// Returns [`PolicyError`] when either the mode or approval profile is
175    /// missing from the loaded repository data.
176    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    /// Checks whether a capability can be used with the provided approvals.
207    ///
208    /// # Errors
209    ///
210    /// Returns [`PolicyError`] when the capability is not allowed by the active
211    /// mode, when the capability is denied outright, or when the capability
212    /// requires approval and the caller did not supply it.
213    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    /// Enforces the rule that widening a mode's authority requires approval.
242    ///
243    /// # Errors
244    ///
245    /// Returns [`PolicyError`] when the target mode introduces a capability
246    /// that is either disallowed or not explicitly approved for the transition.
247    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/// A resolved mode paired with its effective policy.
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct ResolvedMode {
267    /// The mode specification loaded from the repository.
268    pub spec: ModeSpec,
269    /// The effective policy derived from the mode and approval profile.
270    pub effective_policy: EffectivePolicy,
271}
272
273/// Errors produced while loading or enforcing policy.
274#[derive(Debug, Error)]
275pub enum PolicyError {
276    /// Filesystem access failed.
277    #[error("failed to access policy files: {0}")]
278    Io(#[from] std::io::Error),
279    /// YAML parsing failed.
280    #[error("failed to parse policy file {path}: {source}")]
281    Yaml {
282        /// The file that failed to parse.
283        path: PathBuf,
284        /// The underlying parse error.
285        source: serde_yaml::Error,
286    },
287    /// The requested mode was not present.
288    #[error("missing mode `{0}`")]
289    MissingMode(String),
290    /// The requested approval profile was not present.
291    #[error("missing approval profile `{0}`")]
292    MissingApprovalProfile(String),
293    /// The capability is not allowed in the current mode.
294    #[error("capability `{0:?}` is not allowed in this mode")]
295    CapabilityNotAllowed(Capability),
296    /// The capability requires explicit approval.
297    #[error("capability `{0:?}` requires approval")]
298    ApprovalRequired(Capability),
299    /// The capability is denied outright.
300    #[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}