Skip to main content

cookie_agent_engine/
permissions.rs

1//! Permission evaluation over immutable prepared-operation manifests.
2
3use std::{
4    collections::{BTreeMap, HashMap, HashSet},
5    path::{Path, PathBuf},
6    sync::Mutex,
7};
8
9use cookie_agent_config::simple_wildcard_match;
10use cookie_agent_protocol::{
11    AgentSnapshot, ApprovalEvaluation, DecisionTrace, EffectivePermissionAction,
12    EffectivePermissionRule, EventPayload, MatchedPermissionRule, OperationFingerprint,
13    PermissionAction, PermissionEffect, PermissionRule, PermissionRuleSource,
14    PreparedOperationIdentity, SafeCode, SessionId, SessionOrigin, SessionPermissionGetResult,
15    SessionPermissionMutationResult, SessionPermissionOverlay, TreeApprovalGrant,
16    TreeApprovalGrantId, WildcardPattern,
17};
18use thiserror::Error;
19
20use crate::tool_api::UNSCOPED_PERMISSION_RESOURCE_DISPLAY;
21
22/// Visibility is action-level opt-in, not a resource authorization decision.
23#[must_use]
24pub fn tool_visible(
25    rules: &[PermissionRule],
26    overlay: Option<&SessionPermissionOverlay>,
27    action: PermissionAction,
28) -> bool {
29    rules
30        .iter()
31        .chain(overlay.into_iter().flat_map(|overlay| &overlay.rules))
32        .any(|rule| {
33            rule.action == action
34                && matches!(rule.effect, PermissionEffect::Allow | PermissionEffect::Ask)
35        })
36}
37
38#[derive(Debug, Error)]
39pub enum PermissionError {
40    #[error("unknown permission name `{0}`")]
41    UnknownAction(String),
42}
43
44#[derive(Clone, Debug, Eq, Hash, PartialEq)]
45struct ApprovalKey {
46    root: SessionId,
47    fingerprint: OperationFingerprint,
48}
49
50#[derive(Debug, Default)]
51pub struct ApprovalStore {
52    grants: Mutex<HashMap<ApprovalKey, TreeApprovalGrant>>,
53}
54
55impl ApprovalStore {
56    pub fn replace(&self, grants: impl IntoIterator<Item = TreeApprovalGrant>) {
57        let mut stored = self
58            .grants
59            .lock()
60            .unwrap_or_else(|poisoned| poisoned.into_inner());
61        stored.clear();
62        for grant in grants {
63            stored.insert(
64                ApprovalKey {
65                    root: grant.root_session_id,
66                    fingerprint: grant.operation_fingerprint.clone(),
67                },
68                grant,
69            );
70        }
71    }
72
73    pub fn grant(&self, grant: TreeApprovalGrant) {
74        self.grants
75            .lock()
76            .unwrap_or_else(|poisoned| poisoned.into_inner())
77            .insert(
78                ApprovalKey {
79                    root: grant.root_session_id,
80                    fingerprint: grant.operation_fingerprint.clone(),
81                },
82                grant,
83            );
84    }
85
86    #[must_use]
87    pub fn matching(
88        &self,
89        root: SessionId,
90        operation: &PreparedOperationIdentity,
91    ) -> Option<TreeApprovalGrant> {
92        let fingerprint = OperationFingerprint::from_prepared_operation(operation);
93        self.grants
94            .lock()
95            .unwrap_or_else(|poisoned| poisoned.into_inner())
96            .get(&ApprovalKey { root, fingerprint })
97            .filter(|grant| {
98                grant.capabilities == operation.capabilities()
99                    && grant.resources == operation.resources()
100            })
101            .cloned()
102    }
103
104    #[must_use]
105    pub fn for_root(&self, root: SessionId) -> Vec<TreeApprovalGrant> {
106        self.grants
107            .lock()
108            .unwrap_or_else(|poisoned| poisoned.into_inner())
109            .iter()
110            .filter(|(key, _)| key.root == root)
111            .map(|(_, grant)| grant.clone())
112            .collect()
113    }
114
115    pub fn invalidate_grants(&self, ids: &HashSet<TreeApprovalGrantId>) {
116        self.grants
117            .lock()
118            .unwrap_or_else(|poisoned| poisoned.into_inner())
119            .retain(|_, grant| !ids.contains(&grant.grant_id));
120    }
121}
122
123#[derive(Clone, Debug)]
124pub struct PermissionDecision {
125    pub effect: PermissionEffect,
126    pub evaluations: Vec<ApprovalEvaluation>,
127}
128
129#[derive(Debug, Default)]
130pub struct PermissionPipeline {
131    _private: (),
132}
133
134impl PermissionPipeline {
135    pub fn action_for_permission_name(
136        permission_name: &str,
137    ) -> Result<PermissionAction, PermissionError> {
138        match permission_name {
139            "read" => Ok(PermissionAction::Read),
140            "write" => Ok(PermissionAction::Write),
141            "bash" => Ok(PermissionAction::Bash),
142            "delegate" => Ok(PermissionAction::Delegate),
143            "message" => Ok(PermissionAction::Message),
144            "mcp" => Ok(PermissionAction::Mcp),
145            "plugin" => Ok(PermissionAction::Plugin),
146            "skill" => Ok(PermissionAction::Skill),
147            "webfetch" => Ok(PermissionAction::Webfetch),
148            other if other.starts_with("plugin:") && other.len() > "plugin:".len() => {
149                Ok(PermissionAction::Plugin)
150            }
151            other => Err(PermissionError::UnknownAction(other.into())),
152        }
153    }
154
155    #[must_use]
156    pub fn decide_operation(
157        &self,
158        policy: &AgentSnapshot,
159        operation: &PreparedOperationIdentity,
160        policy_labels: &[Option<String>],
161        workspace: &Path,
162    ) -> PermissionDecision {
163        self.decide_operation_with_overlay(policy, None, operation, policy_labels, workspace)
164    }
165
166    #[must_use]
167    pub fn decide_operation_with_overlay(
168        &self,
169        policy: &AgentSnapshot,
170        overlay: Option<&SessionPermissionOverlay>,
171        operation: &PreparedOperationIdentity,
172        policy_labels: &[Option<String>],
173        workspace: &Path,
174    ) -> PermissionDecision {
175        assert!(!operation.resources().is_empty());
176        assert_eq!(operation.resources().len(), policy_labels.len());
177        let evaluations = operation
178            .resources()
179            .iter()
180            .zip(policy_labels)
181            .map(|(resource, normalized)| {
182                let (candidates, effect, reason) = match normalized {
183                    Some(normalized) => {
184                        let candidates = matching_rules(
185                            policy,
186                            overlay,
187                            resource.capability,
188                            normalized,
189                            workspace,
190                        );
191                        let (effect, reason) = effective_permission_with_overlay(
192                            policy,
193                            overlay,
194                            resource.capability,
195                            normalized,
196                            workspace,
197                        );
198                        (candidates, effect, reason)
199                    }
200                    None => {
201                        let candidates = matching_loose_rules(policy, overlay, resource.capability);
202                        let (effect, reason) = effective_loose_permission_with_overlay(
203                            policy,
204                            overlay,
205                            resource.capability,
206                        );
207                        (candidates, effect, reason)
208                    }
209                };
210                ApprovalEvaluation {
211                    resource_digest: resource.binding_digest.clone(),
212                    effect,
213                    trace: DecisionTrace {
214                        action: resource.capability,
215                        normalized_resource: normalized
216                            .clone()
217                            .unwrap_or_else(|| UNSCOPED_PERMISSION_RESOURCE_DISPLAY.to_owned()),
218                        candidates,
219                        effect,
220                        precedence_reason: reason,
221                    },
222                }
223            })
224            .collect::<Vec<_>>();
225        let effect = aggregate_effect(&evaluations);
226        PermissionDecision {
227            effect,
228            evaluations,
229        }
230    }
231
232    #[must_use]
233    pub fn decide_operation_with_grants(
234        &self,
235        policy: &AgentSnapshot,
236        overlay: Option<&SessionPermissionOverlay>,
237        grants: Option<&SessionPermissionOverlay>,
238        operation: &PreparedOperationIdentity,
239        policy_labels: &[Option<String>],
240        workspace: &Path,
241    ) -> PermissionDecision {
242        let mut decision = self.decide_operation_with_overlay(
243            policy,
244            overlay,
245            operation,
246            policy_labels,
247            workspace,
248        );
249        let Some(grants) = grants else {
250            return decision;
251        };
252        for (evaluation, (resource, normalized)) in decision
253            .evaluations
254            .iter_mut()
255            .zip(operation.resources().iter().zip(policy_labels))
256        {
257            if evaluation.effect != PermissionEffect::Ask {
258                continue;
259            }
260            let granted = grants.rules.iter().any(|rule| {
261                rule.effect == PermissionEffect::Allow
262                    && rule.action == resource.capability
263                    && normalized.as_ref().is_some_and(|normalized| {
264                        permission_pattern_matches(
265                            rule.resource.as_str(),
266                            normalized,
267                            &absolute_resource(workspace, normalized),
268                            workspace,
269                        )
270                    })
271            });
272            if granted {
273                evaluation.effect = PermissionEffect::Allow;
274                evaluation.trace.effect = PermissionEffect::Allow;
275                evaluation.trace.precedence_reason =
276                    "turn-scoped skill grant allows an otherwise ask operation".into();
277            }
278        }
279        decision.effect = aggregate_effect(&decision.evaluations);
280        decision
281    }
282
283    #[must_use]
284    pub fn tool_visible(policy: &AgentSnapshot, permission_name: &str) -> bool {
285        Self::tool_visible_with_overlay(policy, None, permission_name)
286    }
287
288    #[must_use]
289    pub fn tool_visible_with_overlay(
290        policy: &AgentSnapshot,
291        overlay: Option<&SessionPermissionOverlay>,
292        permission_name: &str,
293    ) -> bool {
294        let Ok(action) = Self::action_for_permission_name(permission_name) else {
295            return false;
296        };
297        tool_visible(&policy.permissions, overlay, action)
298    }
299
300    #[must_use]
301    pub fn tool_visible_with_grants(
302        policy: &AgentSnapshot,
303        overlay: Option<&SessionPermissionOverlay>,
304        grants: Option<&SessionPermissionOverlay>,
305        permission_name: &str,
306        workspace: &Path,
307    ) -> bool {
308        if Self::tool_visible_with_overlay(policy, overlay, permission_name) {
309            return true;
310        }
311        let Ok(action) = Self::action_for_permission_name(permission_name) else {
312            return false;
313        };
314        let plugin_permission = permission_name.strip_prefix("plugin:");
315        grants.is_some_and(|grants| {
316            grants.rules.iter().any(|grant| {
317                grant.action == action
318                    && grant.effect == PermissionEffect::Allow
319                    && plugin_permission.is_none_or(|permission| {
320                        plugin_rule_governs(grant.resource.as_str(), permission)
321                    })
322                    && effective_permission_with_overlay(
323                        policy,
324                        overlay,
325                        action,
326                        grant.resource.as_str(),
327                        workspace,
328                    )
329                    .0 != PermissionEffect::Deny
330            })
331        })
332    }
333}
334
335fn aggregate_effect(evaluations: &[ApprovalEvaluation]) -> PermissionEffect {
336    if evaluations
337        .iter()
338        .any(|evaluation| evaluation.effect == PermissionEffect::Deny)
339    {
340        PermissionEffect::Deny
341    } else if evaluations
342        .iter()
343        .any(|evaluation| evaluation.effect == PermissionEffect::Ask)
344    {
345        PermissionEffect::Ask
346    } else {
347        PermissionEffect::Allow
348    }
349}
350
351pub(crate) fn effective_loose_permission(
352    policy: &AgentSnapshot,
353    action: PermissionAction,
354) -> (PermissionEffect, String) {
355    policy
356        .permissions
357        .iter()
358        .enumerate()
359        .filter(|(_, rule)| rule.action == action && rule.resource.as_str() == "*")
360        .max_by_key(|(index, _)| *index)
361        .map_or_else(
362            || {
363                (
364                    PermissionEffect::Deny,
365                    "no bare or `*` rule; deny by default for a permission-name-only check".into(),
366                )
367            },
368            |(_, rule)| {
369                (
370                    rule.effect,
371                    "bare or `*` permission rule applies to the permission-name-only check".into(),
372                )
373            },
374        )
375}
376
377pub(crate) fn effective_loose_permission_with_overlay(
378    policy: &AgentSnapshot,
379    overlay: Option<&SessionPermissionOverlay>,
380    action: PermissionAction,
381) -> (PermissionEffect, String) {
382    overlay
383        .and_then(|overlay| {
384            overlay
385                .rules
386                .iter()
387                .enumerate()
388                .filter(|(_, rule)| rule.action == action && rule.resource.as_str() == "*")
389                .max_by_key(|(index, _)| *index)
390        })
391        .map_or_else(
392            || effective_loose_permission(policy, action),
393            |(_, rule)| {
394                (
395                    rule.effect,
396                    "session overlay wildcard rule takes precedence over the agent document".into(),
397                )
398            },
399        )
400}
401
402pub(crate) fn effective_permission(
403    policy: &AgentSnapshot,
404    action: PermissionAction,
405    resource: &str,
406    workspace: &Path,
407) -> (PermissionEffect, String) {
408    let absolute_resource = absolute_resource(workspace, resource);
409    let winner = policy
410        .permissions
411        .iter()
412        .enumerate()
413        .filter(|(_, rule)| {
414            rule.action == action
415                && permission_pattern_matches(
416                    rule.resource.as_str(),
417                    resource,
418                    &absolute_resource,
419                    workspace,
420                )
421        })
422        .max_by_key(|(index, rule)| specificity(rule.resource.as_str(), *index));
423    winner
424        .map_or_else(
425            || {
426                (
427                    PermissionEffect::Deny,
428                    "no matching rule; deny by default".into(),
429                )
430            },
431            |(_, rule)| {
432                (
433                    rule.effect,
434                    "most-specific matching pattern: more literal characters, then fewer wildcards, then later declaration".into(),
435                )
436            },
437        )
438}
439
440pub(crate) fn effective_permission_with_overlay(
441    policy: &AgentSnapshot,
442    overlay: Option<&SessionPermissionOverlay>,
443    action: PermissionAction,
444    resource: &str,
445    workspace: &Path,
446) -> (PermissionEffect, String) {
447    let absolute_resource = absolute_resource(workspace, resource);
448    let winner = overlay.and_then(|overlay| {
449        overlay
450            .rules
451            .iter()
452            .enumerate()
453            .filter(|(_, rule)| {
454                rule.action == action
455                    && permission_pattern_matches(
456                        rule.resource.as_str(),
457                        resource,
458                        &absolute_resource,
459                        workspace,
460                    )
461            })
462            .max_by_key(|(index, rule)| specificity(rule.resource.as_str(), *index))
463    });
464    winner.map_or_else(
465        || effective_permission(policy, action, resource, workspace),
466        |(_, rule)| {
467            (
468                rule.effect,
469                "session overlay matching rule takes precedence over the agent document".into(),
470            )
471        },
472    )
473}
474
475fn permission_pattern_matches(
476    pattern: &str,
477    relative_resource: &str,
478    absolute_resource: &str,
479    workspace: &Path,
480) -> bool {
481    if pattern.contains(cookie_agent_protocol::WildcardPattern::WORKSPACE_DIR_EXPRESSION) {
482        let expanded = expand_workspace_pattern(pattern, workspace);
483        simple_wildcard_match(&expanded, absolute_resource)
484    } else {
485        simple_wildcard_match(pattern, relative_resource)
486    }
487}
488
489fn plugin_rule_governs(pattern: &str, permission_name: &str) -> bool {
490    pattern == "*"
491        || simple_wildcard_match(pattern, permission_name)
492        || pattern
493            .strip_prefix(permission_name)
494            .is_some_and(|suffix| suffix.starts_with(' '))
495}
496
497fn expand_workspace_pattern(pattern: &str, workspace: &Path) -> String {
498    let workspace = normalized_path(&canonical_workspace(workspace));
499    if workspace == "/" {
500        pattern.replace("${workspace_dir}/", "/").replace(
501            cookie_agent_protocol::WildcardPattern::WORKSPACE_DIR_EXPRESSION,
502            "/",
503        )
504    } else {
505        pattern.replace(
506            cookie_agent_protocol::WildcardPattern::WORKSPACE_DIR_EXPRESSION,
507            workspace.trim_end_matches('/'),
508        )
509    }
510}
511
512fn absolute_resource(workspace: &Path, resource: &str) -> String {
513    if resource.starts_with("artifact://") {
514        return resource.to_owned();
515    }
516    let resource_path = Path::new(resource);
517    if resource_path.is_absolute() {
518        normalized_path(resource_path)
519    } else if resource_path == Path::new(".") {
520        normalized_path(&canonical_workspace(workspace))
521    } else {
522        normalized_path(&canonical_workspace(workspace).join(resource_path))
523    }
524}
525
526fn canonical_workspace(workspace: &Path) -> PathBuf {
527    workspace
528        .canonicalize()
529        .unwrap_or_else(|_| workspace.to_owned())
530}
531
532fn normalized_path(path: &Path) -> String {
533    path.to_string_lossy().replace('\\', "/")
534}
535
536fn specificity(
537    pattern: &str,
538    declaration_index: usize,
539) -> (usize, std::cmp::Reverse<usize>, usize) {
540    let wildcards = pattern
541        .chars()
542        .filter(|character| matches!(character, '*' | '?'))
543        .count();
544    let literals = pattern.chars().count() - wildcards;
545    (literals, std::cmp::Reverse(wildcards), declaration_index)
546}
547
548fn matching_rules(
549    policy: &AgentSnapshot,
550    overlay: Option<&SessionPermissionOverlay>,
551    action: PermissionAction,
552    resource: &str,
553    workspace: &Path,
554) -> Vec<MatchedPermissionRule> {
555    let absolute_resource = absolute_resource(workspace, resource);
556    policy
557        .permissions
558        .iter()
559        .filter(|rule| {
560            rule.action == action
561                && permission_pattern_matches(
562                    rule.resource.as_str(),
563                    resource,
564                    &absolute_resource,
565                    workspace,
566                )
567        })
568        .map(|rule| MatchedPermissionRule {
569            source_layer: SafeCode::new("agent_document").expect("static safe code"),
570            action: rule.action,
571            resource: rule.resource.clone(),
572            effect: rule.effect,
573        })
574        .chain(
575            overlay
576                .into_iter()
577                .flat_map(|overlay| overlay.rules.iter())
578                .filter(|rule| {
579                    rule.action == action
580                        && permission_pattern_matches(
581                            rule.resource.as_str(),
582                            resource,
583                            &absolute_resource,
584                            workspace,
585                        )
586                })
587                .map(|rule| MatchedPermissionRule {
588                    source_layer: SafeCode::new("session_overlay").expect("static safe code"),
589                    action: rule.action,
590                    resource: rule.resource.clone(),
591                    effect: rule.effect,
592                }),
593        )
594        .collect()
595}
596
597fn matching_loose_rules(
598    policy: &AgentSnapshot,
599    overlay: Option<&SessionPermissionOverlay>,
600    action: PermissionAction,
601) -> Vec<MatchedPermissionRule> {
602    policy
603        .permissions
604        .iter()
605        .filter(|rule| rule.action == action && rule.resource.as_str() == "*")
606        .map(|rule| MatchedPermissionRule {
607            source_layer: SafeCode::new("agent_document").expect("static safe code"),
608            action: rule.action,
609            resource: rule.resource.clone(),
610            effect: rule.effect,
611        })
612        .chain(
613            overlay
614                .into_iter()
615                .flat_map(|overlay| overlay.rules.iter())
616                .filter(|rule| rule.action == action && rule.resource.as_str() == "*")
617                .map(|rule| MatchedPermissionRule {
618                    source_layer: SafeCode::new("session_overlay").expect("static safe code"),
619                    action: rule.action,
620                    resource: rule.resource.clone(),
621                    effect: rule.effect,
622                }),
623        )
624        .collect()
625}
626
627impl crate::Engine {
628    pub fn get_session_permissions(
629        &self,
630        session_id: SessionId,
631    ) -> Result<SessionPermissionGetResult, crate::EngineError> {
632        let session = self.inner.store.get(session_id)?;
633        let policy = governing_agent(&session);
634        Ok(SessionPermissionGetResult {
635            permissions: effective_permission_view(&policy, &session.permission_overlay),
636            current_mode: Some(self.permission_mode(session_id)),
637        })
638    }
639
640    pub async fn set_session_permission(
641        &self,
642        session_id: SessionId,
643        action: PermissionAction,
644        resource: WildcardPattern,
645        effect: PermissionEffect,
646        origin: cookie_agent_protocol::EventOrigin,
647    ) -> Result<SessionPermissionMutationResult, crate::EngineError> {
648        let _mutation = self
649            .inner
650            .approvals
651            .permission_overlay_mutation
652            .lock()
653            .await;
654        let session = self.inner.store.get(session_id)?;
655        let policy = governing_agent(&session);
656        let mut overlay = session.permission_overlay.clone();
657        let old_effect = rule_effect(
658            &policy,
659            &overlay,
660            action,
661            resource.as_str(),
662            self.inner.store.cwd(),
663        );
664        overlay
665            .rules
666            .retain(|rule| rule.action != action || rule.resource != resource);
667        overlay.rules.push(PermissionRule {
668            action,
669            resource: resource.clone(),
670            effect,
671        });
672        overlay
673            .validate()
674            .map_err(|error| crate::EngineError::Permission(error.to_string()))?;
675        let new_effect = rule_effect(
676            &policy,
677            &overlay,
678            action,
679            resource.as_str(),
680            self.inner.store.cwd(),
681        );
682        if effect_rank(new_effect) > effect_rank(old_effect) {
683            self.invalidate_action_grants(session_id, &session.meta.origin, action)?;
684        }
685        self.append(
686            session_id,
687            None,
688            origin,
689            EventPayload::SessionPermissionOverlaySet { overlay },
690        )
691        .await?;
692        let result = self.get_session_permissions(session_id)?;
693        Ok(SessionPermissionMutationResult {
694            permissions: result.permissions,
695        })
696    }
697
698    pub async fn clear_session_permission(
699        &self,
700        session_id: SessionId,
701        action: PermissionAction,
702        resource: &WildcardPattern,
703        origin: cookie_agent_protocol::EventOrigin,
704    ) -> Result<SessionPermissionMutationResult, crate::EngineError> {
705        let _mutation = self
706            .inner
707            .approvals
708            .permission_overlay_mutation
709            .lock()
710            .await;
711        let session = self.inner.store.get(session_id)?;
712        let policy = governing_agent(&session);
713        let mut overlay = session.permission_overlay;
714        let old_effect = rule_effect(
715            &policy,
716            &overlay,
717            action,
718            resource.as_str(),
719            self.inner.store.cwd(),
720        );
721        let prior_len = overlay.rules.len();
722        overlay
723            .rules
724            .retain(|rule| rule.action != action || &rule.resource != resource);
725        if overlay.rules.len() != prior_len {
726            let new_effect = rule_effect(
727                &policy,
728                &overlay,
729                action,
730                resource.as_str(),
731                self.inner.store.cwd(),
732            );
733            if effect_rank(new_effect) > effect_rank(old_effect) {
734                self.invalidate_action_grants(session_id, &session.meta.origin, action)?;
735            }
736            self.append(
737                session_id,
738                None,
739                origin,
740                EventPayload::SessionPermissionOverlaySet { overlay },
741            )
742            .await?;
743        }
744        let result = self.get_session_permissions(session_id)?;
745        Ok(SessionPermissionMutationResult {
746            permissions: result.permissions,
747        })
748    }
749
750    fn invalidate_action_grants(
751        &self,
752        session_id: SessionId,
753        origin: &SessionOrigin,
754        action: PermissionAction,
755    ) -> Result<(), crate::EngineError> {
756        let root = match origin {
757            SessionOrigin::Root => session_id,
758            SessionOrigin::Delegated {
759                root_session_id, ..
760            } => *root_session_id,
761        };
762        let grants = self
763            .inner
764            .approvals
765            .store
766            .for_root(root)
767            .into_iter()
768            .filter(|grant| {
769                grant
770                    .resources
771                    .iter()
772                    .any(|resource| resource.capability == action)
773            })
774            .collect::<Vec<_>>();
775        let ids = grants
776            .iter()
777            .map(|grant| grant.grant_id)
778            .collect::<HashSet<_>>();
779        if ids.is_empty() {
780            return Ok(());
781        }
782        let digests = grants
783            .iter()
784            .flat_map(|grant| grant.resources.iter())
785            .filter(|resource| resource.capability == action)
786            .map(|resource| resource.binding_digest.digest().clone())
787            .collect();
788        self.inner
789            .grant_journal
790            .invalidate(root, ids.iter().copied().collect(), digests)?;
791        self.inner.approvals.store.invalidate_grants(&ids);
792        Ok(())
793    }
794}
795
796pub(crate) fn governing_agent_for_skills(
797    session: &crate::session::SessionProjection,
798) -> AgentSnapshot {
799    governing_agent(session)
800}
801
802fn governing_agent(session: &crate::session::SessionProjection) -> AgentSnapshot {
803    let latest_run =
804        session
805            .log
806            .event_snapshot()
807            .iter()
808            .rev()
809            .find_map(|event| match &event.payload {
810                EventPayload::RunStarted { agent, .. } => Some(agent.as_ref().clone()),
811                _ => None,
812            });
813    select_governing_agent(&session.creation_agent, latest_run.as_ref())
814}
815
816fn select_governing_agent(
817    creation_agent: &AgentSnapshot,
818    latest_run_agent: Option<&AgentSnapshot>,
819) -> AgentSnapshot {
820    latest_run_agent.unwrap_or(creation_agent).clone()
821}
822
823fn effect_rank(effect: PermissionEffect) -> u8 {
824    match effect {
825        PermissionEffect::Allow => 0,
826        PermissionEffect::Ask => 1,
827        PermissionEffect::Deny => 2,
828    }
829}
830
831fn rule_effect(
832    policy: &AgentSnapshot,
833    overlay: &SessionPermissionOverlay,
834    action: PermissionAction,
835    resource: &str,
836    workspace: &Path,
837) -> PermissionEffect {
838    if resource == "*" {
839        effective_loose_permission_with_overlay(policy, Some(overlay), action).0
840    } else {
841        effective_permission_with_overlay(policy, Some(overlay), action, resource, workspace).0
842    }
843}
844
845fn effective_permission_view(
846    policy: &AgentSnapshot,
847    overlay: &SessionPermissionOverlay,
848) -> Vec<EffectivePermissionAction> {
849    [
850        PermissionAction::Read,
851        PermissionAction::Write,
852        PermissionAction::Bash,
853        PermissionAction::Delegate,
854        PermissionAction::Message,
855        PermissionAction::Mcp,
856        PermissionAction::Plugin,
857        PermissionAction::Skill,
858        PermissionAction::Webfetch,
859    ]
860    .into_iter()
861    .map(|action| {
862        let overlay_wildcard = overlay
863            .rules
864            .iter()
865            .rev()
866            .find(|rule| rule.action == action && rule.resource.as_str() == "*");
867        let agent_wildcard = policy
868            .permissions
869            .iter()
870            .rev()
871            .find(|rule| rule.action == action && rule.resource.as_str() == "*");
872        let (effect, source) = overlay_wildcard.map_or_else(
873            || {
874                agent_wildcard.map_or(
875                    (PermissionEffect::Deny, PermissionRuleSource::Default),
876                    |rule| (rule.effect, PermissionRuleSource::AgentDocument),
877                )
878            },
879            |rule| (rule.effect, PermissionRuleSource::SessionOverlay),
880        );
881        let mut patterns = BTreeMap::new();
882        if overlay_wildcard.is_none() {
883            for rule in policy
884                .permissions
885                .iter()
886                .filter(|rule| rule.action == action && rule.resource.as_str() != "*")
887            {
888                patterns.insert(
889                    rule.resource.as_str().to_owned(),
890                    EffectivePermissionRule {
891                        resource: rule.resource.clone(),
892                        effect: rule.effect,
893                        source: PermissionRuleSource::AgentDocument,
894                    },
895                );
896            }
897        }
898        for rule in overlay
899            .rules
900            .iter()
901            .filter(|rule| rule.action == action && rule.resource.as_str() != "*")
902        {
903            patterns.insert(
904                rule.resource.as_str().to_owned(),
905                EffectivePermissionRule {
906                    resource: rule.resource.clone(),
907                    effect: rule.effect,
908                    source: PermissionRuleSource::SessionOverlay,
909                },
910            );
911        }
912        EffectivePermissionAction {
913            action,
914            effect,
915            source,
916            patterns: patterns.into_values().collect(),
917        }
918    })
919    .collect()
920}
921
922#[cfg(test)]
923mod tests;