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

@@ -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 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)]
pub(crate) struct GuardianV2Config {
local_overrides: GuardianV2ConfigToml,
@@ -36,7 +42,7 @@ pub(crate) struct GuardianV2Config {
pub(crate) max_classifier_instruction_tokens: Option<usize>,
pub(crate) reuse_parent_compaction: bool,
pub(crate) max_parent_compaction_tokens: usize,
pub(crate) sandboxed_exec_commands: bool,
pub(crate) review_scope: GuardianV2ReviewScope,
pub(crate) transcript: TranscriptConfig,
}
@@ -235,11 +241,22 @@ impl GuardianV2Config {
max_classifier_instruction_tokens,
reuse_parent_compaction: configured.reuse_parent_compaction.unwrap_or(true),
max_parent_compaction_tokens,
sandboxed_exec_commands: configured
review_scope: if configured
.review_scope
.as_ref()
.and_then(|review_scope| review_scope.sandboxed_exec_commands)
.unwrap_or(false),
.and_then(|review_scope| review_scope.computer_use_only)
.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 {
sources: transcript_config
.and_then(|transcript| transcript.sources.clone())
@@ -9,26 +9,52 @@ use pretty_assertions::assert_eq;
use super::CLASSIFICATION_OUTPUT_INSTRUCTIONS;
use super::DEFAULT_CLASSIFIER_INSTRUCTIONS;
use super::GuardianV2Config;
use super::GuardianV2ReviewScope;
use crate::async_scorer::transcript::truncate_entry;
#[test]
fn sandboxed_exec_commands_are_excluded_by_default() {
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]
fn sandboxed_exec_commands_can_be_included() {
let config = GuardianV2Config::from_overrides(GuardianV2ConfigToml {
review_scope: Some(GuardianV2ReviewScopeConfigToml {
computer_use_only: None,
sandboxed_exec_commands: Some(true),
}),
..Default::default()
})
.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]
@@ -34,6 +34,8 @@ use codex_login::AgentIdentityAuthPolicy;
use codex_login::AuthManager;
use codex_model_provider::create_model_provider;
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::protocol::ReviewDecision;
use codex_protocol::protocol::TruncationPolicy;
@@ -41,6 +43,7 @@ use codex_protocol::security_risk::SecurityRiskScore;
use serde_json::json;
use super::config::GuardianV2Config;
use super::config::GuardianV2ReviewScope;
use super::review_evidence::render_review_evidence;
use super::sampler::LunaSampler;
use super::sampler::LunaSamplerConfig;
@@ -62,8 +65,14 @@ struct RenderedAction {
fn should_classify_tool(
tool_name: &ToolName,
payload: &ToolPayload,
sandboxed_exec_commands: bool,
review_scope: GuardianV2ReviewScope,
) -> 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
|| !tool_name.is_default_namespace()
|| tool_name.name != "exec_command"
@@ -367,12 +376,23 @@ impl ApprovalReviewContributor for GuardianV2Extension {
&'a self,
_session_store: &'a ExtensionData,
thread_store: &'a ExtensionData,
_prompt: &'a str,
prompt: &'a str,
extension_metrics: Option<Arc<dyn ExtensionMetrics>>,
) -> ExtensionFuture<'a, Option<ReviewDecision>> {
Box::pin(async move {
thread_store.get::<GuardianV2Enabled>()?;
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 latest_scored_tool_call = score_progress
.latest_scored_tool_call
@@ -450,14 +470,12 @@ impl GuardianV2Extension {
let Some(score_progress) = input.thread_store.get::<GuardianV2ScoreProgress>() else {
return;
};
if !should_classify_tool(
input.tool_name,
input.payload,
guardian_config.sandboxed_exec_commands,
) {
score_progress
.latest_tool_call
.fetch_add(/*val*/ 1, Ordering::Relaxed);
if !should_classify_tool(input.tool_name, input.payload, guardian_config.review_scope) {
if guardian_config.review_scope != GuardianV2ReviewScope::ComputerUseOnly {
score_progress
.latest_tool_call
.fetch_add(/*val*/ 1, Ordering::Relaxed);
}
return;
}
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::DEFAULT_MODEL_CONTEXT_ITEM_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::INITIAL_WEBSOCKET_CONNECTIONS;
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 standard_scope = GuardianV2ReviewScope::Standard {
sandboxed_exec_commands: false,
};
assert!(!should_classify_tool(
&tool_name, &sandboxed, /*sandboxed_exec_commands*/ false
&tool_name,
&sandboxed,
standard_scope,
));
assert!(!should_classify_tool(
&tool_name,
&additional_permissions,
/*sandboxed_exec_commands*/ false
standard_scope,
));
assert!(should_classify_tool(
&tool_name,
&unsandboxed,
/*sandboxed_exec_commands*/ false
standard_scope,
));
assert!(should_classify_tool(
&tool_name, &sandboxed, /*sandboxed_exec_commands*/ true
&tool_name,
&sandboxed,
GuardianV2ReviewScope::Standard {
sandboxed_exec_commands: true,
},
));
assert!(should_classify_tool(
&ToolName::plain("read_file"),
&sandboxed,
/*sandboxed_exec_commands*/ false
standard_scope,
));
assert!(should_classify_tool(
&ToolName::namespaced("mcp", "exec_command"),
&sandboxed,
/*sandboxed_exec_commands*/ false
standard_scope,
));
skip_if_no_network!(Ok(()));
@@ -363,6 +373,123 @@ async fn sandboxed_shell_classification_respects_review_scope() -> Result<()> {
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]
fn encrypted_parent_compaction_preserves_the_latest_valid_item() {
let older = ResponseItem::Compaction {