@@ -8,6 +8,14 @@ use crate::{
88 SESSION_SKILL_POLICY_KEY_PREFIX ,
99} ;
1010
11+ #[ derive( Debug , Clone ) ]
12+ pub ( crate ) struct SessionPolicySnapshot {
13+ pub ( crate ) allowed_tools : Option < Vec < String > > ,
14+ pub ( crate ) allowed_skills : Option < Vec < String > > ,
15+ pub ( crate ) skill_policy : SessionSkillPolicy ,
16+ pub ( crate ) mcp_config_json : Option < String > ,
17+ }
18+
1119fn normalize_names ( values : impl IntoIterator < Item = String > ) -> Vec < String > {
1220 let mut cleaned = Vec :: new ( ) ;
1321 let mut seen = HashSet :: new ( ) ;
@@ -40,6 +48,33 @@ impl AgentRuntime {
4048 format ! ( "{SESSION_SKILL_POLICY_KEY_PREFIX}{session_id}" )
4149 }
4250
51+ pub ( crate ) fn load_session_policy_snapshot (
52+ & self ,
53+ session_id : & str ,
54+ ) -> anyhow:: Result < SessionPolicySnapshot > {
55+ Ok ( SessionPolicySnapshot {
56+ allowed_tools : self . load_session_allowed_tools ( session_id) ?,
57+ allowed_skills : self . load_session_allowed_skills ( session_id) ?,
58+ skill_policy : self . load_session_skill_policy ( session_id) ?,
59+ mcp_config_json : self . load_session_mcp_config ( session_id) ?,
60+ } )
61+ }
62+
63+ pub fn configure_session_tooling (
64+ & self ,
65+ session_id : & str ,
66+ allowed_tools : Option < Vec < String > > ,
67+ mcp_config_json : Option < String > ,
68+ ) -> anyhow:: Result < ( ) > {
69+ if let Some ( tools) = allowed_tools {
70+ self . set_session_allowed_tools ( session_id, tools) ?;
71+ }
72+ if let Some ( config_json) = mcp_config_json {
73+ self . set_session_mcp_config ( session_id, config_json) ?;
74+ }
75+ Ok ( ( ) )
76+ }
77+
4378 pub fn set_session_allowed_tools (
4479 & self ,
4580 session_id : & str ,
@@ -157,3 +192,196 @@ impl AgentRuntime {
157192 Ok ( policy)
158193 }
159194}
195+
196+ #[ cfg( test) ]
197+ mod tests {
198+ use std:: collections:: BTreeMap ;
199+
200+ use rexos_kernel:: config:: { LlmConfig , ProviderConfig , ProviderKind , RexosConfig , RouteConfig } ;
201+ use rexos_kernel:: paths:: RexosPaths ;
202+ use rexos_kernel:: router:: { ModelRouter , TaskKind } ;
203+ use rexos_kernel:: security:: SecurityConfig ;
204+ use rexos_llm:: registry:: LlmRegistry ;
205+ use rexos_memory:: MemoryStore ;
206+
207+ use crate :: records:: { WorkflowRunToolArgs , WorkflowStepToolArgs } ;
208+ use crate :: AgentRuntime ;
209+
210+ fn build_agent ( memory : MemoryStore ) -> AgentRuntime {
211+ let mut providers = BTreeMap :: new ( ) ;
212+ providers. insert (
213+ "ollama" . to_string ( ) ,
214+ ProviderConfig {
215+ kind : ProviderKind :: OpenAiCompatible ,
216+ base_url : "http://127.0.0.1:11434/v1" . to_string ( ) ,
217+ api_key_env : "" . to_string ( ) ,
218+ default_model : "x" . to_string ( ) ,
219+ aws_bedrock : None ,
220+ } ,
221+ ) ;
222+
223+ let security = SecurityConfig :: default ( ) ;
224+ let cfg = RexosConfig {
225+ llm : LlmConfig :: default ( ) ,
226+ providers,
227+ router : Default :: default ( ) ,
228+ security : security. clone ( ) ,
229+ } ;
230+ let llms = LlmRegistry :: from_config ( & cfg) . unwrap ( ) ;
231+ let router = ModelRouter :: new ( rexos_kernel:: config:: RouterConfig {
232+ planning : RouteConfig {
233+ provider : "ollama" . to_string ( ) ,
234+ model : "x" . to_string ( ) ,
235+ } ,
236+ coding : RouteConfig {
237+ provider : "ollama" . to_string ( ) ,
238+ model : "x" . to_string ( ) ,
239+ } ,
240+ summary : RouteConfig {
241+ provider : "ollama" . to_string ( ) ,
242+ model : "x" . to_string ( ) ,
243+ } ,
244+ } ) ;
245+ AgentRuntime :: new_with_security_config ( memory, llms, router, security)
246+ }
247+
248+ #[ test]
249+ fn session_policy_snapshot_round_trips_and_normalizes ( ) {
250+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
251+ let paths = RexosPaths {
252+ base_dir : tmp. path ( ) . join ( ".loopforge" ) ,
253+ } ;
254+ paths. ensure_dirs ( ) . unwrap ( ) ;
255+
256+ let memory = MemoryStore :: open_or_create ( & paths) . unwrap ( ) ;
257+ let agent = build_agent ( memory) ;
258+
259+ agent
260+ . set_session_allowed_tools (
261+ "s1" ,
262+ vec ! [
263+ " fs_read " . to_string( ) ,
264+ "" . to_string( ) ,
265+ "fs_write" . to_string( ) ,
266+ "fs_read" . to_string( ) ,
267+ ] ,
268+ )
269+ . unwrap ( ) ;
270+ agent
271+ . set_session_allowed_skills (
272+ "s1" ,
273+ vec ! [
274+ " safe-skill " . to_string( ) ,
275+ "safe-skill" . to_string( ) ,
276+ "x" . to_string( ) ,
277+ "" . to_string( ) ,
278+ ] ,
279+ )
280+ . unwrap ( ) ;
281+ agent
282+ . set_session_skill_policy (
283+ "s1" ,
284+ crate :: SessionSkillPolicy {
285+ allowlist : vec ! [ "shell-helper" . to_string( ) ] ,
286+ require_approval : true ,
287+ auto_approve_readonly : false ,
288+ } ,
289+ )
290+ . unwrap ( ) ;
291+ agent
292+ . set_session_mcp_config ( "s1" , " {\" servers\" :{}} " . to_string ( ) )
293+ . unwrap ( ) ;
294+
295+ let snapshot = agent. load_session_policy_snapshot ( "s1" ) . unwrap ( ) ;
296+ assert_eq ! (
297+ snapshot. allowed_tools,
298+ Some ( vec![ "fs_read" . to_string( ) , "fs_write" . to_string( ) ] )
299+ ) ;
300+ assert_eq ! (
301+ snapshot. allowed_skills,
302+ Some ( vec![ "safe-skill" . to_string( ) , "x" . to_string( ) ] )
303+ ) ;
304+ assert_eq ! (
305+ snapshot. mcp_config_json,
306+ Some ( "{\" servers\" :{}}" . to_string( ) )
307+ ) ;
308+ assert_eq ! (
309+ snapshot. skill_policy. allowlist,
310+ vec![ "shell-helper" . to_string( ) ]
311+ ) ;
312+ assert ! ( snapshot. skill_policy. require_approval) ;
313+ assert ! ( !snapshot. skill_policy. auto_approve_readonly) ;
314+ }
315+
316+ #[ test]
317+ fn session_policy_snapshot_defaults_when_missing_or_blank ( ) {
318+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
319+ let paths = RexosPaths {
320+ base_dir : tmp. path ( ) . join ( ".loopforge" ) ,
321+ } ;
322+ paths. ensure_dirs ( ) . unwrap ( ) ;
323+
324+ let memory = MemoryStore :: open_or_create ( & paths) . unwrap ( ) ;
325+ let agent = build_agent ( memory) ;
326+
327+ agent
328+ . set_session_mcp_config ( "s2" , " " . to_string ( ) )
329+ . unwrap ( ) ;
330+ let snapshot = agent. load_session_policy_snapshot ( "s2" ) . unwrap ( ) ;
331+ assert ! ( snapshot. allowed_tools. is_none( ) ) ;
332+ assert ! ( snapshot. allowed_skills. is_none( ) ) ;
333+ assert ! ( snapshot. mcp_config_json. is_none( ) ) ;
334+ assert ! ( !snapshot. skill_policy. auto_approve_readonly) ;
335+ assert ! ( !snapshot. skill_policy. require_approval) ;
336+ assert ! ( snapshot. skill_policy. allowlist. is_empty( ) ) ;
337+ }
338+
339+ #[ tokio:: test]
340+ async fn session_policy_workflow_uses_allowed_tools_snapshot ( ) {
341+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
342+ let workspace_root = tmp. path ( ) . join ( "workspace" ) ;
343+ std:: fs:: create_dir_all ( & workspace_root) . unwrap ( ) ;
344+
345+ let paths = RexosPaths {
346+ base_dir : tmp. path ( ) . join ( ".loopforge" ) ,
347+ } ;
348+ paths. ensure_dirs ( ) . unwrap ( ) ;
349+ let memory = MemoryStore :: open_or_create ( & paths) . unwrap ( ) ;
350+ let agent = build_agent ( memory) ;
351+
352+ agent
353+ . set_session_allowed_tools ( "s3" , vec ! [ "fs_read" . to_string( ) ] )
354+ . unwrap ( ) ;
355+
356+ let res = agent
357+ . workflow_run (
358+ & workspace_root,
359+ "s3" ,
360+ TaskKind :: Coding ,
361+ WorkflowRunToolArgs {
362+ workflow_id : Some ( "wf-policy" . to_string ( ) ) ,
363+ name : None ,
364+ steps : vec ! [ WorkflowStepToolArgs {
365+ tool: "fs_write" . to_string( ) ,
366+ arguments: serde_json:: json!( {
367+ "path" : "x.txt" ,
368+ "content" : "blocked" ,
369+ } ) ,
370+ name: None ,
371+ approval_required: None ,
372+ } ] ,
373+ continue_on_error : None ,
374+ } ,
375+ )
376+ . await
377+ . unwrap ( ) ;
378+
379+ let res: serde_json:: Value = serde_json:: from_str ( & res) . unwrap ( ) ;
380+ let saved_to = res[ "saved_to" ] . as_str ( ) . unwrap ( ) ;
381+ let state_raw = std:: fs:: read_to_string ( saved_to) . unwrap ( ) ;
382+ let state: serde_json:: Value = serde_json:: from_str ( & state_raw) . unwrap ( ) ;
383+ let err = state[ "steps" ] [ 0 ] [ "error" ] . as_str ( ) . unwrap ( ) ;
384+ assert ! ( err. contains( "workflow step 0 (fs_write)" ) , "got: {err}" ) ;
385+ assert ! ( !workspace_root. join( "x.txt" ) . exists( ) ) ;
386+ }
387+ }
0 commit comments