mirror of
https://github.com/openai/codex.git
synced 2026-11-05 16:29:31 +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
@@ -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")]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user