diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index 2faa3801f2..bdc506e8f2 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -248,7 +248,9 @@ pub(crate) async fn build_guardian_prompt_items_with_parent_turn( push_text(text); } } - ContextSection::PreviousReviews(_) | ContextSection::TrustedTool(_) => { + ContextSection::PreviousReviews(_) + | ContextSection::TrustedTool(_) + | ContextSection::TrustedSkills(_) => { unreachable!("trusted review and tool sections are async-only") } ContextSection::ConversationTranscript { .. } => {} @@ -485,6 +487,7 @@ pub(super) fn collect_guardian_context( permissions, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) } diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs b/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs index f8cfaae78e..9818f4734e 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs @@ -580,6 +580,7 @@ impl GuardianV2Extension { planned_action: Some(&action_section), previous_reviews: Some(&reviews), trusted_tool: trusted_tool_context.as_ref(), + trusted_skill_paths: &trusted_skill_paths, }) }); let transcript = match transcript { @@ -611,8 +612,10 @@ impl GuardianV2Extension { let mut classification_input = Vec::new(); let mut trusted_review_evidence = None; let mut trusted_tool_context = None; + let mut trusted_skills = None; for section in transcript.sections { match section { + ContextSection::TrustedSkills(skills) => trusted_skills = Some(skills), ContextSection::TrustedTool(tool) => trusted_tool_context = Some(tool), ContextSection::PreviousReviews(reviews) => { trusted_review_evidence = Some(reviews) @@ -668,7 +671,7 @@ impl GuardianV2Extension { instructions, trusted_review_evidence, trusted_tool_context, - trusted_skill_paths, + trusted_skills, input: classification_input, images, parent_compaction, diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/sampler.rs b/codex-rs/ext/guardian-v2/src/async_scorer/sampler.rs index f84d5cd920..b4d7afe056 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/sampler.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/sampler.rs @@ -39,7 +39,7 @@ use thiserror::Error; use tokio::sync::oneshot; use uuid::Uuid; -use super::trusted_skills::GuardianTrustedSkillsFragment; +use codex_guardian_context::TrustedSkills; use codex_guardian_context::TrustedTool; pub(crate) const MODEL: &str = "gpt-5.6-luna"; @@ -90,7 +90,7 @@ pub struct LunaSamplingRequest { /// Host-attested metadata for the current home-owned MCP tool or connector. pub trusted_tool_context: Option, /// Host-verified paths of user-owned skills invoked during this turn. - pub trusted_skill_paths: Vec, + pub trusted_skills: Option, /// Ordered untrusted input entries that the model should classify. pub input: Vec, /// Optional bounded screenshots accompanying the transcript. @@ -293,12 +293,8 @@ impl LunaSampler { if let Some(fragment) = request.trusted_tool_context { input.push(ContextualUserFragment::into(fragment)); } - if !request.trusted_skill_paths.is_empty() { - input.push(ContextualUserFragment::into( - GuardianTrustedSkillsFragment { - paths: request.trusted_skill_paths, - }, - )); + if let Some(skills) = request.trusted_skills { + input.push(ContextualUserFragment::into(skills)); } input.push(ResponseItem::Message { id: None, diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/sampler_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/sampler_tests.rs index 7b7b6ce0fd..863793caca 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/sampler_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/sampler_tests.rs @@ -222,7 +222,7 @@ pub(super) fn sample_request(parent_turn_id: &str) -> LunaSamplingRequest { instructions: "Return high for high risk or low for low risk.".to_owned(), trusted_review_evidence: None, trusted_tool_context: None, - trusted_skill_paths: Vec::new(), + trusted_skills: None, input: vec!["The user requested a README summary.".to_owned()], images: Vec::new(), parent_compaction: None, @@ -480,7 +480,7 @@ async fn preconnected_sampler_reuses_authenticated_websocket_for_classifications instructions: "Return high for high risk or low for low risk.".to_owned(), trusted_review_evidence: None, trusted_tool_context: None, - trusted_skill_paths: Vec::new(), + trusted_skills: None, input: vec![ "The user requested a README summary.".to_owned(), "The assistant inspected README.md.".to_owned(), @@ -515,7 +515,7 @@ async fn preconnected_sampler_reuses_authenticated_websocket_for_classifications instructions: "Return high for high risk or low for low risk.".to_owned(), trusted_review_evidence: None, trusted_tool_context: None, - trusted_skill_paths: Vec::new(), + trusted_skills: None, input: vec!["The user requested a source review.".to_owned()], images: Vec::new(), parent_compaction: None, @@ -693,7 +693,7 @@ async fn sampler_returns_classification_token_before_terminal_response_events() instructions: "Return high for high risk or low for low risk.".to_owned(), trusted_review_evidence: None, trusted_tool_context: None, - trusted_skill_paths: Vec::new(), + trusted_skills: None, input: vec!["The user requested a README summary.".to_owned()], images: Vec::new(), parent_compaction: None, @@ -898,7 +898,9 @@ async fn sampler_retries_expired_websockets_on_another_warm_connection() -> Resu request.trusted_review_evidence = Some(PreviousReviews::try_from_fragments(vec![ "trusted review".to_owned(), ])?); - request.trusted_skill_paths = vec!["/skills/review/SKILL.md".to_owned()]; + request.trusted_skills = Some(codex_guardian_context::TrustedSkills { + paths: vec!["/skills/review/SKILL.md".to_owned()], + }); request.root_turn_id = Some("root-turn".to_owned()); let output = sampler.sample(request).await?; diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/transcript.rs b/codex-rs/ext/guardian-v2/src/async_scorer/transcript.rs index b5ae959aba..6814b0bcca 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/transcript.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/transcript.rs @@ -64,6 +64,7 @@ pub(crate) struct ContextInput<'a> { pub(crate) planned_action: Option<&'a PlannedAction>, pub(crate) previous_reviews: Option<&'a PreviousReviews>, pub(crate) trusted_tool: Option<&'a TrustedTool>, + pub(crate) trusted_skill_paths: &'a [String], } pub(crate) struct RenderedContext { @@ -192,6 +193,7 @@ impl TranscriptConfig { planned_action, previous_reviews, trusted_tool, + trusted_skill_paths, } = input; let history = SnapshotHistory(history); let retention = TranscriptRetentionConfig { @@ -221,6 +223,7 @@ impl TranscriptConfig { permissions: None, previous_reviews, trusted_tool, + trusted_skill_paths, })?; let mut truncations = Vec::new(); let sections = context @@ -243,6 +246,7 @@ impl TranscriptConfig { ContextSection::PermissionContext { items } => { ContextSection::PermissionContext { items } } + ContextSection::TrustedSkills(skills) => ContextSection::TrustedSkills(skills), ContextSection::TrustedTool(tool) => ContextSection::TrustedTool(tool), ContextSection::PreviousReviews(reviews) => { ContextSection::PreviousReviews(reviews) diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs index 5dbc04600c..d4c90e21b5 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs @@ -109,6 +109,7 @@ fn transcript_keeps_conversation_and_configured_sources() { planned_action: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("collect transcript") .transcript_entries(); @@ -134,6 +135,7 @@ fn transcript_keeps_conversation_and_configured_sources() { planned_action: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("compose authorization and transcript"); assert_eq!( @@ -165,6 +167,7 @@ fn transcript_keeps_conversation_and_configured_sources() { planned_action: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("collect transcript") .transcript_entries(); @@ -191,6 +194,7 @@ fn transcript_keeps_conversation_and_configured_sources() { planned_action: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("collect transcript") .transcript_entries(); @@ -242,6 +246,7 @@ fn transcript_truncates_oversized_entries_without_splitting_characters() { planned_action: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("collect transcript"); let transcript = rendered.transcript_entries(); @@ -308,6 +313,7 @@ fn transcript_preserves_first_and_latest_user_messages_and_recent_history() { planned_action: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("collect transcript") .transcript_entries(); @@ -372,6 +378,7 @@ fn transcript_preserves_user_restrictions_before_final_assistant_messages() { planned_action: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("collect transcript") .transcript_entries(); @@ -410,6 +417,7 @@ fn transcript_preserves_recent_tool_evidence_when_protected_messages_fill_entry_ planned_action: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("collect transcript"); @@ -474,6 +482,7 @@ fn transcript_reserves_five_recent_tool_entries_from_protected_messages() { planned_action: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("collect transcript") .transcript_entries(); @@ -528,6 +537,7 @@ fn rejected_commentary_does_not_evict_retained_message_evidence() { planned_action: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("collect transcript") .transcript_entries(); @@ -567,6 +577,7 @@ fn transcript_evicts_protected_messages_in_cacheable_chunks() { planned_action: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("collect transcript") .transcript_entries() @@ -636,6 +647,7 @@ fn transcript_preserves_latest_final_when_reserved_tools_fill_entry_window() { planned_action: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("collect transcript") .transcript_entries(); @@ -707,6 +719,7 @@ fn transcript_does_not_protect_legacy_inter_agent_instructions() { planned_action: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("collect transcript") .transcript_entries(); @@ -752,6 +765,7 @@ fn transcript_reserves_separate_budget_for_recent_tool_evidence() { planned_action: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("collect transcript") .transcript_entries(); @@ -812,6 +826,7 @@ fn transcript_reserves_separate_budget_for_recent_tool_evidence() { planned_action: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("collect transcript") .transcript_entries(); @@ -874,6 +889,7 @@ fn transcript_preserves_newest_manual_approval_when_message_budget_overflows() { planned_action: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("collect transcript") .transcript_entries(); @@ -932,6 +948,7 @@ fn rejected_message_does_not_evict_retained_tool_entries() { planned_action: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("collect transcript") .transcript_entries(); @@ -975,6 +992,7 @@ fn transcript_evicts_non_user_entries_in_cacheable_chunks() { planned_action: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("collect transcript") .transcript_entries() @@ -1046,6 +1064,7 @@ fn transcript_truncates_tool_results_using_standard_budget() { planned_action: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("collect transcript") .transcript_entries(); @@ -1099,6 +1118,7 @@ fn transcript_preserves_outputs_with_call_ids_or_explicit_names() { planned_action: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("collect transcript") .transcript_entries(), @@ -1126,6 +1146,7 @@ fn transcript_preserves_outputs_with_call_ids_or_explicit_names() { planned_action: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("collect transcript") .transcript_entries(), @@ -1171,6 +1192,7 @@ fn configured_reasoning_counts_against_message_budget() { planned_action: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("collect transcript") .transcript_entries(); @@ -1238,6 +1260,7 @@ fn transcript_keeps_only_manual_approval_developer_messages() { planned_action: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("collect transcript") .transcript_entries(); @@ -1323,6 +1346,7 @@ fn transcript_omits_media_payloads_and_keeps_readable_content() { planned_action: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("collect transcript") .transcript_entries(); @@ -1390,6 +1414,7 @@ fn transcript_omits_encrypted_messages_arguments_and_tool_outputs() { planned_action: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("collect transcript") .transcript_entries(); diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/trusted_skills.rs b/codex-rs/ext/guardian-v2/src/async_scorer/trusted_skills.rs index 5fe92b3035..a7ba77cf34 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/trusted_skills.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/trusted_skills.rs @@ -5,70 +5,11 @@ use std::path::Path; use std::path::PathBuf; use codex_core::config::Config; -use codex_extension_api::ContentItemKind; -use codex_extension_api::ContextualUserFragment; -use codex_protocol::protocol::TruncationPolicy; use dirs::home_dir; -use serde_json::json; const MAX_TRUSTED_SKILLS: usize = 16; const MAX_TRUSTED_SKILL_PATH_BYTES: usize = 512; const MAX_TRUSTED_SKILL_PATHS_BYTES: usize = 2_048; -const MAX_TRUSTED_SKILL_TOKENS: usize = 768; -const TRUSTED_SKILLS_PREFIX: &str = "Codex-verified invoked user-owned skill paths:\n"; - -/// Host-verified user-owned skill paths for the current Guardian classification. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct GuardianTrustedSkillsFragment { - pub(crate) paths: Vec, -} - -impl ContextualUserFragment for GuardianTrustedSkillsFragment { - fn content_kind(&self) -> ContentItemKind { - ContentItemKind("guardian.trusted_skills".to_owned()) - } - - fn role(&self) -> &'static str { - "developer" - } - - fn requires_separate_message(&self) -> bool { - true - } - - fn markers(&self) -> (&'static str, &'static str) { - Self::type_markers() - } - - fn type_markers() -> (&'static str, &'static str) { - ("", "") - } - - fn body(&self) -> String { - let mut paths = String::from("["); - let max_path_bytes = TruncationPolicy::Tokens(MAX_TRUSTED_SKILL_TOKENS) - .byte_budget() - .saturating_sub(TRUSTED_SKILLS_PREFIX.len()); - for path in &self.paths { - let separator = if paths.ends_with('[') { "" } else { "," }; - let encoded_path = json!(path).to_string(); - if paths - .len() - .saturating_add(separator.len()) - .saturating_add(encoded_path.len()) - .saturating_add(1) - > max_path_bytes - { - continue; - } - paths.push_str(separator); - paths.push_str(&encoded_path); - } - paths.push(']'); - format!("{TRUSTED_SKILLS_PREFIX}{paths}") - } -} - /// Host-owned roots from which invoked skill paths can be verified. pub(crate) struct TrustedSkillRoots { roots: Vec, diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/trusted_skills_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/trusted_skills_tests.rs index 21b9b45119..887de7bced 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/trusted_skills_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/trusted_skills_tests.rs @@ -1,68 +1,13 @@ -use super::GuardianTrustedSkillsFragment; use super::MAX_TRUSTED_SKILL_PATHS_BYTES; -use super::MAX_TRUSTED_SKILL_TOKENS; use super::MAX_TRUSTED_SKILLS; -use super::TRUSTED_SKILLS_PREFIX; use super::TrustedSkillInvocations; use super::TrustedSkillRoots; use anyhow::Result; -use codex_extension_api::ContextualUserFragment; -use codex_protocol::protocol::TruncationPolicy; use core_test_support::responses; use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; use pretty_assertions::assert_eq; -fn rendered_paths(paths: Vec) -> Vec { - let rendered = GuardianTrustedSkillsFragment { paths }.render(); - assert!(rendered.len() <= TruncationPolicy::Tokens(MAX_TRUSTED_SKILL_TOKENS).byte_budget()); - serde_json::from_str( - rendered - .strip_prefix(TRUSTED_SKILLS_PREFIX) - .expect("trusted skill context should contain JSON evidence"), - ) - .expect("trusted skill evidence should remain valid JSON") -} - -#[test] -fn renders_verified_skill_paths() { - assert_eq!( - rendered_paths(vec!["/home/user/.codex/skills/demo/SKILL.md".to_owned()]), - vec!["/home/user/.codex/skills/demo/SKILL.md"], - ); -} - -#[test] -fn bounds_escaped_skill_paths_without_corrupting_json_or_utf8() { - let paths = (0..MAX_TRUSTED_SKILLS) - .map(|index| { - format!( - "/home/user/.codex/skills/{index:03}/{}SKILL.md", - "\u{0001}é".repeat(80) - ) - }) - .collect::>(); - let retained = rendered_paths(paths.clone()); - - assert!(!retained.is_empty()); - assert!(retained.len() < paths.len()); - assert!(retained.iter().all(|path| paths.contains(path))); -} - -#[test] -fn preserves_multiple_invoked_skill_paths() { - assert_eq!( - rendered_paths(vec![ - "/home/user/.codex/skills/first/SKILL.md".to_owned(), - "/home/user/.codex/skills/second/SKILL.md".to_owned(), - ]), - vec![ - "/home/user/.codex/skills/first/SKILL.md", - "/home/user/.codex/skills/second/SKILL.md", - ], - ); -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn trusts_only_user_owned_skill_roots() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/guardian-context/src/lib.rs b/codex-rs/guardian-context/src/lib.rs index 5a20512f27..150b629058 100644 --- a/codex-rs/guardian-context/src/lib.rs +++ b/codex-rs/guardian-context/src/lib.rs @@ -51,7 +51,9 @@ mod authorization; mod entry; mod history; mod permissions; +mod trusted_skills; mod trusted_tool; +pub use trusted_skills::TrustedSkills; pub use trusted_tool::TrustedTool; mod reviews; pub use reviews::MAX_PREVIOUS_REVIEWS; @@ -118,6 +120,8 @@ pub struct SectionInput<'a> { pub previous_reviews: Option<&'a PreviousReviews>, /// Metadata verified by the host for the exact action being classified. pub trusted_tool: Option<&'a TrustedTool>, + /// Current-turn and delegated skill paths verified and bounded by the host. + pub trusted_skill_paths: &'a [String], } /// Supplies repeatable, zero-copy access to a host-owned conversation snapshot. @@ -207,6 +211,7 @@ pub fn default_registry() -> &'static SectionRegistry { let mut registry = SectionRegistry::default(); registry.register(reviews::PreviousReviewsSection); registry.register(trusted_tool::TrustedToolSection); + registry.register(trusted_skills::TrustedSkillsSection); registry.register(RootConversationSection); registry.register(RetainedUserInstructionsSection); registry.register(TrustedUserAnswersSection); diff --git a/codex-rs/guardian-context/src/registry_tests.rs b/codex-rs/guardian-context/src/registry_tests.rs index 185f78cdd0..4e5acad4ad 100644 --- a/codex-rs/guardian-context/src/registry_tests.rs +++ b/codex-rs/guardian-context/src/registry_tests.rs @@ -92,6 +92,7 @@ fn registry_collects_target_specific_sections_in_registration_order() { permissions: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }); let async_sections = registry.collect(&SectionInput { target: ContextTarget::Async, @@ -103,6 +104,7 @@ fn registry_collects_target_specific_sections_in_registration_order() { permissions: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }); assert_eq!( @@ -165,6 +167,7 @@ fn registry_skips_optional_sections_and_stops_on_missing_required_evidence() { permissions: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }), Err(error.clone()) ); @@ -228,6 +231,7 @@ fn reused_registry_preserves_section_identity_and_source_roles() { permissions: Some(&permissions), previous_reviews: Some(&reviews), trusted_tool: Some(&tool), + trusted_skill_paths: &["debug-secret/SKILL.md".into()], }) .unwrap(); assert!(!format!("{context:?}").contains("debug-secret")); @@ -260,6 +264,12 @@ fn reused_registry_preserves_section_identity_and_source_roles() { ] }); } if target == ContextTarget::Async { + expected.insert( + 0, + ContextSection::TrustedSkills(super::TrustedSkills { + paths: vec!["debug-secret/SKILL.md".into()], + }), + ); expected.insert(0, ContextSection::TrustedTool(tool.clone())); expected.insert(0, ContextSection::PreviousReviews(reviews.clone())); } @@ -277,6 +287,7 @@ fn reused_registry_preserves_section_identity_and_source_roles() { permissions: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .unwrap(), vec![ContextSection::ConversationTranscript { items: Vec::new() }] diff --git a/codex-rs/guardian-context/src/section.rs b/codex-rs/guardian-context/src/section.rs index 2c7dd43fe7..7b162a33ec 100644 --- a/codex-rs/guardian-context/src/section.rs +++ b/codex-rs/guardian-context/src/section.rs @@ -3,6 +3,7 @@ use crate::ConversationTranscriptEntry; use crate::PlannedAction; use crate::PreviousReviews; +use crate::TrustedSkills; use crate::TrustedTool; /// Ordered evidence with a stable section identity and source-specific content. @@ -21,5 +22,6 @@ pub enum ContextSection { PlannedAction(PlannedAction), PreviousReviews(PreviousReviews), TrustedTool(TrustedTool), + TrustedSkills(TrustedSkills), PermissionContext { items: Vec }, } diff --git a/codex-rs/guardian-context/src/transcript_tests.rs b/codex-rs/guardian-context/src/transcript_tests.rs index 222b3ed4d9..0f3b294111 100644 --- a/codex-rs/guardian-context/src/transcript_tests.rs +++ b/codex-rs/guardian-context/src/transcript_tests.rs @@ -102,6 +102,7 @@ fn registered_transcript_preserves_shared_roles_and_node_repl_tool_attribution() permissions: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("transcript collection should succeed"); @@ -178,6 +179,7 @@ fn excluded_tool_calls_still_attribute_included_results() { permissions: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("transcript collection should succeed"); @@ -273,6 +275,7 @@ fn outputs_with_call_ids_or_explicit_names_are_retained() { permissions: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("transcript collection should succeed"); let mut expected = vec![ @@ -322,6 +325,7 @@ fn reused_registry_applies_current_history_sources_and_entry_limits() { permissions: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("transcript collection should succeed"); assert_eq!( @@ -358,6 +362,7 @@ fn reused_registry_applies_current_history_sources_and_entry_limits() { permissions: None, previous_reviews: None, trusted_tool: None, + trusted_skill_paths: &[], }) .expect("transcript collection should succeed"); let mut expected = vec![ConversationTranscriptEntry { diff --git a/codex-rs/guardian-context/src/trusted_skills.rs b/codex-rs/guardian-context/src/trusted_skills.rs new file mode 100644 index 0000000000..33cd37aa66 --- /dev/null +++ b/codex-rs/guardian-context/src/trusted_skills.rs @@ -0,0 +1,95 @@ +//! Bounded rendering of host-verified invoked skill paths for async review. +//! Ownership checks, turn storage and delegated evidence selection stay in the host. + +use crate::ContextSection; +use crate::SectionContributor; +use crate::SectionError; +use crate::SectionInput; +use crate::SectionScope; +use codex_context_fragments::ContextualUserFragment; +use codex_protocol::models::ContentItemKind; +use codex_protocol::protocol::TruncationPolicy; +use serde_json::json; + +const MAX_TRUSTED_SKILL_TOKENS: usize = 768; +const TRUSTED_SKILLS_PREFIX: &str = "Codex-verified invoked user-owned skill paths:\n"; + +/// Host-verified user-owned skill paths for the current Guardian classification. +#[derive(Clone, PartialEq, Eq)] +pub struct TrustedSkills { + pub paths: Vec, +} + +impl ContextualUserFragment for TrustedSkills { + fn content_kind(&self) -> ContentItemKind { + ContentItemKind("guardian.trusted_skills".to_owned()) + } + + fn role(&self) -> &'static str { + "developer" + } + + fn requires_separate_message(&self) -> bool { + true + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } + + fn body(&self) -> String { + let mut paths = String::from("["); + let max_path_bytes = TruncationPolicy::Tokens(MAX_TRUSTED_SKILL_TOKENS) + .byte_budget() + .saturating_sub(TRUSTED_SKILLS_PREFIX.len()); + for path in &self.paths { + let separator = if paths.ends_with('[') { "" } else { "," }; + let encoded_path = json!(path).to_string(); + if paths + .len() + .saturating_add(separator.len()) + .saturating_add(encoded_path.len()) + .saturating_add(1) + > max_path_bytes + { + continue; + } + paths.push_str(separator); + paths.push_str(&encoded_path); + } + paths.push(']'); + format!("{TRUSTED_SKILLS_PREFIX}{paths}") + } +} + +impl std::fmt::Debug for TrustedSkills { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("TrustedSkills") + .field("count", &self.paths.len()) + .finish_non_exhaustive() + } +} + +pub(crate) struct TrustedSkillsSection; + +impl SectionContributor for TrustedSkillsSection { + fn scope(&self) -> SectionScope { + SectionScope::AsyncOnly + } + fn contribute(&self, input: &SectionInput<'_>) -> Result, SectionError> { + Ok((!input.trusted_skill_paths.is_empty()).then(|| { + ContextSection::TrustedSkills(TrustedSkills { + paths: input.trusted_skill_paths.to_vec(), + }) + })) + } +} + +#[cfg(test)] +#[path = "trusted_skills_tests.rs"] +mod tests; diff --git a/codex-rs/guardian-context/src/trusted_skills_tests.rs b/codex-rs/guardian-context/src/trusted_skills_tests.rs new file mode 100644 index 0000000000..cf70b3e19d --- /dev/null +++ b/codex-rs/guardian-context/src/trusted_skills_tests.rs @@ -0,0 +1,56 @@ +use super::MAX_TRUSTED_SKILL_TOKENS; +use super::TRUSTED_SKILLS_PREFIX; +use super::TrustedSkills; +use codex_context_fragments::ContextualUserFragment; +use codex_protocol::protocol::TruncationPolicy; +use pretty_assertions::assert_eq; + +fn rendered_paths(paths: Vec) -> Vec { + let rendered = TrustedSkills { paths }.render(); + assert!(rendered.len() <= TruncationPolicy::Tokens(MAX_TRUSTED_SKILL_TOKENS).byte_budget()); + serde_json::from_str( + rendered + .strip_prefix(TRUSTED_SKILLS_PREFIX) + .expect("trusted skill context should contain JSON evidence"), + ) + .expect("trusted skill evidence should remain valid JSON") +} + +#[test] +fn renders_verified_skill_paths() { + assert_eq!( + rendered_paths(vec!["/home/user/.codex/skills/demo/SKILL.md".to_owned()]), + vec!["/home/user/.codex/skills/demo/SKILL.md"], + ); +} + +#[test] +fn bounds_escaped_skill_paths_without_corrupting_json_or_utf8() { + let paths = (0..16) + .map(|index| { + format!( + "/home/user/.codex/skills/{index:03}/{}SKILL.md", + "\u{0001}é".repeat(80) + ) + }) + .collect::>(); + let retained = rendered_paths(paths.clone()); + + assert!(!retained.is_empty()); + assert!(retained.len() < paths.len()); + assert!(retained.iter().all(|path| paths.contains(path))); +} + +#[test] +fn preserves_multiple_invoked_skill_paths() { + assert_eq!( + rendered_paths(vec![ + "/home/user/.codex/skills/first/SKILL.md".to_owned(), + "/home/user/.codex/skills/second/SKILL.md".to_owned(), + ]), + vec![ + "/home/user/.codex/skills/first/SKILL.md", + "/home/user/.codex/skills/second/SKILL.md", + ], + ); +}