Approve the first Node REPL execution without a Guardian wait (#41666)

## Why

The first REPL execution should proceed while its initial asynchronous Guardian
classification is still pending.

## What changed

- Fast-approve the first `js` execution from a Node REPL-backed server while
  continuing its asynchronous classification.
- Track `js` executions separately so setup and reset tools do not consume the
  first-execution allowance.
- Apply the normal Guardian review policy to subsequent executions.

## Testing

Add coverage for browser and computer-use startup, reset, and module-directory
setup sequences, verifying that only the first `js` execution skips the wait.

GitOrigin-RevId: 7297b35411a6317bcf9e7058c08c6db3e3310ac8
This commit is contained in:
jif
2026-08-30 13:02:11 +00:00
committed by copyberry
parent 0a12b855a0
commit cefa060695
3 changed files with 174 additions and 13 deletions
@@ -113,6 +113,8 @@ enum ClassificationOutcome {
#[derive(Default)]
struct GuardianV2ScoreProgress {
latest_tool_call: AtomicUsize,
// Setup and reset calls must not consume the first JS execution allowance.
js_executions: AtomicUsize,
latest_scored_tool_call: AtomicUsize,
latest_failed_tool_call: AtomicUsize,
// Serialize successful score publication with its authorization metadata.
@@ -261,6 +263,26 @@ impl ApprovalReviewContributor for GuardianV2Extension {
record_fast_decision(extension_metrics.as_deref(), "deferred", "out_of_scope");
return None;
}
// The first REPL execution never waits for synchronous Guardian review.
// The async classifier still runs, and later calls use the normal policy.
if action.get("tool_name").and_then(serde_json::Value::as_str) == Some("js")
&& action
.get("connector_id")
.and_then(serde_json::Value::as_str)
== Some("node_repl")
&& thread_store
.get::<GuardianV2ScoreProgress>()?
.js_executions
.load(Ordering::Acquire)
== 1
{
record_fast_decision(
extension_metrics.as_deref(),
"approved",
"initial_cua_call",
);
return Some(ReviewDecision::Approved);
}
} else if thread_store.get::<ModelInfo>().is_some() {
let manager = self.thread_manager.upgrade()?;
let thread_id = ThreadId::from_string(thread_store.level_id()).ok()?;
@@ -400,6 +422,14 @@ impl GuardianV2Extension {
}
return;
}
if input.mcp_tool.is_some_and(|tool| {
let info = tool.tool_info();
is_node_repl_backed_server(&info.server_name) && info.tool.name == "js"
}) {
score_progress
.js_executions
.fetch_add(/*val*/ 1, Ordering::Relaxed);
}
let metrics = score_progress.metrics.clone();
let sampled_at = SystemTime::now();
let tool_call_index = score_progress