Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions crates/loopforge-cli/src/dispatch/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,21 @@ pub(super) async fn run(command: AgentCommand) -> anyhow::Result<()> {
Some(id) => id,
None => rexos::harness::resolve_session_id(&workspace)?,
};
if !allowed_tools.is_empty() {
agent.set_session_allowed_tools(&session_id, allowed_tools)?;
}
if let Some(path) = mcp_config.as_ref() {
let raw = std::fs::read_to_string(path)?;
let json: serde_json::Value =
serde_json::from_str(&raw).map_err(|err| anyhow::anyhow!("{err}"))?;
agent.set_session_mcp_config(&session_id, serde_json::to_string(&json)?)?;
}
let allowed_tools = if allowed_tools.is_empty() {
None
} else {
Some(allowed_tools)
};
let mcp_config_json = match mcp_config.as_ref() {
Some(path) => {
let raw = std::fs::read_to_string(path)?;
let json: serde_json::Value =
serde_json::from_str(&raw).map_err(|err| anyhow::anyhow!("{err}"))?;
Some(serde_json::to_string(&json)?)
}
None => None,
};
agent.configure_session_tooling(&session_id, allowed_tools, mcp_config_json)?;
let out = agent
.run_session(
workspace,
Expand Down
9 changes: 5 additions & 4 deletions crates/rexos-runtime/src/session_runner/chat_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,17 @@ impl AgentRuntime {
user_prompt: &str,
kind: TaskKind,
) -> anyhow::Result<String> {
let allowed_tools = self.load_session_allowed_tools(session_id)?;
let allowed_lookup: Option<HashSet<String>> = allowed_tools
let mut policy = self.load_session_policy_snapshot(session_id)?;
let allowed_lookup: Option<HashSet<String>> = policy
.allowed_tools
.as_ref()
.map(|tools| tools.iter().cloned().collect());
let mcp_config = self.load_session_mcp_config(session_id)?;
let allowed_tools = policy.allowed_tools.take();
let tools = Toolset::new_with_allowed_tools_security_and_mcp_config(
workspace_root.clone(),
allowed_tools,
self.security.clone(),
mcp_config.as_deref(),
policy.mcp_config_json.as_deref(),
)
.await?;
let provider = self.router.provider_for(kind);
Expand Down
6 changes: 4 additions & 2 deletions crates/rexos-runtime/src/session_skills/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ impl AgentRuntime {
skill_name: &str,
requested_permissions: &[String],
) -> anyhow::Result<()> {
if let Some(allowed_skills) = self.load_session_allowed_skills(session_id)? {
let session_policy = self.load_session_policy_snapshot(session_id)?;

if let Some(allowed_skills) = session_policy.allowed_skills.as_ref() {
if !allowed_skills
.iter()
.any(|skill| skill.eq_ignore_ascii_case(skill_name.trim()))
Expand All @@ -52,7 +54,7 @@ impl AgentRuntime {
}
}

let policy: SessionSkillPolicy = self.load_session_skill_policy(session_id)?;
let policy: SessionSkillPolicy = session_policy.skill_policy;
if !policy.allowlist.is_empty()
&& !policy
.allowlist
Expand Down
228 changes: 228 additions & 0 deletions crates/rexos-runtime/src/session_skills/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ use crate::{
SESSION_SKILL_POLICY_KEY_PREFIX,
};

#[derive(Debug, Clone)]
pub(crate) struct SessionPolicySnapshot {
pub(crate) allowed_tools: Option<Vec<String>>,
pub(crate) allowed_skills: Option<Vec<String>>,
pub(crate) skill_policy: SessionSkillPolicy,
pub(crate) mcp_config_json: Option<String>,
}

fn normalize_names(values: impl IntoIterator<Item = String>) -> Vec<String> {
let mut cleaned = Vec::new();
let mut seen = HashSet::new();
Expand Down Expand Up @@ -40,6 +48,33 @@ impl AgentRuntime {
format!("{SESSION_SKILL_POLICY_KEY_PREFIX}{session_id}")
}

pub(crate) fn load_session_policy_snapshot(
&self,
session_id: &str,
) -> anyhow::Result<SessionPolicySnapshot> {
Ok(SessionPolicySnapshot {
allowed_tools: self.load_session_allowed_tools(session_id)?,
allowed_skills: self.load_session_allowed_skills(session_id)?,
skill_policy: self.load_session_skill_policy(session_id)?,
mcp_config_json: self.load_session_mcp_config(session_id)?,
})
}

pub fn configure_session_tooling(
&self,
session_id: &str,
allowed_tools: Option<Vec<String>>,
mcp_config_json: Option<String>,
) -> anyhow::Result<()> {
if let Some(tools) = allowed_tools {
self.set_session_allowed_tools(session_id, tools)?;
}
if let Some(config_json) = mcp_config_json {
self.set_session_mcp_config(session_id, config_json)?;
}
Ok(())
}

pub fn set_session_allowed_tools(
&self,
session_id: &str,
Expand Down Expand Up @@ -157,3 +192,196 @@ impl AgentRuntime {
Ok(policy)
}
}

#[cfg(test)]
mod tests {
use std::collections::BTreeMap;

use rexos_kernel::config::{LlmConfig, ProviderConfig, ProviderKind, RexosConfig, RouteConfig};
use rexos_kernel::paths::RexosPaths;
use rexos_kernel::router::{ModelRouter, TaskKind};
use rexos_kernel::security::SecurityConfig;
use rexos_llm::registry::LlmRegistry;
use rexos_memory::MemoryStore;

use crate::records::{WorkflowRunToolArgs, WorkflowStepToolArgs};
use crate::AgentRuntime;

fn build_agent(memory: MemoryStore) -> AgentRuntime {
let mut providers = BTreeMap::new();
providers.insert(
"ollama".to_string(),
ProviderConfig {
kind: ProviderKind::OpenAiCompatible,
base_url: "http://127.0.0.1:11434/v1".to_string(),
api_key_env: "".to_string(),
default_model: "x".to_string(),
aws_bedrock: None,
},
);

let security = SecurityConfig::default();
let cfg = RexosConfig {
llm: LlmConfig::default(),
providers,
router: Default::default(),
security: security.clone(),
};
let llms = LlmRegistry::from_config(&cfg).unwrap();
let router = ModelRouter::new(rexos_kernel::config::RouterConfig {
planning: RouteConfig {
provider: "ollama".to_string(),
model: "x".to_string(),
},
coding: RouteConfig {
provider: "ollama".to_string(),
model: "x".to_string(),
},
summary: RouteConfig {
provider: "ollama".to_string(),
model: "x".to_string(),
},
});
AgentRuntime::new_with_security_config(memory, llms, router, security)
}

#[test]
fn session_policy_snapshot_round_trips_and_normalizes() {
let tmp = tempfile::tempdir().unwrap();
let paths = RexosPaths {
base_dir: tmp.path().join(".loopforge"),
};
paths.ensure_dirs().unwrap();

let memory = MemoryStore::open_or_create(&paths).unwrap();
let agent = build_agent(memory);

agent
.set_session_allowed_tools(
"s1",
vec![
" fs_read ".to_string(),
"".to_string(),
"fs_write".to_string(),
"fs_read".to_string(),
],
)
.unwrap();
agent
.set_session_allowed_skills(
"s1",
vec![
" safe-skill ".to_string(),
"safe-skill".to_string(),
"x".to_string(),
"".to_string(),
],
)
.unwrap();
agent
.set_session_skill_policy(
"s1",
crate::SessionSkillPolicy {
allowlist: vec!["shell-helper".to_string()],
require_approval: true,
auto_approve_readonly: false,
},
)
.unwrap();
agent
.set_session_mcp_config("s1", " {\"servers\":{}} ".to_string())
.unwrap();

let snapshot = agent.load_session_policy_snapshot("s1").unwrap();
assert_eq!(
snapshot.allowed_tools,
Some(vec!["fs_read".to_string(), "fs_write".to_string()])
);
assert_eq!(
snapshot.allowed_skills,
Some(vec!["safe-skill".to_string(), "x".to_string()])
);
assert_eq!(
snapshot.mcp_config_json,
Some("{\"servers\":{}}".to_string())
);
assert_eq!(
snapshot.skill_policy.allowlist,
vec!["shell-helper".to_string()]
);
assert!(snapshot.skill_policy.require_approval);
assert!(!snapshot.skill_policy.auto_approve_readonly);
}

#[test]
fn session_policy_snapshot_defaults_when_missing_or_blank() {
let tmp = tempfile::tempdir().unwrap();
let paths = RexosPaths {
base_dir: tmp.path().join(".loopforge"),
};
paths.ensure_dirs().unwrap();

let memory = MemoryStore::open_or_create(&paths).unwrap();
let agent = build_agent(memory);

agent
.set_session_mcp_config("s2", " ".to_string())
.unwrap();
let snapshot = agent.load_session_policy_snapshot("s2").unwrap();
assert!(snapshot.allowed_tools.is_none());
assert!(snapshot.allowed_skills.is_none());
assert!(snapshot.mcp_config_json.is_none());
assert!(!snapshot.skill_policy.auto_approve_readonly);
assert!(!snapshot.skill_policy.require_approval);
assert!(snapshot.skill_policy.allowlist.is_empty());
}

#[tokio::test]
async fn session_policy_workflow_uses_allowed_tools_snapshot() {
let tmp = tempfile::tempdir().unwrap();
let workspace_root = tmp.path().join("workspace");
std::fs::create_dir_all(&workspace_root).unwrap();

let paths = RexosPaths {
base_dir: tmp.path().join(".loopforge"),
};
paths.ensure_dirs().unwrap();
let memory = MemoryStore::open_or_create(&paths).unwrap();
let agent = build_agent(memory);

agent
.set_session_allowed_tools("s3", vec!["fs_read".to_string()])
.unwrap();

let res = agent
.workflow_run(
&workspace_root,
"s3",
TaskKind::Coding,
WorkflowRunToolArgs {
workflow_id: Some("wf-policy".to_string()),
name: None,
steps: vec![WorkflowStepToolArgs {
tool: "fs_write".to_string(),
arguments: serde_json::json!({
"path": "x.txt",
"content": "blocked",
}),
name: None,
approval_required: None,
}],
continue_on_error: None,
},
)
.await
.unwrap();

let res: serde_json::Value = serde_json::from_str(&res).unwrap();
let saved_to = res["saved_to"].as_str().unwrap();
let state_raw = std::fs::read_to_string(saved_to).unwrap();
let state: serde_json::Value = serde_json::from_str(&state_raw).unwrap();
let err = state["steps"][0]["error"].as_str().unwrap();
assert!(err.contains("workflow step 0 (fs_write)"), "got: {err}");
assert!(!workspace_root.join("x.txt").exists());
}
}
6 changes: 3 additions & 3 deletions crates/rexos-runtime/src/workflow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,13 @@ impl AgentRuntime {
let state_path = workflow_state_path(workspace_root, &workflow_id);
self.write_workflow_state(&state_path, &state)?;

let allowed_tools = self.load_session_allowed_tools(session_id)?;
let mcp_config = self.load_session_mcp_config(session_id)?;
let mut policy = self.load_session_policy_snapshot(session_id)?;
let allowed_tools = policy.allowed_tools.take();
let tools = Toolset::new_with_allowed_tools_security_and_mcp_config(
workspace_root.clone(),
allowed_tools,
self.security.clone(),
mcp_config.as_deref(),
policy.mcp_config_json.as_deref(),
)
.await?;
let continue_on_error = args.continue_on_error.unwrap_or(false);
Expand Down
Loading
Loading