Skip to main content

atman_runtime/
flow_authority.rs

1use std::collections::{BTreeSet, HashMap};
2use std::path::{Path, PathBuf};
3use std::sync::Mutex;
4
5use crate::event::FlowRunId;
6use crate::tool::Tier;
7use crate::trust::{
8    ExecutionPolicy, PolicyAction, PolicyEscalation, PolicyResolution, RiskKind, TrustConfig,
9};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum InvocationKind {
13    Root,
14    InlineSubflow,
15    SpawnSync,
16    SpawnAsync,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum FlowExecutionState {
21    Running,
22    BlockedOnDescendants {
23        child_run_counts: HashMap<FlowRunId, usize>,
24    },
25    Terminal,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum ChildWorkspaceAuthority {
30    Inherit,
31    Narrow(PathBuf),
32    TrustedDelegation(PathBuf),
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct EffectiveAuthority {
37    pub execution_policy: ExecutionPolicy,
38    pub allowed_tiers: [bool; 5],
39    pub allowed_risks: BTreeSet<RiskKind>,
40    pub tier_ceiling: [PolicyAction; 5],
41    pub risk_ceiling: [PolicyAction; 6],
42    pub shell: bool,
43    pub permission_management: bool,
44    pub workspace_root: Option<PathBuf>,
45}
46
47impl EffectiveAuthority {
48    pub fn root(_trust: &TrustConfig, shell: bool, workspace_root: Option<PathBuf>) -> Self {
49        let risks = all_risks();
50        Self {
51            // Session trust is evaluated per invocation. Root authority records
52            // structural ceilings only, so a user policy change is not frozen at
53            // flow start while delegated child restrictions remain monotonic.
54            execution_policy: ExecutionPolicy::Unrestricted,
55            allowed_tiers: [true; 5],
56            allowed_risks: risks.into_iter().collect(),
57            tier_ceiling: [PolicyAction::Auto; 5],
58            risk_ceiling: [PolicyAction::Auto; 6],
59            shell,
60            // The session root is the trust boundary for permission decisions.
61            // Child authorities can only retain this bit through intersection.
62            permission_management: true,
63            workspace_root,
64        }
65    }
66
67    pub fn for_child(
68        &self,
69        requested: &Self,
70        contract_allows_shell: bool,
71        workspace_root: Option<PathBuf>,
72    ) -> Result<Self, &'static str> {
73        let execution_policy = match (self.execution_policy, requested.execution_policy) {
74            (ExecutionPolicy::Unrestricted, ExecutionPolicy::Unrestricted) => {
75                ExecutionPolicy::Unrestricted
76            }
77            _ => ExecutionPolicy::Controlled,
78        };
79        let allowed_tiers = std::array::from_fn(|index| {
80            self.allowed_tiers[index] && requested.allowed_tiers[index]
81        });
82        let allowed_risks = self
83            .allowed_risks
84            .intersection(&requested.allowed_risks)
85            .copied()
86            .collect();
87        let tier_ceiling = std::array::from_fn(|index| {
88            self.tier_ceiling[index].most_restrictive(requested.tier_ceiling[index])
89        });
90        let risk_ceiling = std::array::from_fn(|index| {
91            self.risk_ceiling[index].most_restrictive(requested.risk_ceiling[index])
92        });
93        Ok(Self {
94            execution_policy,
95            allowed_tiers,
96            allowed_risks,
97            tier_ceiling,
98            risk_ceiling,
99            shell: self.shell && requested.shell && contract_allows_shell,
100            permission_management: self.permission_management && requested.permission_management,
101            workspace_root: narrowed_workspace(self.workspace_root.as_deref(), workspace_root)?,
102        })
103    }
104
105    pub fn constrain_policy(
106        &self,
107        trust: &TrustConfig,
108        tier: Tier,
109        risks: impl IntoIterator<Item = RiskKind>,
110    ) -> (ExecutionPolicy, PolicyAction) {
111        let (execution, resolution) = self.constrain_policy_resolution(trust, tier, risks);
112        (execution, resolution.action)
113    }
114
115    pub fn constrain_policy_resolution(
116        &self,
117        trust: &TrustConfig,
118        tier: Tier,
119        risks: impl IntoIterator<Item = RiskKind>,
120    ) -> (ExecutionPolicy, PolicyResolution) {
121        let current_execution = match (self.execution_policy, trust.execution_policy()) {
122            (ExecutionPolicy::Unrestricted, ExecutionPolicy::Unrestricted) => {
123                ExecutionPolicy::Unrestricted
124            }
125            _ => ExecutionPolicy::Controlled,
126        };
127        if current_execution == ExecutionPolicy::Unrestricted {
128            return (
129                current_execution,
130                PolicyResolution {
131                    action: PolicyAction::Auto,
132                    escalation: PolicyEscalation::None,
133                },
134            );
135        }
136        let tier_index = match tier {
137            Tier::Zero => 0,
138            Tier::One => 1,
139            Tier::Two => 2,
140            Tier::Three => 3,
141            Tier::Four => 4,
142        };
143        let risks: Vec<_> = risks.into_iter().collect();
144        let ceiling = risks
145            .iter()
146            .fold(self.tier_ceiling[tier_index], |action, risk| {
147                action.most_restrictive(self.risk_ceiling[risk_index(*risk)])
148            });
149        let mut resolution = trust.resolve_policy_resolution(tier, risks.iter().copied());
150        if resolution.escalation == PolicyEscalation::Denied
151            && risks.contains(&RiskKind::ProcessSpawn)
152            && risks.iter().all(|risk| {
153                *risk == RiskKind::ProcessSpawn || trust.resolve_risk(*risk) == PolicyAction::Auto
154            })
155        {
156            // Eager Deny rejects direct process elevation, not the safe
157            // sandboxed baseline. Explicit Deny actions never carry the
158            // `Denied` escalation marker and therefore remain final.
159            resolution.action = PolicyAction::Auto;
160        }
161        resolution.action = resolution.action.most_restrictive(ceiling);
162        (current_execution, resolution)
163    }
164
165    pub fn inherited_child(
166        &self,
167        contract_allows_shell: bool,
168        workspace: ChildWorkspaceAuthority,
169    ) -> Result<Self, &'static str> {
170        match workspace {
171            ChildWorkspaceAuthority::Inherit => self.for_child(self, contract_allows_shell, None),
172            ChildWorkspaceAuthority::Narrow(workspace_root) => {
173                self.for_child(self, contract_allows_shell, Some(workspace_root))
174            }
175            ChildWorkspaceAuthority::TrustedDelegation(workspace_root) => {
176                let mut child = self.for_child(self, contract_allows_shell, None)?;
177                child.workspace_root = Some(crate::fs_access::canonicalize_stable(&workspace_root));
178                Ok(child)
179            }
180        }
181    }
182}
183
184#[derive(Debug)]
185pub struct FlowIdentity {
186    pub session_id: String,
187    pub run_id: FlowRunId,
188    pub parent_run_id: Option<FlowRunId>,
189    pub root_run_id: FlowRunId,
190    pub invocation: InvocationKind,
191    pub effective_authority: EffectiveAuthority,
192    pub(crate) execution_state: Mutex<FlowExecutionState>,
193}
194
195impl FlowIdentity {
196    pub fn execution_state(&self) -> FlowExecutionState {
197        self.execution_state.lock().unwrap().clone()
198    }
199}
200
201pub fn contract_allows_shell(contract: Option<&atman_dsl::ast::Contract>) -> bool {
202    contract.is_some_and(|contract| {
203        contract.blocks.iter().any(|block| {
204            block.name.name == "capabilities"
205                && block.kwargs.iter().any(|(name, value)| {
206                    name.name == "shell"
207                        && matches!(
208                            value,
209                            atman_dsl::ast::Expr::Literal(atman_dsl::ast::Literal::Bool(true))
210                        )
211                })
212        })
213    })
214}
215
216fn narrowed_workspace(
217    parent: Option<&Path>,
218    requested: Option<PathBuf>,
219) -> Result<Option<PathBuf>, &'static str> {
220    match (parent, requested) {
221        (Some(parent), Some(requested)) => {
222            if requested
223                .components()
224                .any(|component| component == std::path::Component::ParentDir)
225            {
226                return Err("child workspace must not contain parent traversal");
227            }
228            let parent = crate::fs_access::canonicalize_stable(parent);
229            let requested = crate::fs_access::canonicalize_stable(&requested);
230            if requested.starts_with(&parent) {
231                Ok(Some(requested))
232            } else {
233                Err("child workspace must be within the parent workspace")
234            }
235        }
236        (Some(parent), None) => Ok(Some(crate::fs_access::canonicalize_stable(parent))),
237        (None, None) => Ok(None),
238        (None, Some(_)) => Err("child cannot acquire workspace authority absent from its parent"),
239    }
240}
241
242fn risk_index(risk: RiskKind) -> usize {
243    match risk {
244        RiskKind::WorkspaceExternal => 0,
245        RiskKind::Network => 1,
246        RiskKind::Irreversible => 2,
247        RiskKind::FilesystemWrite => 3,
248        RiskKind::ProcessSpawn => 4,
249        RiskKind::RepositoryMutation => 5,
250    }
251}
252
253fn all_risks() -> [RiskKind; 6] {
254    [
255        RiskKind::WorkspaceExternal,
256        RiskKind::Network,
257        RiskKind::Irreversible,
258        RiskKind::FilesystemWrite,
259        RiskKind::ProcessSpawn,
260        RiskKind::RepositoryMutation,
261    ]
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn child_authority_intersects_every_capability_field() {
270        let parent = EffectiveAuthority {
271            execution_policy: ExecutionPolicy::Controlled,
272            allowed_tiers: [true, false, true, false, true],
273            allowed_risks: BTreeSet::from([RiskKind::Network, RiskKind::FilesystemWrite]),
274            tier_ceiling: [PolicyAction::Auto; 5],
275            risk_ceiling: [PolicyAction::Auto; 6],
276            shell: true,
277            permission_management: false,
278            workspace_root: None,
279        };
280        let requested = EffectiveAuthority {
281            execution_policy: ExecutionPolicy::Unrestricted,
282            allowed_tiers: [false, true, true, true, false],
283            allowed_risks: BTreeSet::from([RiskKind::Network, RiskKind::ProcessSpawn]),
284            tier_ceiling: [PolicyAction::Auto; 5],
285            risk_ceiling: [PolicyAction::Auto; 6],
286            shell: false,
287            permission_management: true,
288            workspace_root: None,
289        };
290
291        let child = parent.for_child(&requested, true, None).unwrap();
292
293        assert_eq!(child.execution_policy, ExecutionPolicy::Controlled);
294        assert_eq!(child.allowed_tiers, [false, false, true, false, false]);
295        assert_eq!(child.allowed_risks, BTreeSet::from([RiskKind::Network]));
296        assert!(!child.shell);
297        assert!(!child.permission_management);
298    }
299
300    #[test]
301    fn root_authority_applies_each_live_session_policy_snapshot() {
302        let started_controlled = TrustConfig::default();
303        let root = EffectiveAuthority::root(&started_controlled, true, None);
304        let reckless = TrustConfig {
305            mode: crate::trust::TrustMode::Reckless,
306            ..TrustConfig::default()
307        };
308
309        assert_eq!(
310            root.constrain_policy(&reckless, Tier::Four, [RiskKind::ProcessSpawn]),
311            (ExecutionPolicy::Unrestricted, PolicyAction::Auto)
312        );
313        assert_eq!(
314            root.constrain_policy(&started_controlled, Tier::Four, [RiskKind::ProcessSpawn]),
315            (ExecutionPolicy::Controlled, PolicyAction::Ask)
316        );
317
318        let restricted = EffectiveAuthority {
319            execution_policy: ExecutionPolicy::Controlled,
320            tier_ceiling: [PolicyAction::Deny; 5],
321            ..root.clone()
322        };
323        let child = root.for_child(&restricted, true, None).unwrap();
324        assert_eq!(
325            child.constrain_policy(&reckless, Tier::Four, [RiskKind::ProcessSpawn]),
326            (ExecutionPolicy::Controlled, PolicyAction::Deny)
327        );
328        assert_eq!(child.shell, root.shell);
329        assert_eq!(child.workspace_root, root.workspace_root);
330    }
331
332    #[test]
333    fn authority_ceiling_prevents_eager_allow_from_becoming_automatic() {
334        let trust = TrustConfig {
335            mode: crate::trust::TrustMode::Eager,
336            escalation: crate::trust::EscalationPolicy::Allow,
337            ..TrustConfig::default()
338        };
339        let authority = EffectiveAuthority {
340            execution_policy: ExecutionPolicy::Controlled,
341            tier_ceiling: [PolicyAction::Ask; 5],
342            ..EffectiveAuthority::root(&trust, true, None)
343        };
344
345        let (execution, resolution) =
346            authority.constrain_policy_resolution(&trust, Tier::Two, [RiskKind::ProcessSpawn]);
347
348        assert_eq!(execution, ExecutionPolicy::Controlled);
349        assert_eq!(resolution.action, PolicyAction::Ask);
350        assert_eq!(resolution.escalation, PolicyEscalation::Allowed);
351    }
352
353    #[test]
354    fn eager_deny_keeps_sandboxable_processes_automatic() {
355        let trust = TrustConfig {
356            mode: crate::trust::TrustMode::Eager,
357            escalation: crate::trust::EscalationPolicy::Deny,
358            ..TrustConfig::default()
359        };
360        let root = EffectiveAuthority::root(&trust, true, None);
361
362        let (execution, resolution) =
363            root.constrain_policy_resolution(&trust, Tier::Four, [RiskKind::ProcessSpawn]);
364
365        assert_eq!(execution, ExecutionPolicy::Controlled);
366        assert_eq!(resolution.action, PolicyAction::Auto);
367        assert_eq!(resolution.escalation, PolicyEscalation::Denied);
368    }
369
370    #[test]
371    fn eager_deny_still_rejects_non_sandboxable_risk_and_authority_ceiling() {
372        let trust = TrustConfig {
373            mode: crate::trust::TrustMode::Eager,
374            escalation: crate::trust::EscalationPolicy::Deny,
375            ..TrustConfig::default()
376        };
377        let root = EffectiveAuthority::root(&trust, true, None);
378        let (_, external) = root.constrain_policy_resolution(
379            &trust,
380            Tier::Four,
381            [RiskKind::ProcessSpawn, RiskKind::WorkspaceExternal],
382        );
383        assert_eq!(external.action, PolicyAction::Deny);
384
385        let constrained = EffectiveAuthority {
386            tier_ceiling: [PolicyAction::Deny; 5],
387            ..root
388        };
389        let (_, denied) =
390            constrained.constrain_policy_resolution(&trust, Tier::Four, [RiskKind::ProcessSpawn]);
391        assert_eq!(denied.action, PolicyAction::Deny);
392    }
393
394    #[test]
395    fn inherited_child_authority_only_narrows_capabilities_and_workspace() {
396        let temp = tempfile::tempdir().unwrap();
397        let parent_root = temp.path().join("parent");
398        let child_root = parent_root.join("child");
399        std::fs::create_dir_all(&child_root).unwrap();
400        let parent =
401            EffectiveAuthority::root(&TrustConfig::default(), true, Some(parent_root.clone()));
402
403        let inherited = parent
404            .inherited_child(true, ChildWorkspaceAuthority::Inherit)
405            .unwrap();
406        assert_eq!(
407            inherited.workspace_root,
408            Some(crate::fs_access::canonicalize_stable(&parent_root))
409        );
410        assert_eq!(inherited.allowed_tiers, parent.allowed_tiers);
411        assert_eq!(inherited.allowed_risks, parent.allowed_risks);
412        assert_eq!(inherited.shell, parent.shell);
413
414        let narrowed = parent
415            .inherited_child(false, ChildWorkspaceAuthority::Narrow(child_root.clone()))
416            .unwrap();
417        assert_eq!(
418            narrowed.workspace_root,
419            Some(crate::fs_access::canonicalize_stable(&child_root))
420        );
421        assert_eq!(narrowed.allowed_tiers, parent.allowed_tiers);
422        assert_eq!(narrowed.allowed_risks, parent.allowed_risks);
423        assert!(!narrowed.shell);
424    }
425
426    #[test]
427    fn inherited_child_rejects_workspace_widening() {
428        let temp = tempfile::tempdir().unwrap();
429        let parent_root = temp.path().join("parent");
430        std::fs::create_dir(&parent_root).unwrap();
431        let parent =
432            EffectiveAuthority::root(&TrustConfig::default(), true, Some(parent_root.clone()));
433
434        let error = parent
435            .inherited_child(
436                true,
437                ChildWorkspaceAuthority::Narrow(temp.path().join("sibling")),
438            )
439            .unwrap_err();
440
441        assert_eq!(error, "child workspace must be within the parent workspace");
442    }
443
444    #[test]
445    fn inherited_child_rejects_parent_traversal_for_nonexistent_target() {
446        let temp = tempfile::tempdir().unwrap();
447        let parent_root = temp.path().join("parent");
448        std::fs::create_dir(&parent_root).unwrap();
449        let parent =
450            EffectiveAuthority::root(&TrustConfig::default(), true, Some(parent_root.clone()));
451
452        let error = parent
453            .inherited_child(
454                true,
455                ChildWorkspaceAuthority::Narrow(parent_root.join("missing/../../escape")),
456            )
457            .unwrap_err();
458
459        assert_eq!(error, "child workspace must not contain parent traversal");
460    }
461
462    #[cfg(unix)]
463    #[test]
464    fn inherited_child_rejects_symlink_escape() {
465        let temp = tempfile::tempdir().unwrap();
466        let parent_root = temp.path().join("parent");
467        let outside = temp.path().join("outside");
468        std::fs::create_dir(&parent_root).unwrap();
469        std::fs::create_dir(&outside).unwrap();
470        std::os::unix::fs::symlink(&outside, parent_root.join("escape")).unwrap();
471        let parent =
472            EffectiveAuthority::root(&TrustConfig::default(), true, Some(parent_root.clone()));
473
474        let error = parent
475            .inherited_child(
476                true,
477                ChildWorkspaceAuthority::Narrow(parent_root.join("escape/missing")),
478            )
479            .unwrap_err();
480
481        assert_eq!(error, "child workspace must be within the parent workspace");
482    }
483
484    #[test]
485    fn trusted_workspace_delegation_can_replace_the_parent_root() {
486        let temp = tempfile::tempdir().unwrap();
487        let parent_root = temp.path().join("parent");
488        let delegated_root = temp.path().join("managed-worktree");
489        std::fs::create_dir(&parent_root).unwrap();
490        std::fs::create_dir(&delegated_root).unwrap();
491        let parent = EffectiveAuthority::root(&TrustConfig::default(), true, Some(parent_root));
492
493        let child = parent
494            .inherited_child(
495                true,
496                ChildWorkspaceAuthority::TrustedDelegation(delegated_root.clone()),
497            )
498            .unwrap();
499
500        assert_eq!(
501            child.workspace_root,
502            Some(crate::fs_access::canonicalize_stable(&delegated_root))
503        );
504        assert_eq!(child.allowed_tiers, parent.allowed_tiers);
505        assert_eq!(child.allowed_risks, parent.allowed_risks);
506        assert_eq!(child.shell, parent.shell);
507    }
508}