Skip to main content

roder_dynamic_workflows/
approval.rs

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}