1use std::collections::{BTreeSet, HashMap};
2
3use roder_api::dynamic_workflows::{WorkflowApprovalDecision, WorkflowConsent, WorkflowScriptHash};
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use time::OffsetDateTime;
7
8#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
9#[serde(rename_all = "camelCase")]
10pub enum WorkflowApprovalScope {
11 RunOnce,
12 ScriptAndWorkspace,
13}
14
15impl WorkflowApprovalScope {
16 pub fn from_decision(decision: WorkflowApprovalDecision) -> Option<Self> {
17 match decision {
18 WorkflowApprovalDecision::RunOnce => Some(Self::RunOnce),
19 WorkflowApprovalDecision::AlwaysForScriptAndWorkspace => Some(Self::ScriptAndWorkspace),
20 WorkflowApprovalDecision::Deny => None,
21 }
22 }
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
26#[serde(rename_all = "camelCase")]
27pub struct WorkflowConsentKey {
28 pub script_hash: WorkflowScriptHash,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub workspace: Option<String>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub source_path: Option<String>,
33 pub scope: WorkflowApprovalScope,
34}
35
36impl WorkflowConsentKey {
37 pub fn new(
38 script_hash: impl Into<WorkflowScriptHash>,
39 workspace: Option<impl Into<String>>,
40 source_path: Option<impl Into<String>>,
41 scope: WorkflowApprovalScope,
42 ) -> Self {
43 Self {
44 script_hash: script_hash.into(),
45 workspace: workspace.map(Into::into),
46 source_path: source_path.map(Into::into),
47 scope,
48 }
49 }
50
51 pub fn reusable(
52 script_hash: impl Into<WorkflowScriptHash>,
53 workspace: Option<impl Into<String>>,
54 source_path: Option<impl Into<String>>,
55 ) -> Self {
56 Self::new(
57 script_hash,
58 workspace,
59 source_path,
60 WorkflowApprovalScope::ScriptAndWorkspace,
61 )
62 }
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
66#[serde(rename_all = "camelCase")]
67pub struct StoredWorkflowConsent {
68 pub key: WorkflowConsentKey,
69 pub consent: WorkflowConsent,
70}
71
72impl StoredWorkflowConsent {
73 pub fn approved_capabilities(&self) -> BTreeSet<&str> {
74 self.consent
75 .approved_capabilities
76 .iter()
77 .map(String::as_str)
78 .collect()
79 }
80}
81
82#[derive(Debug, Clone, Default)]
83pub struct WorkflowConsentStore {
84 entries: HashMap<WorkflowConsentKey, StoredWorkflowConsent>,
85}
86
87impl WorkflowConsentStore {
88 pub fn record(
89 &mut self,
90 key: WorkflowConsentKey,
91 approved_capabilities: Vec<String>,
92 decided_at: OffsetDateTime,
93 expires_at: Option<OffsetDateTime>,
94 ) -> StoredWorkflowConsent {
95 let consent = WorkflowConsent {
96 script_hash: key.script_hash.clone(),
97 workspace: key.workspace.clone(),
98 decision: match key.scope {
99 WorkflowApprovalScope::RunOnce => WorkflowApprovalDecision::RunOnce,
100 WorkflowApprovalScope::ScriptAndWorkspace => {
101 WorkflowApprovalDecision::AlwaysForScriptAndWorkspace
102 }
103 },
104 approved_capabilities,
105 decided_at,
106 expires_at,
107 };
108 let stored = StoredWorkflowConsent { key, consent };
109 self.entries.insert(stored.key.clone(), stored.clone());
110 stored
111 }
112
113 pub fn reusable_consent(
114 &self,
115 key: &WorkflowConsentKey,
116 now: OffsetDateTime,
117 requested_capabilities: &[String],
118 ) -> Option<&StoredWorkflowConsent> {
119 if key.scope != WorkflowApprovalScope::ScriptAndWorkspace {
120 return None;
121 }
122 let stored = self.entries.get(key)?;
123 if stored
124 .consent
125 .expires_at
126 .is_some_and(|expires_at| expires_at <= now)
127 {
128 return None;
129 }
130 let approved = stored.approved_capabilities();
131 requested_capabilities
132 .iter()
133 .all(|capability| approved.contains(capability.as_str()))
134 .then_some(stored)
135 }
136
137 pub fn get(&self, key: &WorkflowConsentKey) -> Option<&StoredWorkflowConsent> {
138 self.entries.get(key)
139 }
140
141 pub fn len(&self) -> usize {
142 self.entries.len()
143 }
144
145 pub fn is_empty(&self) -> bool {
146 self.entries.is_empty()
147 }
148}
149
150pub fn workflow_script_hash(source: &str) -> WorkflowScriptHash {
151 let digest = Sha256::digest(source.as_bytes());
152 let mut output = String::with_capacity(digest.len() * 2);
153 for byte in digest {
154 use std::fmt::Write as _;
155 write!(&mut output, "{byte:02x}").expect("writing to a string cannot fail");
156 }
157 output
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn reusable_consent_is_keyed_by_script_workspace_source_and_scope() {
166 let now = OffsetDateTime::UNIX_EPOCH;
167 let hash = workflow_script_hash("workflow.define({name:'audit'}, async () => {})");
168 let key = WorkflowConsentKey::reusable(
169 hash.clone(),
170 Some("/workspace"),
171 Some(".agents/workflows/audit.workflow.js"),
172 );
173 let mut store = WorkflowConsentStore::default();
174 store.record(
175 key.clone(),
176 vec!["childAgents".to_string(), "checkpoints".to_string()],
177 now,
178 None,
179 );
180
181 assert!(
182 store
183 .reusable_consent(&key, now, &["childAgents".to_string()])
184 .is_some()
185 );
186
187 let different_source = WorkflowConsentKey::reusable(
188 hash,
189 Some("/workspace"),
190 Some(".agents/workflows/other.workflow.js"),
191 );
192 assert!(
193 store
194 .reusable_consent(&different_source, now, &["childAgents".to_string()])
195 .is_none()
196 );
197 }
198
199 #[test]
200 fn reusable_consent_does_not_expand_capabilities_or_expired_grants() {
201 let now = OffsetDateTime::UNIX_EPOCH;
202 let key = WorkflowConsentKey::reusable("hash", Some("/workspace"), None::<String>);
203 let mut store = WorkflowConsentStore::default();
204 store.record(
205 key.clone(),
206 vec!["childAgents".to_string()],
207 now,
208 Some(now + time::Duration::seconds(5)),
209 );
210
211 assert!(
212 store
213 .reusable_consent(&key, now, &["shell".to_string()])
214 .is_none()
215 );
216 assert!(
217 store
218 .reusable_consent(
219 &key,
220 now + time::Duration::seconds(6),
221 &["childAgents".to_string()]
222 )
223 .is_none()
224 );
225 }
226
227 #[test]
228 fn run_once_approval_is_not_reusable_consent() {
229 let now = OffsetDateTime::UNIX_EPOCH;
230 let key = WorkflowConsentKey::new(
231 "hash",
232 Some("/workspace"),
233 None::<String>,
234 WorkflowApprovalScope::RunOnce,
235 );
236 let mut store = WorkflowConsentStore::default();
237 store.record(key.clone(), vec!["childAgents".to_string()], now, None);
238
239 assert!(
240 store
241 .reusable_consent(&key, now, &["childAgents".to_string()])
242 .is_none()
243 );
244 }
245}