mirror of
https://github.com/openai/codex.git
synced 2026-11-07 20:30:33 +00:00
Add original-history evidence snapshots for Guardian V3 (#59669)
## What changed Add `EvidenceSnapshot::capture` to read original evidence from `ThreadStore` into an immutable snapshot with root-history ordinals. Preserve accepted user messages, assistant messages, tool activity, verified answers, delivered assistant messages, and host-annotated approvals without changing their contents or provenance. Exclude generated context and compaction output. Reject forked or reverted histories, inherited or ambiguously attributed user messages, and incomplete retained evidence. Capture currently supports append-only root histories and requires the host to flush pending originals first. ## Testing Add an integration test covering content preservation, generated-context filtering, approval provenance, isolation from later appends, and rejection of ambiguous user messages. GitOrigin-RevId: f464a2714c2678bcffe7abf0a8224196bfe48692
This commit is contained in:
Generated
+9
@@ -3797,9 +3797,18 @@ dependencies = [
|
||||
name = "codex-guardian-v3"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"codex-context-fragments",
|
||||
"codex-history",
|
||||
"codex-message-board",
|
||||
"codex-protocol",
|
||||
"codex-state",
|
||||
"codex-thread-store",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -12,6 +12,17 @@ test = false
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
codex-history = { workspace = true }
|
||||
codex-message-board = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-state = { workspace = true }
|
||||
codex-thread-store = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
codex-context-fragments = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Captures original conversation, tool activity and host-verified evidence from history.
|
||||
//! The prototype supports append-only root histories; it has no durable intake state.
|
||||
|
||||
use anyhow::ensure;
|
||||
use codex_history::ResponseItemEnvelope;
|
||||
use codex_history::RetainedContextEvent;
|
||||
use codex_history::RolloutItem;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::ContentItemProvenance;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_thread_store::LoadThreadHistoryParams;
|
||||
use codex_thread_store::ThreadStore;
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Position in the containing snapshot's root history, independent of channel routing.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
pub struct EvidenceId {
|
||||
pub ordinal: usize,
|
||||
}
|
||||
|
||||
/// The original rollout record retains roles, call IDs and host provenance unchanged.
|
||||
/// Its contents are deliberately excluded from Debug output.
|
||||
#[derive(Serialize)]
|
||||
pub struct OriginalEvidence {
|
||||
pub id: EvidenceId,
|
||||
pub original: RolloutItem,
|
||||
}
|
||||
|
||||
/// An immutable capture. Later history appends cannot enter its reads or routing.
|
||||
/// Clones share the captured records rather than copying message payloads.
|
||||
#[derive(Clone)]
|
||||
pub struct EvidenceSnapshot {
|
||||
thread_id: ThreadId,
|
||||
records: Arc<[OriginalEvidence]>,
|
||||
}
|
||||
|
||||
impl EvidenceSnapshot {
|
||||
/// Load through the existing history API after the host flushes pending originals.
|
||||
/// This deliberately reloads history; incremental IO and restart recovery are deferred.
|
||||
/// Forked, reverted and ambiguously attributed user histories are unsupported.
|
||||
pub async fn capture(store: &dyn ThreadStore, thread_id: ThreadId) -> anyhow::Result<Self> {
|
||||
let history = store
|
||||
.load_history(LoadThreadHistoryParams {
|
||||
thread_id,
|
||||
include_archived: false,
|
||||
})
|
||||
.await?;
|
||||
ensure!(
|
||||
history.thread_id == thread_id,
|
||||
"evidence history owner mismatch"
|
||||
);
|
||||
let mut records = Vec::new();
|
||||
for (ordinal, item) in history.items.into_iter().enumerate() {
|
||||
let include = match &item {
|
||||
RolloutItem::ResponseItem(envelope) => selected(envelope)?,
|
||||
RolloutItem::RetainedContext(RetainedContextEvent::VerifiedAnswer {
|
||||
answer,
|
||||
..
|
||||
}) => {
|
||||
ensure!(!answer.questions.is_empty(), "incomplete verified answer");
|
||||
true
|
||||
}
|
||||
RolloutItem::RetainedContext(RetainedContextEvent::DeliveredAssistantMessage {
|
||||
message,
|
||||
..
|
||||
}) => {
|
||||
ensure!(message.complete, "incomplete delivered assistant message");
|
||||
true
|
||||
}
|
||||
RolloutItem::SessionMeta(meta) => {
|
||||
ensure!(
|
||||
meta.meta.forked_from_id.is_none(),
|
||||
"forked evidence is unsupported"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(_)) => {
|
||||
anyhow::bail!("reverted evidence is unsupported");
|
||||
}
|
||||
RolloutItem::RetainedContext(RetainedContextEvent::AcceptedFileAudio {
|
||||
..
|
||||
})
|
||||
| RolloutItem::DynamicToolsUpdated { .. }
|
||||
| RolloutItem::InterAgentCommunication(_)
|
||||
| RolloutItem::InterAgentCommunicationMetadata { .. }
|
||||
| RolloutItem::Compacted(_)
|
||||
| RolloutItem::TurnContext(_)
|
||||
| RolloutItem::TokenUsageRecord(_)
|
||||
| RolloutItem::WorldState(_)
|
||||
| RolloutItem::SecurityRiskScore(_)
|
||||
| RolloutItem::EventMsg(_)
|
||||
| RolloutItem::RealtimeItem(_) => continue,
|
||||
};
|
||||
if include {
|
||||
records.push(OriginalEvidence {
|
||||
id: EvidenceId { ordinal },
|
||||
original: item,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
thread_id,
|
||||
records: records.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn thread_id(&self) -> ThreadId {
|
||||
self.thread_id
|
||||
}
|
||||
|
||||
pub fn records(&self) -> &[OriginalEvidence] {
|
||||
&self.records
|
||||
}
|
||||
}
|
||||
|
||||
fn selected(envelope: &ResponseItemEnvelope) -> anyhow::Result<bool> {
|
||||
if let Some(metadata) = &envelope.metadata {
|
||||
ensure!(
|
||||
!metadata.inherited_user_message,
|
||||
"inherited evidence is unsupported"
|
||||
);
|
||||
if metadata.compaction_output {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ok(match &envelope.item {
|
||||
ResponseItem::Message {
|
||||
role,
|
||||
content,
|
||||
internal_chat_message_metadata_passthrough,
|
||||
..
|
||||
} if role == "user" => {
|
||||
if envelope
|
||||
.metadata
|
||||
.as_ref()
|
||||
.is_some_and(|metadata| metadata.user_input_order.is_some())
|
||||
{
|
||||
true
|
||||
} else {
|
||||
// Generated context can use the user wire role. Only explicit producer
|
||||
// attribution permits skipping it; unknown user evidence stops capture.
|
||||
let metadata = internal_chat_message_metadata_passthrough.as_ref();
|
||||
let kinds = metadata.and_then(|metadata| metadata.content_item_kinds.as_ref());
|
||||
let sources = metadata.and_then(|metadata| metadata.content_item_metadata.as_ref());
|
||||
ensure!(
|
||||
kinds.zip(sources).is_some_and(|(kinds, sources)| {
|
||||
!content.is_empty()
|
||||
&& kinds.len() == content.len()
|
||||
&& sources.len() == content.len()
|
||||
&& kinds.iter().zip(sources).all(|(kind, source)| {
|
||||
!kind.as_str().is_empty()
|
||||
&& kind.as_str() != "unknown"
|
||||
&& !kind.as_str().starts_with("user.")
|
||||
&& matches!(
|
||||
&source.provenance,
|
||||
ContentItemProvenance::Harness { .. }
|
||||
| ContentItemProvenance::AgentsMd
|
||||
| ContentItemProvenance::Skills
|
||||
| ContentItemProvenance::DeveloperInstructions { .. }
|
||||
| ContentItemProvenance::Tool { .. }
|
||||
)
|
||||
})
|
||||
}),
|
||||
"user evidence has no host acceptance or context provenance"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
ResponseItem::Message { role, .. } if role == "assistant" => true,
|
||||
ResponseItem::Message {
|
||||
role,
|
||||
content,
|
||||
internal_chat_message_metadata_passthrough: Some(metadata),
|
||||
..
|
||||
} if role == "developer" => {
|
||||
// Only the host's approval fragment is evidence, not arbitrary developer text.
|
||||
metadata
|
||||
.content_item_kinds
|
||||
.as_ref()
|
||||
.zip(metadata.content_item_metadata.as_ref())
|
||||
.is_some_and(|(kinds, sources)| {
|
||||
!content.is_empty()
|
||||
&& kinds.len() == content.len()
|
||||
&& sources.len() == content.len()
|
||||
&& kinds.iter().zip(sources).all(|(kind, source)| {
|
||||
kind.as_str() == "guardian.approved_action"
|
||||
&& matches!(
|
||||
&source.provenance,
|
||||
ContentItemProvenance::Harness { .. }
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
ResponseItem::FunctionCall { .. }
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::ToolSearchCall { .. }
|
||||
| ResponseItem::FunctionCallOutput { .. }
|
||||
| ResponseItem::CustomToolCallOutput { .. }
|
||||
| ResponseItem::ToolSearchOutput { .. } => true,
|
||||
ResponseItem::Message { .. }
|
||||
| ResponseItem::AgentMessage { .. }
|
||||
| ResponseItem::Reasoning { .. }
|
||||
| ResponseItem::AdditionalTools { .. }
|
||||
| ResponseItem::Compaction { .. }
|
||||
| ResponseItem::ContextCompaction { .. }
|
||||
| ResponseItem::ConfigurationUpdate { .. }
|
||||
| ResponseItem::CompactionTrigger { .. }
|
||||
| ResponseItem::Other => false,
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Guardian's durable evidence storage, owned by the main thread.
|
||||
//! Shared boards provide persistence and access checks. This crate starts no background work
|
||||
//! and exposes no model tools; ingestion and review integration follow separately.
|
||||
//! and exposes no model tools. Evidence capture reads original root history;
|
||||
//! live worker and review integration follow separately.
|
||||
//! Hosts invoke permanent-deletion cleanup only when Guardian V3 is enabled.
|
||||
|
||||
use codex_message_board::LocalBoard;
|
||||
@@ -8,6 +9,11 @@ use codex_message_board::LocalBoardOptions;
|
||||
use codex_protocol::SessionId;
|
||||
use codex_state::SqliteConfig;
|
||||
|
||||
mod evidence;
|
||||
pub use evidence::EvidenceId;
|
||||
pub use evidence::EvidenceSnapshot;
|
||||
pub use evidence::OriginalEvidence;
|
||||
|
||||
/// Storage selected by the Guardian consumer for setup, opening and cleanup.
|
||||
pub fn local_board_options() -> LocalBoardOptions {
|
||||
LocalBoardOptions {
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Captured originals preserve attribution, complete contents and a fixed history boundary.
|
||||
|
||||
use codex_context_fragments::AdditionalContextUserFragment;
|
||||
use codex_context_fragments::AnnotatedContent;
|
||||
use codex_context_fragments::ContextualUserFragment;
|
||||
use codex_context_fragments::message_from_parts;
|
||||
use codex_guardian_v3::EvidenceSnapshot;
|
||||
use codex_history::AcceptedFileAudio;
|
||||
use codex_history::CodexHarnessMetadata;
|
||||
use codex_history::ResponseItemEnvelope;
|
||||
use codex_history::RetainedContextEvent;
|
||||
use codex_history::RetainedUserMessage;
|
||||
use codex_history::RolloutItem;
|
||||
use codex_history::UserInputOrigin;
|
||||
use codex_history::VerifiedAnswer;
|
||||
use codex_history::VerifiedQuestionAnswer;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ContentItemKind;
|
||||
use codex_protocol::models::ContentItemMetadata;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::ThreadHistoryMode;
|
||||
use codex_protocol::protocol::ThreadMemoryMode;
|
||||
use codex_state::SqliteConfig;
|
||||
use codex_thread_store::AppendThreadItemsParams;
|
||||
use codex_thread_store::CreateThreadParams;
|
||||
use codex_thread_store::LocalThreadStore;
|
||||
use codex_thread_store::LocalThreadStoreConfig;
|
||||
use codex_thread_store::PersistContext;
|
||||
use codex_thread_store::ThreadPersistenceMetadata;
|
||||
use codex_thread_store::ThreadStore;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test]
|
||||
async fn captures_originals_without_generated_context_or_later_appends() -> anyhow::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let owner = ThreadId::new();
|
||||
let store = LocalThreadStore::new(
|
||||
LocalThreadStoreConfig {
|
||||
codex_home: dir.path().to_path_buf(),
|
||||
sqlite: SqliteConfig::new_for_testing(dir.path().to_path_buf().try_into()?),
|
||||
default_model_provider_id: "test-provider".into(),
|
||||
},
|
||||
/*state_db*/ None,
|
||||
);
|
||||
store
|
||||
.create_thread(CreateThreadParams {
|
||||
attached_thread: false,
|
||||
creator_user_id: None,
|
||||
creator_account_id: None,
|
||||
session_id: owner.into(),
|
||||
thread_id: owner,
|
||||
extra_config: None,
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
source: SessionSource::Exec,
|
||||
thread_source: None,
|
||||
originator: "test-originator".into(),
|
||||
base_instructions: BaseInstructions::default(),
|
||||
dynamic_tools: Vec::new(),
|
||||
selected_capability_roots: Vec::new(),
|
||||
multi_agent_version: None,
|
||||
history_mode: ThreadHistoryMode::Legacy,
|
||||
history_base: None,
|
||||
subagent_history_start_ordinal: None,
|
||||
initial_window_id: "window-1".into(),
|
||||
runtime_workspace_roots: None,
|
||||
metadata: ThreadPersistenceMetadata {
|
||||
cwd: Some(dir.path().to_path_buf()),
|
||||
model_provider: "test-provider".into(),
|
||||
memory_mode: ThreadMemoryMode::Disabled,
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
let user = ResponseItemEnvelope {
|
||||
item: serde_json::from_value(json!({"type":"message","id":"user-1","role":"user",
|
||||
"content":[{"type":"input_text","text":"é🙂".repeat(15_000)}]}))?,
|
||||
metadata: Some(CodexHarnessMetadata {
|
||||
user_input_order: Some(1),
|
||||
..Default::default()
|
||||
}),
|
||||
};
|
||||
let assistant = ResponseItemEnvelope::new(serde_json::from_value(json!({
|
||||
"type":"message","id":"assistant-1","role":"assistant",
|
||||
"content":[{"type":"output_text","text":"I will inspect the directory."}]
|
||||
}))?);
|
||||
let call = ResponseItemEnvelope::new(serde_json::from_value(json!({
|
||||
"type":"function_call","id":"call-1","call_id":"call-1","name":"shell","arguments":"{\"cmd\":\"pwd\"}"
|
||||
}))?);
|
||||
let result = ResponseItemEnvelope::new(serde_json::from_value(json!({
|
||||
"type":"function_call_output","call_id":"call-1","output":"/workspace"
|
||||
}))?);
|
||||
let generated = ResponseItemEnvelope::new(ContextualUserFragment::into(
|
||||
AdditionalContextUserFragment::new("environment".into(), "Generated context".into()),
|
||||
));
|
||||
let mut summary = assistant.clone();
|
||||
summary.metadata = Some(CodexHarnessMetadata {
|
||||
compaction_output: true,
|
||||
..Default::default()
|
||||
});
|
||||
let approved_action = r#"The user has manually approved a specific action that was previously `Rejected`.
|
||||
Treat this as approval to perform that exact action in the same context in which it was originally requested.
|
||||
Do not assume this also authorizes similar operations with different payloads.
|
||||
Approved action: {"action":{"command":"write /workspace/report.txt"},"outcome":"allowed"}"#;
|
||||
let approval = ResponseItemEnvelope::new(message_from_parts(
|
||||
"developer",
|
||||
vec![AnnotatedContent::text(
|
||||
approved_action,
|
||||
ContentItemKind("guardian.approved_action".into()),
|
||||
ContentItemMetadata::harness(),
|
||||
)],
|
||||
));
|
||||
// Identical words without the host's approval annotation must not be promoted.
|
||||
let ordinary_developer = ResponseItemEnvelope::new(serde_json::from_value(json!({
|
||||
"type":"message","role":"developer","content":[{"type":"input_text","text":approved_action}]
|
||||
}))?);
|
||||
let answer = RolloutItem::RetainedContext(RetainedContextEvent::VerifiedAnswer {
|
||||
answer: VerifiedAnswer {
|
||||
turn_id: "turn-1".into(),
|
||||
call_id: "question-1".into(),
|
||||
questions: vec![VerifiedQuestionAnswer {
|
||||
question: "May I publish the report?".into(),
|
||||
answer: "Only the draft; do not publish the final report.".into(),
|
||||
}],
|
||||
},
|
||||
acceptance_order: Some(2),
|
||||
});
|
||||
let delivered = RolloutItem::RetainedContext(RetainedContextEvent::DeliveredAssistantMessage {
|
||||
message: RetainedUserMessage {
|
||||
turn_id: "turn-1".into(),
|
||||
message_id: Some("delivered-1".into()),
|
||||
text: "I will write the draft only.".into(),
|
||||
complete: true,
|
||||
origin: UserInputOrigin::User,
|
||||
phase: None,
|
||||
},
|
||||
acceptance_order: 3,
|
||||
});
|
||||
let expected = vec![
|
||||
RolloutItem::ResponseItem(user.clone()),
|
||||
RolloutItem::ResponseItem(assistant),
|
||||
RolloutItem::ResponseItem(call),
|
||||
RolloutItem::ResponseItem(result),
|
||||
answer,
|
||||
RolloutItem::ResponseItem(approval),
|
||||
delivered,
|
||||
];
|
||||
let mut items = expected.clone();
|
||||
items.insert(1, RolloutItem::ResponseItem(generated));
|
||||
items.extend([
|
||||
RolloutItem::ResponseItem(summary),
|
||||
RolloutItem::ResponseItem(ordinary_developer),
|
||||
RolloutItem::RetainedContext(RetainedContextEvent::AcceptedFileAudio {
|
||||
evidence: Arc::new(AcceptedFileAudio {
|
||||
turn_id: "turn-1".into(),
|
||||
model: "test-model".into(),
|
||||
provider: "test-provider".into(),
|
||||
file_ids: ["file-audio-1".into()].into(),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
store
|
||||
.append_items(AppendThreadItemsParams {
|
||||
thread_id: owner,
|
||||
items,
|
||||
})
|
||||
.await?;
|
||||
store
|
||||
.persist_thread(owner, PersistContext::Standard)
|
||||
.await?;
|
||||
store.flush_thread(owner).await?;
|
||||
let captured = EvidenceSnapshot::capture(&store, owner).await?;
|
||||
let originals = captured
|
||||
.records()
|
||||
.iter()
|
||||
.map(|record| &record.original)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
serde_json::to_value(originals)?,
|
||||
serde_json::to_value(expected)?
|
||||
);
|
||||
|
||||
let mut later = user.clone();
|
||||
later.item = serde_json::from_value(json!({"type":"message","id":"user-2","role":"user",
|
||||
"content":[{"type":"input_text","text":"Do not publish."}]}))?;
|
||||
later.metadata = Some(CodexHarnessMetadata {
|
||||
user_input_order: Some(4),
|
||||
..Default::default()
|
||||
});
|
||||
store
|
||||
.append_items(AppendThreadItemsParams {
|
||||
thread_id: owner,
|
||||
items: vec![RolloutItem::ResponseItem(later.clone())],
|
||||
})
|
||||
.await?;
|
||||
store.flush_thread(owner).await?;
|
||||
let latest = EvidenceSnapshot::capture(&store, owner).await?;
|
||||
let new_record = latest.records().last().expect("later original");
|
||||
assert_eq!(
|
||||
serde_json::to_value(&new_record.original)?,
|
||||
serde_json::to_value(RolloutItem::ResponseItem(later.clone()))?
|
||||
);
|
||||
assert_eq!(latest.records().len(), captured.records().len() + 1);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&latest.records()[..captured.records().len()])?,
|
||||
serde_json::to_value(captured.records())?
|
||||
);
|
||||
|
||||
let mut ambiguous = later;
|
||||
ambiguous.metadata = None;
|
||||
store
|
||||
.append_items(AppendThreadItemsParams {
|
||||
thread_id: owner,
|
||||
items: vec![RolloutItem::ResponseItem(ambiguous)],
|
||||
})
|
||||
.await?;
|
||||
store.flush_thread(owner).await?;
|
||||
assert!(EvidenceSnapshot::capture(&store, owner).await.is_err());
|
||||
store.shutdown_thread(owner).await?;
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user