Files
codex/codex-rs/core/src/guardian/mod.rs
T
Michael Bolin e7dda8070e Surface filesystem permission profiles in prompt context (#23924)
## Summary
Some permission profiles can encode filesystem reads that should remain
unavailable to the agent. Before this change, the model-visible context
and automatic approval review prompt summarized the effective
permissions as a legacy sandbox mode, which can omit permission-profile
filesystem entries from escalation decisions.

For example, a profile can grant workspace access while denying a
private subtree across every workspace root:

```toml
default_permissions = "restricted-workspace"

[permissions.restricted-workspace.workspace_roots]
"/Users/alice/project" = true
"/Users/alice/other-project" = true

[permissions.restricted-workspace.filesystem]
":minimal" = "read"

[permissions.restricted-workspace.filesystem.":workspace_roots"]
"." = "write"
"private" = "deny"
"private/**" = "deny"
```

The context window now describes the workspace roots and effective
filesystem side of the `PermissionProfile` directly, with deny entries
marked as non-escalatable:

```xml
<environment_context>
  <cwd>/Users/alice/project</cwd>
  <shell>zsh</shell>
  <filesystem><workspace_roots><root>/Users/alice/project</root><root>/Users/alice/other-project</root></workspace_roots><permission_profile type="managed"><file_system type="restricted"><entry access="read"><special>:minimal</special></entry><entry access="write"><path>/Users/alice/project</path></entry><entry access="write"><path>/Users/alice/other-project</path></entry><entry access="deny" escalatable="false"><path>/Users/alice/project/private</path></entry><entry access="deny" escalatable="false"><path>/Users/alice/other-project/private</path></entry><entry access="deny" escalatable="false"><glob>/Users/alice/project/private/**</glob></entry><entry access="deny" escalatable="false"><glob>/Users/alice/other-project/private/**</glob></entry></file_system></permission_profile></filesystem>
</environment_context>
```

Managed requirements can impose the same kind of deny-read restriction:

```toml
[permissions.filesystem]
deny_read = [
  "/Users/alice/project/private",
  "/Users/alice/project/private/**",
]
```

The automatic approval review prompt also receives the parent turn's
denied-read context, so review decisions can account for the active
permission profile.

## What Changed
- Render the effective filesystem profile in `<environment_context>`,
including profile type, filesystem entries, workspace roots, and
non-escalatable deny entries.
- Persist effective `workspace_roots` in `TurnContextItem` so
resumed/replayed context does not have to bind `:workspace_roots`
through legacy `cwd` fallback.
- Add explicit permission instructions that denied reads are policy
restrictions, not escalation targets.
- Pass the parent turn's denied-read context into automatic approval
reviews.
- Add targeted coverage for prompt rendering, workspace-root
materialization, replay context, and review prompt context.
- Keep the prompt-context test expectations platform-aware so the same
filesystem rendering assertions pass on Unix and Windows paths.

## Testing
- `just test -p codex-core
context::environment_context::tests::serialize_environment_context_with_full_filesystem_profile`
- `just test -p codex-core
context::environment_context::tests::turn_context_item_filesystem_uses_workspace_roots_instead_of_cwd`
- `just test -p codex-core
context::permissions_instructions::permissions_instructions_tests::builds_permissions_from_profile_with_denied_reads`
- `just fix -p codex-core`

I also attempted `just test -p codex-core`; the changed prompt-context
tests passed, but the full local run did not complete cleanly in this
sandboxed macOS environment due unrelated user-shell `CODEX_SANDBOX*`
expectations and integration-test timeouts.
2026-05-28 14:56:53 -07:00

176 lines
6.5 KiB
Rust

//! Guardian review decides whether an `on-request` approval should be granted
//! automatically instead of shown to the user.
//!
//! High-level approach:
//! 1. Reconstruct a compact transcript that preserves user intent plus the most
//! relevant recent assistant and tool context.
//! 2. Ask a dedicated guardian review session to assess the exact planned
//! action and return strict JSON.
//! The guardian clones the parent config, so it inherits any managed
//! network proxy / allowlist that the parent turn already had.
//! 3. Fail closed on timeout, execution failure, or malformed output.
//! 4. Apply the guardian's explicit allow/deny outcome.
mod approval_request;
mod metrics;
mod prompt;
mod review;
mod review_session;
use std::time::Duration;
use codex_protocol::protocol::GuardianAssessmentDecisionSource;
use codex_protocol::protocol::GuardianAssessmentOutcome;
use serde::Deserialize;
use serde::Serialize;
pub(crate) use approval_request::GuardianApprovalRequest;
pub(crate) use approval_request::GuardianMcpAnnotations;
pub(crate) use approval_request::GuardianNetworkAccessTrigger;
#[cfg(test)]
pub(crate) use approval_request::guardian_approval_request_to_json;
pub(crate) use review::guardian_rejection_message;
pub(crate) use review::guardian_timeout_message;
pub(crate) use review::is_guardian_reviewer_source;
pub(crate) use review::new_guardian_review_id;
#[cfg(test)]
pub(crate) use review::record_guardian_denial_for_test;
pub(crate) use review::review_approval_request;
#[cfg(test)]
pub(crate) use review::review_approval_request_with_cancel;
pub(crate) use review::routes_approval_to_guardian;
pub(crate) use review::spawn_approval_request_review;
pub(crate) use review_session::GuardianReviewSessionManager;
pub(crate) use review_session::prompt_cache_key_override_for_review_session;
pub(crate) const GUARDIAN_REVIEW_TIMEOUT: Duration = Duration::from_secs(90);
pub(crate) const GUARDIAN_REVIEWER_NAME: &str = "guardian";
pub(crate) const MAX_CONSECUTIVE_GUARDIAN_DENIALS_PER_TURN: u32 = 3;
pub(crate) const MAX_RECENT_AUTO_REVIEW_DENIALS_PER_TURN: u32 = 10;
pub(crate) const AUTO_REVIEW_DENIAL_WINDOW_SIZE: usize = 50;
pub(crate) const AUTO_REVIEW_DENIED_ACTION_APPROVAL_DEVELOPER_PREFIX: &str =
"The user has manually approved a specific action that was previously `Rejected`.";
const GUARDIAN_MAX_MESSAGE_TRANSCRIPT_TOKENS: usize = 10_000;
const GUARDIAN_MAX_TOOL_TRANSCRIPT_TOKENS: usize = 10_000;
const GUARDIAN_MAX_MESSAGE_ENTRY_TOKENS: usize = 2_000;
const GUARDIAN_MAX_TOOL_ENTRY_TOKENS: usize = 1_000;
const GUARDIAN_MAX_ACTION_STRING_TOKENS: usize = 16_000;
const GUARDIAN_RECENT_ENTRY_LIMIT: usize = 40;
const TRUNCATION_TAG: &str = "truncated";
/// Structured output contract that the guardian reviewer must satisfy.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub(crate) struct GuardianAssessment {
pub(crate) risk_level: codex_protocol::protocol::GuardianRiskLevel,
pub(crate) user_authorization: codex_protocol::protocol::GuardianUserAuthorization,
pub(crate) outcome: GuardianAssessmentOutcome,
pub(crate) rationale: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct GuardianRejection {
pub(crate) rationale: String,
pub(crate) source: GuardianAssessmentDecisionSource,
}
#[derive(Debug, Default)]
pub(crate) struct GuardianRejectionCircuitBreaker {
turns: std::collections::HashMap<String, GuardianRejectionCircuitBreakerTurn>,
}
#[derive(Debug, Default)]
struct GuardianRejectionCircuitBreakerTurn {
consecutive_denials: u32,
recent_denials: std::collections::VecDeque<bool>,
interrupt_triggered: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum GuardianRejectionCircuitBreakerAction {
Continue,
InterruptTurn {
consecutive_denials: u32,
recent_denials: u32,
},
}
impl GuardianRejectionCircuitBreaker {
pub(crate) fn clear_turn(&mut self, turn_id: &str) {
self.turns.remove(turn_id);
}
pub(crate) fn record_denial(&mut self, turn_id: &str) -> GuardianRejectionCircuitBreakerAction {
let turn = self.turns.entry(turn_id.to_string()).or_default();
turn.consecutive_denials = turn.consecutive_denials.saturating_add(1);
Self::record_recent_review(turn, /*denied*/ true);
let recent_denials = turn.recent_denials.iter().filter(|denied| **denied).count() as u32;
if !turn.interrupt_triggered
&& (turn.consecutive_denials >= MAX_CONSECUTIVE_GUARDIAN_DENIALS_PER_TURN
|| recent_denials >= MAX_RECENT_AUTO_REVIEW_DENIALS_PER_TURN)
{
turn.interrupt_triggered = true;
GuardianRejectionCircuitBreakerAction::InterruptTurn {
consecutive_denials: turn.consecutive_denials,
recent_denials,
}
} else {
GuardianRejectionCircuitBreakerAction::Continue
}
}
pub(crate) fn record_non_denial(&mut self, turn_id: &str) {
let turn = self.turns.entry(turn_id.to_string()).or_default();
turn.consecutive_denials = 0;
Self::record_recent_review(turn, /*denied*/ false);
}
fn record_recent_review(turn: &mut GuardianRejectionCircuitBreakerTurn, denied: bool) {
turn.recent_denials.push_back(denied);
if turn.recent_denials.len() > AUTO_REVIEW_DENIAL_WINDOW_SIZE {
turn.recent_denials.pop_front();
}
}
}
#[cfg(test)]
use approval_request::format_guardian_action_pretty;
#[cfg(test)]
use approval_request::guardian_assessment_action;
#[cfg(test)]
use approval_request::guardian_request_turn_id;
#[cfg(test)]
use prompt::GuardianPromptMode;
#[cfg(test)]
use prompt::GuardianTranscriptCursor;
#[cfg(test)]
use prompt::GuardianTranscriptEntry;
#[cfg(test)]
use prompt::GuardianTranscriptEntryKind;
#[cfg(test)]
use prompt::build_guardian_prompt_items;
#[cfg(test)]
use prompt::build_guardian_prompt_items_with_parent_turn;
#[cfg(test)]
use prompt::collect_guardian_transcript_entries;
#[cfg(test)]
use prompt::guardian_output_schema;
#[cfg(test)]
pub(crate) use prompt::guardian_policy_prompt;
#[cfg(test)]
pub(crate) use prompt::guardian_policy_prompt_with_config;
#[cfg(test)]
use prompt::guardian_truncate_text;
#[cfg(test)]
use prompt::parse_guardian_assessment;
#[cfg(test)]
use prompt::render_guardian_transcript_entries;
#[cfg(test)]
use review::GuardianReviewOutcome;
#[cfg(test)]
use review::run_guardian_review_session as run_guardian_review_session_for_test;
#[cfg(test)]
use review_session::build_guardian_review_session_config as build_guardian_review_session_config_for_test;
#[cfg(test)]
mod tests;