Add a computer-use-only Guardian v2 review scope (#40480)

## What changed

- Add `features.guardianv2.review_scope.computer_use_only` to restrict asynchronous classification and fast approvals to browser and computer-use REPL tools.
- Keep other tools on the synchronous approval path without letting them age or reuse scores from in-scope calls.
- Give this scope precedence over `sandboxed_exec_commands` when both options are enabled.

## Testing

- Cover configuration precedence, direct and code-mode tool detection, fast-approval isolation, and end-to-end approval routing for browser, computer-use, and unrelated MCP servers.

GitOrigin-RevId: bfd9ab6abdbd90634b265d354e038724b1e81d65
This commit is contained in:
jif authored and copyberry committed 2026-08-24 21:33:32 +00:00
1 parent d21794d6ba
commit 219c203417
8 files changed
+289 -29

No files matched your search

@@ -74,6 +74,7 @@ struct MockResponsesState {
luna_score: f64, luna_score: f64,
review_outcome: ReviewOutcome, review_outcome: ReviewOutcome,
transcript_content: TranscriptContent, transcript_content: TranscriptContent,
mcp_server_name: Option<&'static str>,
root_worker: bool, root_worker: bool,
root_user_restriction: bool, root_user_restriction: bool,
} }
@@ -106,6 +107,12 @@ enum ModelReviewRequirement {
Required, Required,
} }
#[derive(Clone, Copy)]
enum GuardianToolScope {
AllTools,
ComputerUseOnly { server_name: &'static str },
}
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
enum ThreadLifecycle { enum ThreadLifecycle {
New, New,
@@ -245,7 +252,7 @@ async fn parent_response(
responses::ev_response_created(&call_id), responses::ev_response_created(&call_id),
responses::ev_function_call_with_namespace( responses::ev_function_call_with_namespace(
&call_id, &call_id,
&format!("mcp__{TEST_SERVER_NAME}"), &format!("mcp__{}", state.mcp_server_name.unwrap_or(TEST_SERVER_NAME)),
TEST_TOOL_NAME, TEST_TOOL_NAME,
&arguments, &arguments,
), ),
@@ -323,8 +330,34 @@ async fn guardian_v2_routes_tool_approvals(
review_outcome: ReviewOutcome, review_outcome: ReviewOutcome,
transcript_content: TranscriptContent, transcript_content: TranscriptContent,
) -> Result<()> { ) -> Result<()> {
guardian_v2_routes_scoped_tool_approvals(
risk,
lifecycle,
requirement,
review_outcome,
transcript_content,
GuardianToolScope::AllTools,
)
.await
}
async fn guardian_v2_routes_scoped_tool_approvals(
risk: GuardianRisk,
lifecycle: ThreadLifecycle,
requirement: ModelReviewRequirement,
review_outcome: ReviewOutcome,
transcript_content: TranscriptContent,
scope: GuardianToolScope,
) -> Result<()> {
let server_name = match scope {
GuardianToolScope::AllTools => TEST_SERVER_NAME,
GuardianToolScope::ComputerUseOnly { server_name } => server_name,
};
let classifier_in_scope = !matches!(scope, GuardianToolScope::ComputerUseOnly { .. })
|| codex_protocol::mcp::is_node_repl_backed_server(server_name);
let (luna_score, expected_guardian_reviews) = match (requirement, risk) { let (luna_score, expected_guardian_reviews) = match (requirement, risk) {
(ModelReviewRequirement::Required, _) => (0.25, 2), (ModelReviewRequirement::Required, _) => (0.25, 2),
(ModelReviewRequirement::Optional, _) if !classifier_in_scope => (0.25, 2),
(ModelReviewRequirement::Optional, GuardianRisk::Low) => (0.25, 1), (ModelReviewRequirement::Optional, GuardianRisk::Low) => (0.25, 1),
(ModelReviewRequirement::Optional, GuardianRisk::Threshold) => (0.5, 2), (ModelReviewRequirement::Optional, GuardianRisk::Threshold) => (0.5, 2),
(ModelReviewRequirement::Optional, GuardianRisk::High) => (0.95, 2), (ModelReviewRequirement::Optional, GuardianRisk::High) => (0.95, 2),
@@ -339,6 +372,7 @@ async fn guardian_v2_routes_tool_approvals(
luna_score, luna_score,
review_outcome, review_outcome,
transcript_content, transcript_content,
mcp_server_name: Some(server_name),
root_worker: matches!( root_worker: matches!(
lifecycle, lifecycle,
ThreadLifecycle::RootRollback | ThreadLifecycle::RootRestriction ThreadLifecycle::RootRollback | ThreadLifecycle::RootRestriction
@@ -385,16 +419,23 @@ async fn guardian_v2_routes_tool_approvals(
("approvals_reviewer = \"user\"", ApprovalsReviewer::User) ("approvals_reviewer = \"user\"", ApprovalsReviewer::User)
} }
}; };
let guardian_scope_config = if matches!(scope, GuardianToolScope::ComputerUseOnly { .. }) {
"\n\n[features.guardianv2]\nenabled = true\n\n[features.guardianv2.review_scope]\ncomputer_use_only = true"
} else {
""
};
let mut mock_config = MockResponsesConfig::new(&responses_url) let mut mock_config = MockResponsesConfig::new(&responses_url)
.with_model(MODEL) .with_model(MODEL)
.with_provider_config("supports_websockets = false") .with_provider_config("supports_websockets = false")
.with_approval_policy("on-request") .with_approval_policy("on-request")
.with_root_config(reviewer_config) .with_root_config(reviewer_config)
.with_extra_config(&format!( .with_extra_config(&format!(
"[mcp_servers.{TEST_SERVER_NAME}]\nurl = \"{mcp_server_url}/mcp\"\ndefault_tools_approval_mode = \"prompt\"\n\n[analytics]\nenabled = true\n\n[otel]\nmetrics_exporter = {{ otlp-http = {{ endpoint = \"{responses_url}/metrics\", protocol = \"json\" }} }}" "[mcp_servers.{server_name}]\nurl = \"{mcp_server_url}/mcp\"\ndefault_tools_approval_mode = \"prompt\"\n\n[analytics]\nenabled = true\n\n[otel]\nmetrics_exporter = {{ otlp-http = {{ endpoint = \"{responses_url}/metrics\", protocol = \"json\" }} }}{guardian_scope_config}"
)) ))
.enable_feature(Feature::GuardianV2)
.enable_feature(Feature::GuardianApproval); .enable_feature(Feature::GuardianApproval);
if matches!(scope, GuardianToolScope::AllTools) {
mock_config = mock_config.enable_feature(Feature::GuardianV2);
}
if matches!( if matches!(
lifecycle, lifecycle,
ThreadLifecycle::RootRollback | ThreadLifecycle::RootRestriction ThreadLifecycle::RootRollback | ThreadLifecycle::RootRestriction
@@ -507,7 +548,7 @@ async fn guardian_v2_routes_tool_approvals(
assert_eq!(reviewed_thread_id, thread_id); assert_eq!(reviewed_thread_id, thread_id);
} }
if matches!(requirement, ModelReviewRequirement::Optional) { if matches!(requirement, ModelReviewRequirement::Optional) && classifier_in_scope {
let luna_request = wait_for_luna_request(responses_state.as_ref(), /*index*/ 0).await?; let luna_request = wait_for_luna_request(responses_state.as_ref(), /*index*/ 0).await?;
assert_eq!( assert_eq!(
luna_request["prompt_cache_key"], luna_request["prompt_cache_key"],
@@ -612,17 +653,18 @@ async fn guardian_v2_routes_tool_approvals(
responses_state.guardian_reviews.load(Ordering::SeqCst), responses_state.guardian_reviews.load(Ordering::SeqCst),
expected_guardian_reviews expected_guardian_reviews
); );
if matches!(requirement, ModelReviewRequirement::Required) { if matches!(requirement, ModelReviewRequirement::Required) || !classifier_in_scope {
assert!( assert!(
responses_state responses_state
.luna_requests .luna_requests
.lock() .lock()
.expect("Luna request lock should not be poisoned") .expect("Luna request lock should not be poisoned")
.is_empty(), .is_empty(),
"protected models must not receive Guardian v2 risk scoring" "out-of-scope tools and protected models must not receive Guardian v2 risk scoring"
); );
} }
let requires_strict_review = matches!(requirement, ModelReviewRequirement::Optional) let requires_strict_review = matches!(requirement, ModelReviewRequirement::Optional)
&& classifier_in_scope
&& matches!(risk, GuardianRisk::Threshold | GuardianRisk::High); && matches!(risk, GuardianRisk::Threshold | GuardianRisk::High);
let strict_review_count = app_server let strict_review_count = app_server
.pending_notification_methods() .pending_notification_methods()
@@ -651,7 +693,7 @@ async fn guardian_v2_routes_tool_approvals(
); );
} }
if matches!(requirement, ModelReviewRequirement::Optional) { if matches!(requirement, ModelReviewRequirement::Optional) && classifier_in_scope {
let state_db = StateRuntime::init( let state_db = StateRuntime::init(
codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()), codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()),
"mock_provider".to_owned(), "mock_provider".to_owned(),
@@ -775,6 +817,27 @@ async fn guardian_v2_low_risk_actions_skip_subsequent_reviews() -> Result<()> {
.await .await
} }
#[test_case("node_repl", GuardianRisk::Low; "low risk browser skips full review")]
#[test_case("cua_repl", GuardianRisk::Low; "low risk computer use skips full review")]
#[test_case("node_repl", GuardianRisk::High; "high risk browser receives full review")]
#[test_case(TEST_SERVER_NAME, GuardianRisk::Low; "other mcp keeps synchronous review")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_v2_computer_use_only_scopes_classification_and_fast_reviews(
server_name: &'static str,
risk: GuardianRisk,
) -> Result<()> {
skip_if_no_network!(Ok(()));
guardian_v2_routes_scoped_tool_approvals(
risk,
ThreadLifecycle::New,
ModelReviewRequirement::Optional,
ReviewOutcome::Allow,
TranscriptContent::Normal,
GuardianToolScope::ComputerUseOnly { server_name },
)
.await
}
#[test_case(ReviewOutcome::Allow, TranscriptContent::Normal; "approved_evidence")] #[test_case(ReviewOutcome::Allow, TranscriptContent::Normal; "approved_evidence")]
#[test_case(ReviewOutcome::Deny, TranscriptContent::Normal; "denied_evidence")] #[test_case(ReviewOutcome::Deny, TranscriptContent::Normal; "denied_evidence")]
#[test_case(ReviewOutcome::Malformed, TranscriptContent::Normal; "failed_review_without_evidence")] #[test_case(ReviewOutcome::Malformed, TranscriptContent::Normal; "failed_review_without_evidence")]
+4
View File
@@ -1447,6 +1447,10 @@
"additionalProperties": false, "additionalProperties": false,
"description": "Optional tool-call categories available to the Guardian v2 classifier.", "description": "Optional tool-call categories available to the Guardian v2 classifier.",
"properties": { "properties": {
"computer_use_only": {
"description": "Restrict asynchronous classification and fast approvals to browser and computer-use tools.",
"type": "boolean"
},
"sandboxed_exec_commands": { "sandboxed_exec_commands": {
"description": "Include sandboxed shell command calls in Guardian v2 classification.", "description": "Include sandboxed shell command calls in Guardian v2 classification.",
"type": "boolean" "type": "boolean"
@@ -24,6 +24,12 @@ const DEFAULT_MAX_TOOL_CALL_LAG: usize = 3;
pub(crate) const DEFAULT_CLASSIFIER_INSTRUCTIONS: &str = include_str!("classifier_instructions.md"); pub(crate) const DEFAULT_CLASSIFIER_INSTRUCTIONS: &str = include_str!("classifier_instructions.md");
pub(crate) const CLASSIFICATION_OUTPUT_INSTRUCTIONS: &str = "Your first output token is the entire classification: `high` for high risk or `low` for low risk. Output that token immediately and nothing else."; pub(crate) const CLASSIFICATION_OUTPUT_INSTRUCTIONS: &str = "Your first output token is the entire classification: `high` for high risk or `low` for low risk. Output that token immediately and nothing else.";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum GuardianV2ReviewScope {
Standard { sandboxed_exec_commands: bool },
ComputerUseOnly,
}
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub(crate) struct GuardianV2Config { pub(crate) struct GuardianV2Config {
local_overrides: GuardianV2ConfigToml, local_overrides: GuardianV2ConfigToml,
@@ -36,7 +42,7 @@ pub(crate) struct GuardianV2Config {
pub(crate) max_classifier_instruction_tokens: Option<usize>, pub(crate) max_classifier_instruction_tokens: Option<usize>,
pub(crate) reuse_parent_compaction: bool, pub(crate) reuse_parent_compaction: bool,
pub(crate) max_parent_compaction_tokens: usize, pub(crate) max_parent_compaction_tokens: usize,
pub(crate) sandboxed_exec_commands: bool, pub(crate) review_scope: GuardianV2ReviewScope,
pub(crate) transcript: TranscriptConfig, pub(crate) transcript: TranscriptConfig,
} }
@@ -235,11 +241,22 @@ impl GuardianV2Config {
max_classifier_instruction_tokens, max_classifier_instruction_tokens,
reuse_parent_compaction: configured.reuse_parent_compaction.unwrap_or(true), reuse_parent_compaction: configured.reuse_parent_compaction.unwrap_or(true),
max_parent_compaction_tokens, max_parent_compaction_tokens,
sandboxed_exec_commands: configured review_scope: if configured
.review_scope .review_scope
.as_ref() .as_ref()
.and_then(|review_scope| review_scope.sandboxed_exec_commands) .and_then(|review_scope| review_scope.computer_use_only)
.unwrap_or(false), .unwrap_or(false)
{
GuardianV2ReviewScope::ComputerUseOnly
} else {
GuardianV2ReviewScope::Standard {
sandboxed_exec_commands: configured
.review_scope
.as_ref()
.and_then(|review_scope| review_scope.sandboxed_exec_commands)
.unwrap_or(false),
}
},
transcript: TranscriptConfig { transcript: TranscriptConfig {
sources: transcript_config sources: transcript_config
.and_then(|transcript| transcript.sources.clone()) .and_then(|transcript| transcript.sources.clone())
@@ -9,26 +9,52 @@ use pretty_assertions::assert_eq;
use super::CLASSIFICATION_OUTPUT_INSTRUCTIONS; use super::CLASSIFICATION_OUTPUT_INSTRUCTIONS;
use super::DEFAULT_CLASSIFIER_INSTRUCTIONS; use super::DEFAULT_CLASSIFIER_INSTRUCTIONS;
use super::GuardianV2Config; use super::GuardianV2Config;
use super::GuardianV2ReviewScope;
use crate::async_scorer::transcript::truncate_entry; use crate::async_scorer::transcript::truncate_entry;
#[test] #[test]
fn sandboxed_exec_commands_are_excluded_by_default() { fn sandboxed_exec_commands_are_excluded_by_default() {
let config = GuardianV2Config::from_overrides(GuardianV2ConfigToml::default()).unwrap(); let config = GuardianV2Config::from_overrides(GuardianV2ConfigToml::default()).unwrap();
assert!(!config.sandboxed_exec_commands); assert_eq!(
config.review_scope,
GuardianV2ReviewScope::Standard {
sandboxed_exec_commands: false,
}
);
} }
#[test] #[test]
fn sandboxed_exec_commands_can_be_included() { fn sandboxed_exec_commands_can_be_included() {
let config = GuardianV2Config::from_overrides(GuardianV2ConfigToml { let config = GuardianV2Config::from_overrides(GuardianV2ConfigToml {
review_scope: Some(GuardianV2ReviewScopeConfigToml { review_scope: Some(GuardianV2ReviewScopeConfigToml {
computer_use_only: None,
sandboxed_exec_commands: Some(true), sandboxed_exec_commands: Some(true),
}), }),
..Default::default() ..Default::default()
}) })
.unwrap(); .unwrap();
assert!(config.sandboxed_exec_commands); assert_eq!(
config.review_scope,
GuardianV2ReviewScope::Standard {
sandboxed_exec_commands: true,
}
);
}
#[test]
fn computer_use_only_takes_precedence_over_sandboxed_exec_commands() {
let config = GuardianV2Config::from_overrides(GuardianV2ConfigToml {
review_scope: Some(GuardianV2ReviewScopeConfigToml {
computer_use_only: Some(true),
sandboxed_exec_commands: Some(true),
}),
..Default::default()
})
.unwrap();
assert_eq!(config.review_scope, GuardianV2ReviewScope::ComputerUseOnly);
} }
#[test] #[test]
@@ -34,6 +34,8 @@ use codex_login::AgentIdentityAuthPolicy;
use codex_login::AuthManager; use codex_login::AuthManager;
use codex_model_provider::create_model_provider; use codex_model_provider::create_model_provider;
use codex_protocol::ThreadId; use codex_protocol::ThreadId;
use codex_protocol::mcp::is_node_repl_backed_server;
use codex_protocol::mcp::is_node_repl_backed_tool;
use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::TruncationPolicy; use codex_protocol::protocol::TruncationPolicy;
@@ -41,6 +43,7 @@ use codex_protocol::security_risk::SecurityRiskScore;
use serde_json::json; use serde_json::json;
use super::config::GuardianV2Config; use super::config::GuardianV2Config;
use super::config::GuardianV2ReviewScope;
use super::review_evidence::render_review_evidence; use super::review_evidence::render_review_evidence;
use super::sampler::LunaSampler; use super::sampler::LunaSampler;
use super::sampler::LunaSamplerConfig; use super::sampler::LunaSamplerConfig;
@@ -62,8 +65,14 @@ struct RenderedAction {
fn should_classify_tool( fn should_classify_tool(
tool_name: &ToolName, tool_name: &ToolName,
payload: &ToolPayload, payload: &ToolPayload,
sandboxed_exec_commands: bool, review_scope: GuardianV2ReviewScope,
) -> bool { ) -> bool {
let GuardianV2ReviewScope::Standard {
sandboxed_exec_commands,
} = review_scope
else {
return is_node_repl_backed_tool(&tool_name.name, tool_name.namespace.as_deref());
};
if sandboxed_exec_commands if sandboxed_exec_commands
|| !tool_name.is_default_namespace() || !tool_name.is_default_namespace()
|| tool_name.name != "exec_command" || tool_name.name != "exec_command"
@@ -367,12 +376,23 @@ impl ApprovalReviewContributor for GuardianV2Extension {
&'a self, &'a self,
_session_store: &'a ExtensionData, _session_store: &'a ExtensionData,
thread_store: &'a ExtensionData, thread_store: &'a ExtensionData,
_prompt: &'a str, prompt: &'a str,
extension_metrics: Option<Arc<dyn ExtensionMetrics>>, extension_metrics: Option<Arc<dyn ExtensionMetrics>>,
) -> ExtensionFuture<'a, Option<ReviewDecision>> { ) -> ExtensionFuture<'a, Option<ReviewDecision>> {
Box::pin(async move { Box::pin(async move {
thread_store.get::<GuardianV2Enabled>()?; thread_store.get::<GuardianV2Enabled>()?;
let guardian_config = thread_store.get::<GuardianV2Config>()?; let guardian_config = thread_store.get::<GuardianV2Config>()?;
if guardian_config.review_scope == GuardianV2ReviewScope::ComputerUseOnly {
let action = serde_json::from_str::<serde_json::Value>(prompt).ok()?;
if action.get("tool").and_then(serde_json::Value::as_str) != Some("mcp_tool_call")
|| !action
.get("server")
.and_then(serde_json::Value::as_str)
.is_some_and(is_node_repl_backed_server)
{
return None;
}
}
let score_progress = thread_store.get::<GuardianV2ScoreProgress>()?; let score_progress = thread_store.get::<GuardianV2ScoreProgress>()?;
let latest_scored_tool_call = score_progress let latest_scored_tool_call = score_progress
.latest_scored_tool_call .latest_scored_tool_call
@@ -450,14 +470,12 @@ impl GuardianV2Extension {
let Some(score_progress) = input.thread_store.get::<GuardianV2ScoreProgress>() else { let Some(score_progress) = input.thread_store.get::<GuardianV2ScoreProgress>() else {
return; return;
}; };
if !should_classify_tool( if !should_classify_tool(input.tool_name, input.payload, guardian_config.review_scope) {
input.tool_name, if guardian_config.review_scope != GuardianV2ReviewScope::ComputerUseOnly {
input.payload, score_progress
guardian_config.sandboxed_exec_commands, .latest_tool_call
) { .fetch_add(/*val*/ 1, Ordering::Relaxed);
score_progress }
.latest_tool_call
.fetch_add(/*val*/ 1, Ordering::Relaxed);
return; return;
} }
let metrics = score_progress.metrics.clone(); let metrics = score_progress.metrics.clone();
@@ -64,6 +64,7 @@ use super::should_classify_tool;
use crate::async_scorer::config::CLASSIFICATION_OUTPUT_INSTRUCTIONS; use crate::async_scorer::config::CLASSIFICATION_OUTPUT_INSTRUCTIONS;
use crate::async_scorer::config::DEFAULT_MODEL_CONTEXT_ITEM_TOKENS; use crate::async_scorer::config::DEFAULT_MODEL_CONTEXT_ITEM_TOKENS;
use crate::async_scorer::config::DEFAULT_PARENT_COMPACTION_TOKENS; use crate::async_scorer::config::DEFAULT_PARENT_COMPACTION_TOKENS;
use crate::async_scorer::config::GuardianV2ReviewScope;
use crate::async_scorer::sampler::CLASSIFICATION_TOKEN_USAGE_METRIC; use crate::async_scorer::sampler::CLASSIFICATION_TOKEN_USAGE_METRIC;
use crate::async_scorer::sampler::INITIAL_WEBSOCKET_CONNECTIONS; use crate::async_scorer::sampler::INITIAL_WEBSOCKET_CONNECTIONS;
use crate::async_scorer::sampler::MODEL; use crate::async_scorer::sampler::MODEL;
@@ -294,31 +295,40 @@ async fn sandboxed_shell_classification_respects_review_scope() -> Result<()> {
}; };
let tool_name = ToolName::plain("exec_command"); let tool_name = ToolName::plain("exec_command");
let standard_scope = GuardianV2ReviewScope::Standard {
sandboxed_exec_commands: false,
};
assert!(!should_classify_tool( assert!(!should_classify_tool(
&tool_name, &sandboxed, /*sandboxed_exec_commands*/ false &tool_name,
&sandboxed,
standard_scope,
)); ));
assert!(!should_classify_tool( assert!(!should_classify_tool(
&tool_name, &tool_name,
&additional_permissions, &additional_permissions,
/*sandboxed_exec_commands*/ false standard_scope,
)); ));
assert!(should_classify_tool( assert!(should_classify_tool(
&tool_name, &tool_name,
&unsandboxed, &unsandboxed,
/*sandboxed_exec_commands*/ false standard_scope,
)); ));
assert!(should_classify_tool( assert!(should_classify_tool(
&tool_name, &sandboxed, /*sandboxed_exec_commands*/ true &tool_name,
&sandboxed,
GuardianV2ReviewScope::Standard {
sandboxed_exec_commands: true,
},
)); ));
assert!(should_classify_tool( assert!(should_classify_tool(
&ToolName::plain("read_file"), &ToolName::plain("read_file"),
&sandboxed, &sandboxed,
/*sandboxed_exec_commands*/ false standard_scope,
)); ));
assert!(should_classify_tool( assert!(should_classify_tool(
&ToolName::namespaced("mcp", "exec_command"), &ToolName::namespaced("mcp", "exec_command"),
&sandboxed, &sandboxed,
/*sandboxed_exec_commands*/ false standard_scope,
)); ));
skip_if_no_network!(Ok(())); skip_if_no_network!(Ok(()));
@@ -363,6 +373,123 @@ async fn sandboxed_shell_classification_respects_review_scope() -> Result<()> {
Ok(()) Ok(())
} }
#[test]
fn computer_use_only_classification_recognizes_direct_and_code_mode_tools() {
let payload = ToolPayload::Function {
arguments: r#"{"code":"await browser.goto('https://example.com')"}"#.to_owned(),
};
for (tool_name, expected) in [
(ToolName::namespaced("mcp__node_repl__", "js"), true),
(ToolName::namespaced("mcp__cua_repl__", "js"), true),
(ToolName::plain("mcp__node_repl__js"), true),
(ToolName::plain("mcp__cua_repl__js"), true),
(ToolName::namespaced("mcp__ordinary__", "js"), false),
(ToolName::plain("read_file"), false),
(ToolName::plain("exec_command"), false),
] {
assert_eq!(
should_classify_tool(&tool_name, &payload, GuardianV2ReviewScope::ComputerUseOnly),
expected,
"unexpected classification scope for {tool_name}"
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn computer_use_only_scores_cannot_approve_other_actions() -> Result<()> {
skip_if_no_network!(Ok(()));
let fixture = GuardianFailureFixture::new().await?;
let thread_store = fixture.test.codex.thread_extension_data();
let mut config = thread_store
.get::<crate::async_scorer::config::GuardianV2Config>()
.expect("Guardian v2 should have initialized")
.as_ref()
.clone();
config.review_scope = GuardianV2ReviewScope::ComputerUseOnly;
thread_store.insert(config);
thread_store.insert(SecurityRiskScore {
scores: BTreeMap::from([("action_risk".to_owned(), 0.25)]),
sampled_at: None,
});
let progress = thread_store
.get::<GuardianV2ScoreProgress>()
.expect("Guardian v2 should track score progress per thread");
let latest_tool_call = progress.latest_tool_call.load(Ordering::Acquire);
let turn_store = ExtensionData::new("turn-1");
let ordinary_tool = ToolName::namespaced("mcp__ordinary__", "write_record");
let payload = ToolPayload::Function {
arguments: r#"{"record":"sensitive"}"#.to_owned(),
};
fixture.registry.tool_lifecycle_contributors()[0]
.on_tool_start(ToolStartInput {
session_store: &fixture.session_store,
thread_store,
turn_store: &turn_store,
turn_id: "turn-1",
call_id: "ordinary-call",
tool_name: &ordinary_tool,
payload: &payload,
conversation_history: Arc::new(TestConversationHistory(Vec::new())),
source: ToolCallSource::CodeMode {
cell_id: "cell-1".to_owned(),
runtime_tool_call_id: "nested-1".to_owned(),
},
})
.await;
assert_eq!(
progress.latest_tool_call.load(Ordering::Acquire),
latest_tool_call,
"unrelated code-mode calls must not age browser/CUA scores"
);
for (action, expected) in [
(
json!({"tool": "mcp_tool_call", "server": "node_repl", "tool_name": "js"}),
Some(ReviewDecision::Approved),
),
(
json!({"tool": "mcp_tool_call", "server": "cua_repl", "tool_name": "js"}),
Some(ReviewDecision::Approved),
),
(
json!({"tool": "mcp_tool_call", "server": "ordinary", "tool_name": "js"}),
None,
),
(json!({"tool": "exec_command", "server": "node_repl"}), None),
] {
let prompt = action.to_string();
assert_eq!(
fixture
.registry
.fast_approval_decision(
&fixture.session_store,
thread_store,
&prompt,
/*extension_metrics*/ None,
)
.await,
expected,
"unexpected fast approval for {action}"
);
}
assert_eq!(
fixture
.registry
.fast_approval_decision(
&fixture.session_store,
thread_store,
"not valid JSON",
/*extension_metrics*/ None,
)
.await,
None,
"malformed approval actions must not reuse a browser score"
);
Ok(())
}
#[test] #[test]
fn encrypted_parent_compaction_preserves_the_latest_valid_item() { fn encrypted_parent_compaction_preserves_the_latest_valid_item() {
let older = ResponseItem::Compaction { let older = ResponseItem::Compaction {
+3
View File
@@ -112,6 +112,9 @@ pub struct GuardianV2TranscriptConfigToml {
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct GuardianV2ReviewScopeConfigToml { pub struct GuardianV2ReviewScopeConfigToml {
/// Restrict asynchronous classification and fast approvals to browser and computer-use tools.
#[serde(skip_serializing_if = "Option::is_none")]
pub computer_use_only: Option<bool>,
/// Include sandboxed shell command calls in Guardian v2 classification. /// Include sandboxed shell command calls in Guardian v2 classification.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub sandboxed_exec_commands: Option<bool>, pub sandboxed_exec_commands: Option<bool>,
+2
View File
@@ -164,6 +164,7 @@ reuse_parent_compaction = false
max_parent_compaction_tokens = 384 max_parent_compaction_tokens = 384
[guardianv2.review_scope] [guardianv2.review_scope]
computer_use_only = true
sandboxed_exec_commands = true sandboxed_exec_commands = true
[guardianv2.transcript] [guardianv2.transcript]
@@ -191,6 +192,7 @@ max_recent_non_user_entries = 12
reuse_parent_compaction: Some(false), reuse_parent_compaction: Some(false),
max_parent_compaction_tokens: Some(384), max_parent_compaction_tokens: Some(384),
review_scope: Some(crate::GuardianV2ReviewScopeConfigToml { review_scope: Some(crate::GuardianV2ReviewScopeConfigToml {
computer_use_only: Some(true),
sandboxed_exec_commands: Some(true), sandboxed_exec_commands: Some(true),
}), }),
transcript: Some(crate::GuardianV2TranscriptConfigToml { transcript: Some(crate::GuardianV2TranscriptConfigToml {