mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
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:
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 {
|
||||
|
||||
Reference in new issue
Block a user