1use super::mission::AgentMission;
4use sim_kernel::{Expr, Result, Symbol};
5use sim_lib_stream_core::{DevCassette, DevEvent};
6
7#[derive(Clone, Debug, PartialEq, Eq)]
9pub enum GuardCapability {
10 EditRepo(String),
12 RegenDocs(String),
14 PlanPin,
16 RunValidation(String),
18 EvalGated,
20}
21
22#[derive(Clone, Debug, PartialEq, Eq)]
24pub enum AtelierAction {
25 EditFile {
27 repo: String,
29 path: String,
31 },
32 RegenDocs {
34 repo: String,
36 },
37 PlanPin {
39 repo: String,
41 },
42 RunValidation {
44 repo: String,
46 },
47 Eval {
49 label: String,
51 },
52 AddGithubRemote {
54 remote: String,
56 },
57 FlipPublishToGithub {
59 repo: String,
61 },
62 PushMirrorRemote,
64}
65
66impl AtelierAction {
67 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 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 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#[derive(Clone, Debug, PartialEq, Eq)]
104pub struct GuardRefusal {
105 action: AtelierAction,
106 reason: String,
107}
108
109impl GuardRefusal {
110 pub fn new(action: AtelierAction, reason: impl Into<String>) -> Self {
112 Self {
113 action,
114 reason: reason.into(),
115 }
116 }
117
118 pub fn action(&self) -> &AtelierAction {
120 &self.action
121 }
122
123 pub fn reason(&self) -> &str {
125 &self.reason
126 }
127
128 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#[derive(Clone, Debug, PartialEq, Eq)]
153pub enum GuardDecision {
154 Granted,
156 Refused(GuardRefusal),
158}
159
160impl GuardDecision {
161 pub fn is_granted(&self) -> bool {
163 matches!(self, Self::Granted)
164 }
165
166 pub fn refusal(&self) -> Option<&GuardRefusal> {
168 match self {
169 Self::Granted => None,
170 Self::Refused(refusal) => Some(refusal),
171 }
172 }
173}
174
175#[derive(Clone, Debug, PartialEq, Eq)]
177pub struct GuardEvaluation {
178 decision: GuardDecision,
179 cassette: Option<DevCassette>,
180}
181
182impl GuardEvaluation {
183 pub fn decision(&self) -> &GuardDecision {
185 &self.decision
186 }
187
188 pub fn cassette(&self) -> Option<&DevCassette> {
190 self.cassette.as_ref()
191 }
192}
193
194pub 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
212pub 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}