Skip to main content

sim_lib_agent/atelier/
guard.rs

1//! Guard tokens and refusal recording for Atelier actions.
2
3use super::mission::AgentMission;
4use sim_kernel::{Expr, Result, Symbol};
5use sim_lib_stream_core::{DevCassette, DevEvent};
6
7/// Capability a mission must hold to perform a guarded Atelier action.
8#[derive(Clone, Debug, PartialEq, Eq)]
9pub enum GuardCapability {
10    /// Permission to edit files in the named repository.
11    EditRepo(String),
12    /// Permission to regenerate docs for the named repository.
13    RegenDocs(String),
14    /// Permission to plan a `repos.toml` commit pin update.
15    PlanPin,
16    /// Permission to run the named repository's validation command.
17    RunValidation(String),
18    /// Permission to run a gated eval action.
19    EvalGated,
20}
21
22/// Concrete action an agent requests the guard to evaluate.
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub enum AtelierAction {
25    /// Edit a file within a repository.
26    EditFile {
27        /// Target repository name.
28        repo: String,
29        /// File path within the repository.
30        path: String,
31    },
32    /// Regenerate docs for a repository.
33    RegenDocs {
34        /// Target repository name.
35        repo: String,
36    },
37    /// Plan a `repos.toml` commit pin update for a repository.
38    PlanPin {
39        /// Target repository name.
40        repo: String,
41    },
42    /// Run a repository's validation command.
43    RunValidation {
44        /// Target repository name.
45        repo: String,
46    },
47    /// Run a gated eval action.
48    Eval {
49        /// Label identifying the eval.
50        label: String,
51    },
52    /// Add a GitHub remote (always hard-denied).
53    AddGithubRemote {
54        /// Remote being added.
55        remote: String,
56    },
57    /// Flip a repository's `publish_to_github` flag (always hard-denied).
58    FlipPublishToGithub {
59        /// Target repository name.
60        repo: String,
61    },
62    /// Push to a mirror remote (always hard-denied).
63    PushMirrorRemote,
64}
65
66impl AtelierAction {
67    /// Builds an [`AtelierAction::EditFile`] from repo and path.
68    pub fn edit_file(repo: impl Into<String>, path: impl Into<String>) -> Self {
69        Self::EditFile {
70            repo: repo.into(),
71            path: path.into(),
72        }
73    }
74
75    /// Returns the repository this action targets, if any.
76    pub fn repo(&self) -> Option<&str> {
77        match self {
78            Self::EditFile { repo, .. }
79            | Self::RegenDocs { repo }
80            | Self::PlanPin { repo }
81            | Self::RunValidation { repo }
82            | Self::FlipPublishToGithub { repo } => Some(repo),
83            Self::Eval { .. } | Self::AddGithubRemote { .. } | Self::PushMirrorRemote => None,
84        }
85    }
86
87    /// Returns a short human-readable label for this action.
88    pub fn label(&self) -> String {
89        match self {
90            Self::EditFile { repo, path } => format!("edit {repo}:{path}"),
91            Self::RegenDocs { repo } => format!("regen-docs {repo}"),
92            Self::PlanPin { repo } => format!("plan-pin {repo}"),
93            Self::RunValidation { repo } => format!("run-validation {repo}"),
94            Self::Eval { label } => format!("eval {label}"),
95            Self::AddGithubRemote { remote } => format!("add-github-remote {remote}"),
96            Self::FlipPublishToGithub { repo } => format!("flip-publish-to-github {repo}"),
97            Self::PushMirrorRemote => "push-mirror-remote".to_owned(),
98        }
99    }
100}
101
102/// Record of a denied action paired with the reason it was refused.
103#[derive(Clone, Debug, PartialEq, Eq)]
104pub struct GuardRefusal {
105    action: AtelierAction,
106    reason: String,
107}
108
109impl GuardRefusal {
110    /// Builds a refusal for an action and its reason.
111    pub fn new(action: AtelierAction, reason: impl Into<String>) -> Self {
112        Self {
113            action,
114            reason: reason.into(),
115        }
116    }
117
118    /// Returns the refused action.
119    pub fn action(&self) -> &AtelierAction {
120        &self.action
121    }
122
123    /// Returns the human-readable refusal reason.
124    pub fn reason(&self) -> &str {
125        &self.reason
126    }
127
128    /// Encodes this refusal as a refusal `DevEvent` for the given node.
129    pub fn dev_event(&self, atelier_node: Symbol) -> Result<DevEvent> {
130        DevEvent::refusal(atelier_node, self.payload_expr())
131    }
132
133    fn payload_expr(&self) -> Expr {
134        Expr::Map(vec![
135            (
136                Expr::Symbol(Symbol::new("refusal")),
137                Expr::Symbol(Symbol::qualified("atelier/refusal", "guard")),
138            ),
139            (
140                Expr::Symbol(Symbol::new("action")),
141                Expr::String(self.action.label()),
142            ),
143            (
144                Expr::Symbol(Symbol::new("reason")),
145                Expr::String(self.reason.clone()),
146            ),
147        ])
148    }
149}
150
151/// Outcome of guarding an action: granted or refused with a reason.
152#[derive(Clone, Debug, PartialEq, Eq)]
153pub enum GuardDecision {
154    /// The action is permitted.
155    Granted,
156    /// The action is denied, carrying the refusal record.
157    Refused(GuardRefusal),
158}
159
160impl GuardDecision {
161    /// Returns true when the action was granted.
162    pub fn is_granted(&self) -> bool {
163        matches!(self, Self::Granted)
164    }
165
166    /// Returns the refusal record when the action was refused.
167    pub fn refusal(&self) -> Option<&GuardRefusal> {
168        match self {
169            Self::Granted => None,
170            Self::Refused(refusal) => Some(refusal),
171        }
172    }
173}
174
175/// Guard decision plus any evidence cassette recorded for a refusal.
176#[derive(Clone, Debug, PartialEq, Eq)]
177pub struct GuardEvaluation {
178    decision: GuardDecision,
179    cassette: Option<DevCassette>,
180}
181
182impl GuardEvaluation {
183    /// Returns the guard decision.
184    pub fn decision(&self) -> &GuardDecision {
185        &self.decision
186    }
187
188    /// Returns the evidence cassette recorded for a refusal, if any.
189    pub fn cassette(&self) -> Option<&DevCassette> {
190        self.cassette.as_ref()
191    }
192}
193
194/// Guards an action and records a refusal cassette when it is denied.
195pub fn evaluate_guarded_action(
196    mission: &AgentMission,
197    action: AtelierAction,
198    atelier_node: Symbol,
199    stream_id: Symbol,
200) -> Result<GuardEvaluation> {
201    let decision = guard_action(mission, action);
202    let cassette = match &decision {
203        GuardDecision::Granted => None,
204        GuardDecision::Refused(refusal) => Some(DevCassette::from_events(
205            stream_id,
206            vec![refusal.dev_event(atelier_node)?],
207        )?),
208    };
209    Ok(GuardEvaluation { decision, cassette })
210}
211
212/// Decides an action against hard denials and the mission's capabilities.
213pub fn guard_action(mission: &AgentMission, action: AtelierAction) -> GuardDecision {
214    if let Some(reason) = hard_denial_reason(mission, &action) {
215        return refused(action, reason);
216    }
217    let Some(required) = required_capability(&action) else {
218        return GuardDecision::Granted;
219    };
220    if mission.capabilities().contains(&required) {
221        GuardDecision::Granted
222    } else {
223        refused(
224            action,
225            format!("missing guard capability {}", capability_label(&required)),
226        )
227    }
228}
229
230fn hard_denial_reason(mission: &AgentMission, action: &AtelierAction) -> Option<String> {
231    match action {
232        AtelierAction::EditFile { repo, path } => {
233            if path_has_meta_workspace(path) {
234                return Some("edits under .meta-workspace are denied".to_owned());
235            }
236            if repo != mission.leased_repo() {
237                return Some(format!(
238                    "mission lease is {}, not {repo}",
239                    mission.leased_repo()
240                ));
241            }
242            if mission.code_free_repos().iter().any(|r| r == repo) && path.ends_with(".rs") {
243                return Some(format!("Rust source is denied in {repo}"));
244            }
245            None
246        }
247        AtelierAction::AddGithubRemote { .. } => {
248            Some("adding a GitHub remote is denied".to_owned())
249        }
250        AtelierAction::FlipPublishToGithub { .. } => {
251            Some("changing publish_to_github is denied".to_owned())
252        }
253        AtelierAction::PushMirrorRemote => Some("pushing to a mirror remote is denied".to_owned()),
254        AtelierAction::RegenDocs { .. }
255        | AtelierAction::PlanPin { .. }
256        | AtelierAction::RunValidation { .. }
257        | AtelierAction::Eval { .. } => None,
258    }
259}
260
261fn required_capability(action: &AtelierAction) -> Option<GuardCapability> {
262    match action {
263        AtelierAction::EditFile { repo, .. } => Some(GuardCapability::EditRepo(repo.clone())),
264        AtelierAction::RegenDocs { repo } => Some(GuardCapability::RegenDocs(repo.clone())),
265        AtelierAction::PlanPin { .. } => Some(GuardCapability::PlanPin),
266        AtelierAction::RunValidation { repo } => Some(GuardCapability::RunValidation(repo.clone())),
267        AtelierAction::Eval { .. } => Some(GuardCapability::EvalGated),
268        AtelierAction::AddGithubRemote { .. }
269        | AtelierAction::FlipPublishToGithub { .. }
270        | AtelierAction::PushMirrorRemote => None,
271    }
272}
273
274fn refused(action: AtelierAction, reason: impl Into<String>) -> GuardDecision {
275    GuardDecision::Refused(GuardRefusal::new(action, reason))
276}
277
278fn capability_label(capability: &GuardCapability) -> String {
279    match capability {
280        GuardCapability::EditRepo(repo) => format!("EditRepo({repo})"),
281        GuardCapability::RegenDocs(repo) => format!("RegenDocs({repo})"),
282        GuardCapability::PlanPin => "PlanPin".to_owned(),
283        GuardCapability::RunValidation(repo) => format!("RunValidation({repo})"),
284        GuardCapability::EvalGated => "EvalGated".to_owned(),
285    }
286}
287
288fn path_has_meta_workspace(path: &str) -> bool {
289    path.split(['/', '\\'])
290        .any(|component| component == ".meta-workspace")
291}