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 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 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 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}