Track permission instructions in world state (#33944)

## What changed

- Model permission instructions as a world-state section keyed by a stable hash of their rendered developer message.
- Re-emit permission context when its model-visible contents change or the retained fragment is missing, while avoiding duplicates when matching instructions already exist in history, including bundled developer messages.
- Normalize CRLF line endings when hashing world-state fragments so equivalent content produces the same snapshot.

## Testing

- Add snapshot coverage for unchanged, changed, absent, and unknown permission state.
- Cover bundled retained permissions and restored world-state baselines during rollout reconstruction.

GitOrigin-RevId: 7d8d212460c1690ca617859f66dcd825987f1e23
This commit is contained in:
pakrym-oai authored and copyberry committed 2026-07-18 02:45:55 +00:00
1 parent 6bd3f5e3db
commit 82b294c73c
22 files changed
+396 -189

No files matched your search

@@ -3,6 +3,7 @@ mod apps_instructions;
mod collaboration_mode;
mod environment;
mod environments_instructions;
mod permissions;
mod plugins_instructions;
mod realtime;
#[cfg(test)]
@@ -19,6 +20,8 @@ use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Map;
use serde_json::Value;
use sha1::Digest;
use sha1::Sha1;
use std::collections::BTreeMap;
use std::fmt;
@@ -27,6 +30,7 @@ pub(crate) use apps_instructions::AppsInstructionsState;
pub(crate) use collaboration_mode::CollaborationModeState;
pub(crate) use environment::EnvironmentsState;
pub(crate) use environments_instructions::EnvironmentsInstructionsState;
pub(crate) use permissions::PermissionsState;
pub(crate) use plugins_instructions::PluginsInstructionsState;
pub(crate) use realtime::RealtimeState;
@@ -211,6 +215,27 @@ pub(crate) trait WorldStateSection: Send + Sync + 'static {
) -> Option<Box<dyn ContextualUserFragment>>;
}
/// Stable fingerprint of a model-visible World State fragment.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
#[serde(transparent)]
pub(crate) struct WorldStateHash(String);
impl WorldStateHash {
pub(crate) fn from_fragment(fragment: &(impl ContextualUserFragment + ?Sized)) -> Self {
let mut hasher = Sha1::new();
hasher.update(b"codex-world-state-fragment-v1\0");
hash_component(&mut hasher, fragment.role());
hash_component(&mut hasher, &fragment.render());
Self(format!("{:x}", hasher.finalize()))
}
}
fn hash_component(hasher: &mut Sha1, value: &str) {
let value = value.replace("\r\n", "\n");
hasher.update((value.len() as u64).to_be_bytes());
hasher.update(value.as_bytes());
}
/// Live model-visible state, keyed by the same stable section IDs used in rollouts.
#[derive(Default)]
pub(crate) struct WorldState {
@@ -0,0 +1,80 @@
use super::PreviousSectionState;
use super::WorldStateHash;
use super::WorldStateSection;
use crate::context::ApprovalPromptContext;
use crate::context::ContextualUserFragment;
use crate::context::PermissionsInstructions;
use codex_execpolicy::Policy;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use std::path::Path;
/// Permission instructions currently visible to the model.
#[derive(Clone, Debug)]
pub(crate) struct PermissionsState {
snapshot: WorldStateHash,
instructions: PermissionsInstructions,
}
impl PermissionsState {
pub(crate) fn new(
permission_profile: &PermissionProfile,
approval_policy: AskForApproval,
approval_context: ApprovalPromptContext<'_>,
exec_policy: &Policy,
cwd: &Path,
exec_permission_approvals_enabled: bool,
request_permissions_tool_enabled: bool,
) -> Self {
let instructions = PermissionsInstructions::from_permission_profile(
permission_profile,
approval_policy,
approval_context,
exec_policy,
cwd,
exec_permission_approvals_enabled,
request_permissions_tool_enabled,
);
let snapshot = WorldStateHash::from_fragment(&instructions);
Self {
snapshot,
instructions,
}
}
}
impl WorldStateSection for PermissionsState {
const ID: &'static str = "permissions";
type Snapshot = WorldStateHash;
fn snapshot(&self) -> Self::Snapshot {
self.snapshot.clone()
}
fn matches_legacy_fragment(role: &str, text: &str) -> bool {
role == "developer" && PermissionsInstructions::matches_text(text)
}
fn has_retained_fragment_matcher() -> bool {
true
}
fn matches_retained_fragment(role: &str, text: &str) -> bool {
Self::matches_legacy_fragment(role, text)
}
fn render_diff(
&self,
previous: PreviousSectionState<'_, Self::Snapshot>,
) -> Option<Box<dyn ContextualUserFragment>> {
if matches!(previous, PreviousSectionState::Known(previous) if previous == &self.snapshot) {
return None;
}
Some(Box::new(self.instructions.clone()))
}
}
#[cfg(test)]
#[path = "permissions_tests.rs"]
mod tests;
@@ -0,0 +1,94 @@
use super::*;
use crate::context::world_state::test_support::render_section_cases;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::models::ContentItem;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ApprovalMessages;
use codex_protocol::openai_models::PermissionMessages;
use codex_protocol::protocol::AskForApproval;
use pretty_assertions::assert_eq;
use std::path::Path;
#[test]
fn snapshots() {
use PreviousSectionState::Absent;
use PreviousSectionState::Known;
use PreviousSectionState::Unknown;
let read_only = permissions_state(PermissionProfile::read_only(), AskForApproval::OnRequest);
let full_access = permissions_state(PermissionProfile::Disabled, AskForApproval::OnRequest);
let never_ask = permissions_state(PermissionProfile::read_only(), AskForApproval::Never);
insta::assert_snapshot!(render_section_cases(&[
(Absent, Absent),
(Absent, Known(&read_only)),
(Known(&read_only), Known(&read_only)),
(Known(&read_only), Known(&full_access)),
(Known(&read_only), Known(&never_ask)),
(Unknown, Known(&read_only)),
]));
}
#[test]
fn persisted_permissions_are_detected_inside_bundled_developer_messages() {
let state = permissions_state(PermissionProfile::read_only(), AskForApproval::OnRequest);
let retained = ContextualUserFragment::into(state.instructions.clone());
let mut world_state = super::super::WorldState::default();
world_state.add_section(state);
let snapshot = world_state.snapshot();
let mut bundled_retained = retained.clone();
let ResponseItem::Message { content, .. } = &mut bundled_retained else {
panic!("permissions should render as a message");
};
content.insert(
0,
ContentItem::InputText {
text: "Other developer instructions.".to_string(),
},
);
assert_eq!(
world_state
.render_history_diff(/*previous*/ None, std::slice::from_ref(&retained))
.len(),
1,
);
assert_eq!(
world_state.render_history_diff(Some(&snapshot), &[]).len(),
1,
);
assert!(
world_state
.render_history_diff(Some(&snapshot), &[bundled_retained])
.is_empty()
);
}
fn permissions_state(
permission_profile: PermissionProfile,
approval_policy: AskForApproval,
) -> PermissionsState {
let approval_messages = ApprovalMessages {
on_request: Some("Ask for approval.".to_string()),
on_request_auto_review: None,
};
let permission_messages = PermissionMessages {
danger_full_access: Some("Full access.".to_string()),
workspace_write: Some("Workspace write.".to_string()),
read_only: Some("Read only.".to_string()),
};
PermissionsState::new(
&permission_profile,
approval_policy,
ApprovalPromptContext::new(
ApprovalsReviewer::User,
Some(&approval_messages),
Some(&permission_messages),
),
&Policy::empty(),
Path::new("/workspace"),
/*exec_permission_approvals_enabled*/ false,
/*request_permissions_tool_enabled*/ false,
)
}
@@ -0,0 +1,33 @@
---
source: core/src/context/world_state/permissions_tests.rs
expression: "render_section_cases(&[(Absent, Absent), (Absent, Known(&read_only)),\n(Known(&read_only), Known(&read_only)),\n(Known(&read_only), Known(&full_access)),\n(Known(&read_only), Known(&never_ask)), (Unknown, Known(&read_only)),])"
---
Absent -> Absent
None
Absent -> "0ccde536dd8b4cfebb1df573f679dfe86b5718e3" (role - developer)
<permissions instructions>
Read only.
Ask for approval.
</permissions instructions>
"0ccde536dd8b4cfebb1df573f679dfe86b5718e3" -> "0ccde536dd8b4cfebb1df573f679dfe86b5718e3"
None
"0ccde536dd8b4cfebb1df573f679dfe86b5718e3" -> "7e17d9a0df61e5f0c032ed7f0bf1dda58c9d5e85" (role - developer)
<permissions instructions>
Full access.
Ask for approval.
</permissions instructions>
"0ccde536dd8b4cfebb1df573f679dfe86b5718e3" -> "d96f228104cb899a8d3788b08e1d64b3bbb6ac84" (role - developer)
<permissions instructions>
Read only.
Approval policy is currently never. Do not provide the `sandbox_permissions` for any reason, commands will be rejected.
</permissions instructions>
Unknown -> "0ccde536dd8b4cfebb1df573f679dfe86b5718e3" (role - developer)
<permissions instructions>
Read only.
Ask for approval.
</permissions instructions>
@@ -53,6 +53,14 @@ impl ContextualUserFragment for TestFragment {
}
}
#[test]
fn world_state_hash_normalizes_crlf_line_endings() {
assert_eq!(
WorldStateHash::from_fragment(&TestFragment("line one\r\nline two".to_string())),
WorldStateHash::from_fragment(&TestFragment("line one\nline two".to_string())),
);
}
struct DuplicateTestSection;
impl WorldStateSection for DuplicateTestSection {
@@ -1,13 +1,9 @@
use crate::context::ApprovalPromptContext;
use crate::context::ContextualUserFragment;
use crate::context::ModelSwitchInstructions;
use crate::context::MultiAgentModeInstructions;
use crate::context::PermissionsInstructions;
use crate::context::PersonalitySpecInstructions;
use crate::session::PreviousTurnSettings;
use crate::session::turn_context::TurnContext;
use codex_execpolicy::Policy;
use codex_features::Feature;
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::config_types::Personality;
use codex_protocol::models::ContentItem;
@@ -15,52 +11,6 @@ use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::TurnContextItem;
fn build_permissions_update_item(
previous: Option<&TurnContextItem>,
next: &TurnContext,
exec_policy: &Policy,
) -> Option<String> {
if !next.config.include_permissions_instructions {
return None;
}
let prev = previous?;
if prev.permission_profile() == next.permission_profile()
&& prev.approval_policy == next.approval_policy.value()
&& prev.model == next.model_info.slug
{
return None;
}
Some(
PermissionsInstructions::from_permission_profile(
&next.permission_profile,
next.approval_policy.value(),
ApprovalPromptContext::new(
next.config.approvals_reviewer,
next.model_info
.model_messages
.as_ref()
.and_then(|messages| messages.approvals.as_ref()),
next.model_info
.model_messages
.as_ref()
.and_then(|messages| messages.permissions.as_ref()),
),
exec_policy,
#[allow(deprecated)]
&next.cwd,
next.config
.features
.enabled(Feature::ExecPermissionApprovals),
next.config
.features
.enabled(Feature::RequestPermissionsTool),
)
.render(),
)
}
fn build_multi_agent_mode_update_item(
previous: Option<&TurnContextItem>,
next: &TurnContext,
@@ -185,7 +135,6 @@ pub(crate) fn build_settings_update_items(
previous: Option<&TurnContextItem>,
previous_turn_settings: Option<&PreviousTurnSettings>,
next: &TurnContext,
exec_policy: &Policy,
personality_feature_enabled: bool,
) -> Vec<ResponseItem> {
// TODO(ccunningham): build_settings_update_items still does not cover every
@@ -196,7 +145,6 @@ pub(crate) fn build_settings_update_items(
// Keep model-switch instructions first so model-specific guidance is read before
// any other context diffs on this turn.
build_model_instructions_update_item(previous_turn_settings, next),
build_permissions_update_item(previous, next, exec_policy),
build_multi_agent_mode_update_item(previous, next),
build_personality_update_item(previous, next, personality_feature_enabled),
]
+1 -38
View File
@@ -22,13 +22,11 @@ use crate::build_available_skills;
use crate::compact;
use crate::config::ManagedFeatures;
use crate::config::resolve_tool_suggest_config_from_layer_stack;
use crate::context::ApprovalPromptContext;
use crate::context::ApprovedCommandPrefixSaved;
use crate::context::AvailableSkillsInstructions;
use crate::context::ContextualUserFragment;
use crate::context::MultiAgentModeInstructions;
use crate::context::NetworkRuleSaved;
use crate::context::PermissionsInstructions;
use crate::context::PersonalitySpecInstructions;
use crate::context::RecommendedPluginsInstructions;
use crate::context::world_state::WorldState;
@@ -1702,18 +1700,16 @@ impl Session {
) -> Vec<ResponseItem> {
// TODO: Make context updates a pure diff of persisted previous/current TurnContextItem
// state so replay/backtracking is deterministic. Runtime inputs that affect model-visible
// context (exec policy, feature gates, previous-turn bridge) should be persisted
// context (feature gates and the previous-turn bridge) should be persisted
// state or explicit non-state replay events.
let previous_turn_settings = {
let state = self.state.lock().await;
state.previous_turn_settings()
};
let exec_policy = self.services.exec_policy.current();
crate::context_manager::updates::build_settings_update_items(
reference_context_item,
previous_turn_settings.as_ref(),
current_context,
exec_policy.as_ref(),
self.features.enabled(Feature::Personality),
)
}
@@ -3213,39 +3209,6 @@ impl Session {
{
developer_sections.push(model_switch_message);
}
if turn_context.config.include_permissions_instructions {
developer_sections.push(
PermissionsInstructions::from_permission_profile(
&turn_context.permission_profile,
turn_context.approval_policy.value(),
ApprovalPromptContext::new(
turn_context.config.approvals_reviewer,
turn_context
.model_info
.model_messages
.as_ref()
.and_then(|messages| messages.approvals.as_ref()),
turn_context
.model_info
.model_messages
.as_ref()
.and_then(|messages| messages.permissions.as_ref()),
),
self.services.exec_policy.current().as_ref(),
#[allow(deprecated)]
&turn_context.cwd,
turn_context
.config
.features
.enabled(Feature::ExecPermissionApprovals),
turn_context
.config
.features
.enabled(Feature::RequestPermissionsTool),
)
.render(),
);
}
let separate_guardian_developer_message =
crate::guardian::is_guardian_reviewer_source(&session_source);
// Keep the guardian policy prompt out of the aggregated developer bundle so it
@@ -139,12 +139,21 @@ async fn record_initial_history_restores_world_state_baseline() {
let (session, turn_context) = make_session_and_context().await;
let turn_context = Arc::new(turn_context);
let world_state = build_world_state_from_turn_context(&session, &turn_context).await;
let rollout_items = completed_user_turn_rollout(
turn_context.to_turn_context_item(),
vec![RolloutItem::WorldState(WorldStateItem::full(
world_state.snapshot().into_value(),
))],
);
let expected_history = world_state
.render_full()
.into_iter()
.map(ContextualUserFragment::into_boxed_response_item)
.collect::<Vec<_>>();
let mut world_state_items = expected_history
.iter()
.cloned()
.map(RolloutItem::ResponseItem)
.collect::<Vec<_>>();
world_state_items.push(RolloutItem::WorldState(WorldStateItem::full(
world_state.snapshot().into_value(),
)));
let rollout_items =
completed_user_turn_rollout(turn_context.to_turn_context_item(), world_state_items);
session
.record_initial_history(InitialHistory::Resumed(ResumedHistory {
@@ -158,7 +167,10 @@ async fn record_initial_history_restores_world_state_baseline() {
.record_context_updates_and_set_reference_context_item(&step_context)
.await;
assert_eq!(session.clone_history().await.raw_items(), &[]);
assert_eq!(
session.clone_history().await.raw_items(),
expected_history.as_slice(),
);
}
#[tokio::test]
@@ -8,6 +8,7 @@ Scenario: First request after fork when startup preserves the parent baseline, t
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
02:message/user:fork seed
03:message/developer:<PERMISSIONS_INSTRUCTIONS>
04:message/developer:<collaboration_mode>Fork turn collaboration instructions.</collaboration_mode>
05:message/user:after fork
03:message/developer[2]:
[01] <PERMISSIONS_INSTRUCTIONS>
[02] <collaboration_mode>Fork turn collaboration instructions.</collaboration_mode>
04:message/user:after fork
+12 -2
View File
@@ -8913,12 +8913,22 @@ async fn record_context_updates_and_set_reference_context_item_persists_baseline
let previous_context_item = turn_context.to_turn_context_item();
let previous_context = Arc::new(turn_context);
let world_state = build_world_state_from_turn_context(&session, &previous_context).await;
let retained_world_state = world_state
.render_full()
.into_iter()
.map(ContextualUserFragment::into_boxed_response_item)
.collect::<Vec<_>>();
session
.replace_history(
retained_world_state.clone(),
Some(previous_context_item.clone()),
)
.await;
let mut turn_context = Arc::try_unwrap(previous_context)
.unwrap_or_else(|_| panic!("previous turn context should have no remaining references"));
turn_context.sub_id = format!("{}-next", turn_context.sub_id);
{
let mut state = session.state.lock().await;
state.set_reference_context_item(Some(previous_context_item.clone()));
state
.history
.set_world_state_baseline(world_state.snapshot());
@@ -8938,7 +8948,7 @@ async fn record_context_updates_and_set_reference_context_item_persists_baseline
assert_eq!(
session.clone_history().await.raw_items().to_vec(),
Vec::new()
retained_world_state
);
assert_eq!(
serde_json::to_value(session.reference_context_item().await)
+27
View File
@@ -1,11 +1,13 @@
use super::session::Session;
use super::step_context::StepContext;
use crate::connectors;
use crate::context::ApprovalPromptContext;
use crate::context::world_state::AgentsMdState;
use crate::context::world_state::AppsInstructionsState;
use crate::context::world_state::CollaborationModeState;
use crate::context::world_state::EnvironmentsInstructionsState;
use crate::context::world_state::EnvironmentsState;
use crate::context::world_state::PermissionsState;
use crate::context::world_state::PluginsInstructionsState;
use crate::context::world_state::RealtimeState;
use crate::context::world_state::WorldState;
@@ -40,6 +42,31 @@ impl Session {
.as_deref(),
));
world_state.add_section(AgentsMdState::new(step_context.loaded_agents_md.as_deref()));
if turn_context.config.include_permissions_instructions {
let permission_profile = turn_context.permission_profile();
let model_messages = turn_context.model_info.model_messages.as_ref();
let exec_policy = self.services.exec_policy.current();
world_state.add_section(PermissionsState::new(
&permission_profile,
turn_context.approval_policy.value(),
ApprovalPromptContext::new(
turn_context.config.approvals_reviewer,
model_messages.and_then(|messages| messages.approvals.as_ref()),
model_messages.and_then(|messages| messages.permissions.as_ref()),
),
exec_policy.as_ref(),
#[allow(deprecated)]
&turn_context.cwd,
turn_context
.config
.features
.enabled(Feature::ExecPermissionApprovals),
turn_context
.config
.features
.enabled(Feature::RequestPermissionsTool),
));
}
if turn_context.config.include_collaboration_mode_instructions
&& let Some(collaboration_mode) =
CollaborationModeState::from_collaboration_mode(&turn_context.collaboration_mode())