Skip to main content

roder_core/runtime/
thread_policy.rs

1use super::*;
2use roder_api::extension_state::{ExtensionStateRecord, ExtensionStoreScope};
3
4const POLICY_EXTENSION: &str = "roder-core";
5const POLICY_KEY: &str = "thread-policy-mode";
6
7impl Runtime {
8    /// Persist a thread-specific policy without changing other sessions' permissions.
9    pub async fn set_thread_policy_mode(
10        &self,
11        thread_id: &str,
12        mode: PolicyMode,
13    ) -> anyhow::Result<()> {
14        let previous_mode = self.effective_policy_mode_for_thread(thread_id).await;
15        let mut modes = self.thread_policy_modes.write().await;
16        if let Some(store) = &self.thread_store {
17            let id = thread_id.to_string();
18            anyhow::ensure!(
19                store.load_thread_metadata(&id).await?.is_some(),
20                "thread not found: {thread_id}"
21            );
22            store
23                .append_extension_state(
24                    &id,
25                    &ExtensionStateRecord {
26                        extension_id: POLICY_EXTENSION.into(),
27                        key: POLICY_KEY.into(),
28                        scope: ExtensionStoreScope::Thread {
29                            thread_id: id.clone(),
30                        },
31                        schema_version: 1,
32                        value: serde_json::to_value(mode)?,
33                    },
34                )
35                .await?;
36        }
37        modes.insert(thread_id.into(), mode);
38        drop(modes);
39        self.emit(RoderEvent::PolicyModeChanged(PolicyModeChanged {
40            thread_id: thread_id.into(),
41            turn_id: None,
42            previous_mode,
43            new_mode: mode,
44            reason: Some("thread mode selection".into()),
45            timestamp: OffsetDateTime::now_utc(),
46        }))
47        .await;
48        self.auto_resolve_pending_tool_approvals().await;
49        Ok(())
50    }
51
52    pub async fn thread_policy_mode(&self, thread_id: &str) -> anyhow::Result<Option<PolicyMode>> {
53        let mut modes = self.thread_policy_modes.write().await;
54        if let Some(mode) = modes.get(thread_id).copied() {
55            return Ok(Some(mode));
56        }
57        let Some(store) = &self.thread_store else {
58            return Ok(None);
59        };
60        let states = store.load_extension_states(&thread_id.to_string()).await?;
61        let record = states
62            .iter()
63            .rev()
64            .find(|state| state.extension_id == POLICY_EXTENSION && state.key == POLICY_KEY);
65        let Some(record) = record else {
66            return Ok(None);
67        };
68        anyhow::ensure!(
69            record.schema_version == 1
70                && record.scope
71                    == (ExtensionStoreScope::Thread {
72                        thread_id: thread_id.into()
73                    }),
74            "invalid thread policy state"
75        );
76        let mode = serde_json::from_value(record.value.clone())?;
77        modes.insert(thread_id.into(), mode);
78        Ok(Some(mode))
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[tokio::test]
87    async fn thread_policy_plan_exit_changes_only_the_requesting_thread() {
88        let mut builder = roder_api::extension::ExtensionRegistryBuilder::new();
89        builder.inference_engine(Arc::new(crate::fake_provider::FakeInferenceEngine));
90        let runtime = Runtime::new(builder.build().unwrap(), Default::default()).unwrap();
91        runtime
92            .set_thread_policy_mode("a", PolicyMode::Plan)
93            .await
94            .unwrap();
95        runtime
96            .set_thread_policy_mode("b", PolicyMode::Plan)
97            .await
98            .unwrap();
99        runtime
100            .record_pending_plan_exit(PendingPlanExit::new(
101                "a".into(),
102                "turn".into(),
103                "exit".into(),
104                PolicyMode::AcceptAll,
105                Some("Approved implementation".into()),
106                vec![],
107            ))
108            .await;
109        runtime
110            .resolve_pending_plan_exit("exit", true)
111            .await
112            .unwrap();
113        assert_eq!(
114            runtime.effective_policy_mode_for_thread("a").await,
115            PolicyMode::AcceptAll
116        );
117        assert_eq!(
118            runtime.effective_policy_mode_for_thread("b").await,
119            PolicyMode::Plan
120        );
121        assert_eq!(runtime.status().await.policy_mode, PolicyMode::Default);
122    }
123
124    #[tokio::test]
125    async fn thread_policy_denied_plan_exit_reports_scoped_mode() {
126        let mut builder = roder_api::extension::ExtensionRegistryBuilder::new();
127        builder.inference_engine(Arc::new(crate::fake_provider::FakeInferenceEngine));
128        let runtime = Runtime::new(builder.build().unwrap(), Default::default()).unwrap();
129        runtime
130            .set_thread_policy_mode("a", PolicyMode::Plan)
131            .await
132            .unwrap();
133        runtime
134            .set_policy_mode(PolicyMode::Bypass, None)
135            .await
136            .unwrap();
137        let mut events = runtime.subscribe_events();
138        runtime
139            .record_pending_plan_exit(PendingPlanExit::new(
140                "a".into(),
141                "turn".into(),
142                "exit".into(),
143                PolicyMode::Default,
144                None,
145                vec![],
146            ))
147            .await;
148        runtime
149            .resolve_pending_plan_exit("exit", false)
150            .await
151            .unwrap();
152        loop {
153            if let RoderEvent::PolicyExitPlanResolved(event) = events.recv().await.unwrap().event {
154                assert!(!event.approved);
155                assert_eq!(event.resolved_mode, PolicyMode::Plan);
156                break;
157            }
158        }
159        assert_eq!(
160            runtime.effective_policy_mode_for_thread("a").await,
161            PolicyMode::Plan
162        );
163        assert_eq!(runtime.status().await.policy_mode, PolicyMode::Bypass);
164    }
165}