Skip to content

Commit 4e90c57

Browse files
committed
test(replay): cover tool approval + leak guard enforce
1 parent 966f748 commit 4e90c57

3 files changed

Lines changed: 198 additions & 1 deletion

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
[
2+
{
3+
"choices": [
4+
{
5+
"index": 0,
6+
"message": {
7+
"role": "assistant",
8+
"content": null,
9+
"tool_calls": [
10+
{
11+
"id": "call_1",
12+
"type": "function",
13+
"function": {
14+
"name": "fs_read",
15+
"arguments": "{\"path\":\"secret.txt\"}"
16+
}
17+
}
18+
]
19+
},
20+
"finish_reason": "tool_calls"
21+
}
22+
]
23+
}
24+
]
25+
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
[
2+
{
3+
"choices": [
4+
{
5+
"index": 0,
6+
"message": {
7+
"role": "assistant",
8+
"content": null,
9+
"tool_calls": [
10+
{
11+
"id": "call_1",
12+
"type": "function",
13+
"function": {
14+
"name": "shell",
15+
"arguments": "{\"command\":\"echo hi\"}"
16+
}
17+
}
18+
]
19+
},
20+
"finish_reason": "tool_calls"
21+
}
22+
]
23+
}
24+
]
25+

crates/rexos/tests/session_replay.rs

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,33 @@ use std::collections::BTreeMap;
33
use rexos::config::{ProviderConfig, ProviderKind, RexosConfig, RouteConfig, RouterConfig};
44
use rexos::paths::RexosPaths;
55
use rexos::router::TaskKind;
6-
use rexos::security::SecurityConfig;
6+
use rexos::security::{LeakMode, SecurityConfig};
77
use serde_json::{json, Value};
88

99
mod support;
1010

11+
struct EnvVarGuard {
12+
key: &'static str,
13+
prev: Option<String>,
14+
}
15+
16+
impl EnvVarGuard {
17+
fn set(key: &'static str, value: &str) -> Self {
18+
let prev = std::env::var(key).ok();
19+
std::env::set_var(key, value);
20+
Self { key, prev }
21+
}
22+
}
23+
24+
impl Drop for EnvVarGuard {
25+
fn drop(&mut self) {
26+
match self.prev.as_ref() {
27+
Some(value) => std::env::set_var(self.key, value),
28+
None => std::env::remove_var(self.key),
29+
}
30+
}
31+
}
32+
1133
fn fixture_agent(
1234
tmp: &tempfile::TempDir,
1335
fixture_base_url: String,
@@ -476,3 +498,128 @@ async fn replay_fixture_executes_mcp_tool_calls() {
476498

477499
server.abort();
478500
}
501+
502+
#[tokio::test]
503+
async fn replay_fixture_enforces_tool_approval_for_dangerous_tools() {
504+
let _mode = EnvVarGuard::set("LOOPFORGE_APPROVAL_MODE", "enforce");
505+
let _allow = EnvVarGuard::set("LOOPFORGE_APPROVAL_ALLOW", "");
506+
507+
let fixture = support::openai_compat_fixture::load_json_array(include_str!(
508+
"fixtures/replay/session_tool_approval_required.json"
509+
));
510+
let server = support::openai_compat_fixture::FixtureServer::spawn(fixture).await;
511+
512+
let tmp = tempfile::tempdir().unwrap();
513+
let (agent, _paths, workspace_root) = fixture_agent(
514+
&tmp,
515+
server.base_url.clone(),
516+
rexos::security::SecurityConfig::default(),
517+
);
518+
519+
let session_id = "s-replay-approval";
520+
agent
521+
.set_session_allowed_tools(session_id, vec!["shell".to_string()])
522+
.unwrap();
523+
524+
let err = agent
525+
.run_session(
526+
workspace_root,
527+
session_id,
528+
None,
529+
"run shell",
530+
TaskKind::Coding,
531+
)
532+
.await
533+
.unwrap_err();
534+
let err_text = err.to_string();
535+
assert!(
536+
err_text.contains("approval required for dangerous tool `shell`"),
537+
"expected approval error, got: {err_text}"
538+
);
539+
540+
let requests = server.requests.lock().unwrap().clone();
541+
assert_eq!(requests.len(), 1, "expected one chat completions call");
542+
assert_eq!(
543+
compact_request(&requests[0]),
544+
json!({
545+
"model": "fixture-model",
546+
"temperature": 0.0,
547+
"tools": [{
548+
"name": "shell",
549+
"type": "function",
550+
"param_type": "object",
551+
"required": ["command"],
552+
"properties": ["command", "timeout_ms"],
553+
"additional_properties": false,
554+
}],
555+
"message_roles": ["user"],
556+
"assistant_tool_calls": [],
557+
"tool_messages": [],
558+
})
559+
);
560+
561+
server.abort();
562+
}
563+
564+
#[tokio::test]
565+
async fn replay_fixture_blocks_leak_guard_in_enforce_mode() {
566+
let fixture = support::openai_compat_fixture::load_json_array(include_str!(
567+
"fixtures/replay/session_leak_guard_enforce.json"
568+
));
569+
let server = support::openai_compat_fixture::FixtureServer::spawn(fixture).await;
570+
571+
let tmp = tempfile::tempdir().unwrap();
572+
let mut security = rexos::security::SecurityConfig::default();
573+
security.leaks.mode = LeakMode::Enforce;
574+
575+
let (agent, _paths, workspace_root) = fixture_agent(&tmp, server.base_url.clone(), security);
576+
std::fs::write(
577+
workspace_root.join("secret.txt"),
578+
"secret=sk-01234567890123456789",
579+
)
580+
.unwrap();
581+
582+
let session_id = "s-replay-leak-guard";
583+
agent
584+
.set_session_allowed_tools(session_id, vec!["fs_read".to_string()])
585+
.unwrap();
586+
587+
let err = agent
588+
.run_session(
589+
workspace_root,
590+
session_id,
591+
None,
592+
"read secret",
593+
TaskKind::Coding,
594+
)
595+
.await
596+
.unwrap_err();
597+
let err_text = err.to_string();
598+
assert!(
599+
err_text.contains("tool output blocked by leak guard"),
600+
"expected leak guard error, got: {err_text}"
601+
);
602+
603+
let requests = server.requests.lock().unwrap().clone();
604+
assert_eq!(requests.len(), 1, "expected one chat completions call");
605+
assert_eq!(
606+
compact_request(&requests[0]),
607+
json!({
608+
"model": "fixture-model",
609+
"temperature": 0.0,
610+
"tools": [{
611+
"name": "fs_read",
612+
"type": "function",
613+
"param_type": "object",
614+
"required": ["path"],
615+
"properties": ["path"],
616+
"additional_properties": false,
617+
}],
618+
"message_roles": ["user"],
619+
"assistant_tool_calls": [],
620+
"tool_messages": [],
621+
})
622+
);
623+
624+
server.abort();
625+
}

0 commit comments

Comments
 (0)