fix: allowlist public system input projections
This commit is contained in:
@@ -1180,12 +1180,10 @@ pub enum Event {
|
||||
},
|
||||
/// One agent-injected system item committed to history.
|
||||
///
|
||||
/// Carries the JSON form of `session_store::SystemItem`. Covers
|
||||
/// `Method::Notify` echoes, child-Worker lifecycle events from
|
||||
/// `Method::WorkerEvent`, `@<path>` resolution payloads, and any future
|
||||
/// Clients dispatch on the `kind` tag for typed rendering instead
|
||||
/// of parsing free-text prefixes like `[Notification] …` or
|
||||
/// `[File: …]`.
|
||||
/// Carries the allowlisted JSON form of `session_store::PublicSystemItem`,
|
||||
/// not the storage record. Its descriptive `kind` is independent from the
|
||||
/// typed `presentation` used for notification, reminder, or context rendering.
|
||||
/// Private Feature payloads and prompt provenance are never event data.
|
||||
///
|
||||
/// One event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and
|
||||
/// wire-side are 1:1.
|
||||
|
||||
@@ -98,7 +98,8 @@ pub use segment::{
|
||||
pub use segment_log::{LogEntry, RestoredState, SegmentOrigin, SessionExtension, collect_state};
|
||||
pub use store::{Store, StoreError};
|
||||
pub use system_item::{
|
||||
PromptRenderProvenance, SystemItem, SystemReminder, SystemReminderSource, render_worker_event,
|
||||
PromptRenderProvenance, PublicSystemItem, PublicSystemPresentation, SystemItem, SystemReminder,
|
||||
SystemReminderSource, render_worker_event,
|
||||
};
|
||||
pub use uploaded_file::{
|
||||
DEFAULT_MAX_FILES_PER_SUBMISSION, DEFAULT_MAX_SESSION_ARTIFACT_BYTES,
|
||||
|
||||
@@ -1992,16 +1992,10 @@ fn system_entry(
|
||||
provenance: SessionEntryProvenance,
|
||||
derived_from: Vec<String>,
|
||||
) -> SessionSnapshotEntry {
|
||||
let mut data = serde_json::to_value(item).ok();
|
||||
if let Some(serde_json::Value::Object(object)) = data.as_mut() {
|
||||
object.remove("prompt_provenance");
|
||||
}
|
||||
let item_kind = data
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("kind"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("system_item")
|
||||
.to_owned();
|
||||
let public = item.public_item();
|
||||
let data = Some(serde_json::to_value(&public).expect("public system input is serializable"));
|
||||
let item_kind = public.kind;
|
||||
let content = public.body;
|
||||
SessionSnapshotEntry {
|
||||
entry_id,
|
||||
timestamp,
|
||||
@@ -2009,7 +2003,7 @@ fn system_entry(
|
||||
derived_from,
|
||||
data: SessionSnapshotEntryData::SystemItem {
|
||||
item_kind,
|
||||
content: item.history_text(),
|
||||
content,
|
||||
data,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -197,7 +197,58 @@ pub enum SystemItem {
|
||||
},
|
||||
}
|
||||
|
||||
/// Allowlisted presentation data shared by live events and retained snapshots.
|
||||
/// Private Feature payloads and render provenance never cross this boundary.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PublicSystemItem {
|
||||
pub kind: String,
|
||||
pub body: String,
|
||||
pub presentation: PublicSystemPresentation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum PublicSystemPresentation {
|
||||
Notification { message: String },
|
||||
WorkerEvent { event: WorkerEvent },
|
||||
Context,
|
||||
TaskReminder,
|
||||
Ignored,
|
||||
}
|
||||
|
||||
impl SystemItem {
|
||||
pub fn public_item(&self) -> PublicSystemItem {
|
||||
let presentation = match self {
|
||||
Self::Notification { message, .. } => PublicSystemPresentation::Notification {
|
||||
message: message.clone(),
|
||||
},
|
||||
Self::WorkerEvent { event, .. } => PublicSystemPresentation::WorkerEvent {
|
||||
event: event.clone(),
|
||||
},
|
||||
Self::TaskReminder { .. } => PublicSystemPresentation::TaskReminder,
|
||||
Self::LegacyKnowledgeIgnored { .. } | Self::LegacyIgnored { .. } => {
|
||||
PublicSystemPresentation::Ignored
|
||||
}
|
||||
Self::JournalStartup { .. }
|
||||
| Self::ResidentSummaryRefresh { .. }
|
||||
| Self::SubjectBehaviorRefresh { .. }
|
||||
| Self::SkillActivation { .. }
|
||||
| Self::FeatureInvocationResult { .. }
|
||||
| Self::FileAttachment { .. }
|
||||
| Self::Interrupt { .. } => PublicSystemPresentation::Context,
|
||||
};
|
||||
PublicSystemItem {
|
||||
kind: self.kind_label().to_owned(),
|
||||
body: if matches!(presentation, PublicSystemPresentation::Ignored) {
|
||||
String::new()
|
||||
} else {
|
||||
self.history_text()
|
||||
},
|
||||
presentation,
|
||||
}
|
||||
}
|
||||
|
||||
/// Free-text body the LLM sees inside its `role:system` message
|
||||
/// for this item. Returns the variant's stored `body` verbatim.
|
||||
pub fn history_text(&self) -> String {
|
||||
@@ -311,6 +362,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_system_input_decoder_rejects_private_fields_and_unknown_presentations() {
|
||||
let valid = serde_json::json!({
|
||||
"kind": "new-feature-label",
|
||||
"body": "explicit public content",
|
||||
"presentation": { "kind": "context" },
|
||||
});
|
||||
let public: PublicSystemItem = serde_json::from_value(valid.clone()).unwrap();
|
||||
assert_eq!(public.body, "explicit public content");
|
||||
for private_field in [
|
||||
"payload",
|
||||
"prompt_provenance",
|
||||
"policy",
|
||||
"snapshot",
|
||||
"behavior_md",
|
||||
] {
|
||||
let mut malformed = valid.clone();
|
||||
malformed[private_field] = serde_json::json!({ "private": true });
|
||||
assert!(serde_json::from_value::<PublicSystemItem>(malformed).is_err());
|
||||
}
|
||||
let mut unknown = valid;
|
||||
unknown["presentation"] = serde_json::json!({ "kind": "execute_feature_payload" });
|
||||
assert!(serde_json::from_value::<PublicSystemItem>(unknown).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_subject_behavior_log_reads_old_counter_without_republishing_it() {
|
||||
let old = r#"{"kind":"subject_behavior_refresh","body":"exact archived body","behavior_revision":7}"#;
|
||||
|
||||
+34
-20
@@ -3013,7 +3013,8 @@ impl App {
|
||||
/// `[File: …]` parsing path: each kind maps directly to a typed
|
||||
/// block (`Block::Notify`, `Block::WorkerEvent`, …).
|
||||
fn apply_system_item(&mut self, value: &serde_json::Value) {
|
||||
let Ok(item) = serde_json::from_value::<session_store::SystemItem>(value.clone()) else {
|
||||
let Ok(item) = serde_json::from_value::<session_store::PublicSystemItem>(value.clone())
|
||||
else {
|
||||
// Unknown / forward-compat shape: fall back to rendering the
|
||||
// raw text payload (if any) as a generic system message.
|
||||
if let Some(text) = value.get("body").and_then(|b| b.as_str()) {
|
||||
@@ -3024,29 +3025,23 @@ impl App {
|
||||
}
|
||||
return;
|
||||
};
|
||||
match item {
|
||||
session_store::SystemItem::Notification { message, .. } => {
|
||||
let body = item.body;
|
||||
match item.presentation {
|
||||
session_store::PublicSystemPresentation::Notification { message } => {
|
||||
self.blocks.push(Block::Notify { message });
|
||||
}
|
||||
session_store::SystemItem::WorkerEvent { event, .. } => {
|
||||
session_store::PublicSystemPresentation::WorkerEvent { event } => {
|
||||
self.blocks.push(Block::WorkerEvent { event });
|
||||
}
|
||||
session_store::SystemItem::FeatureInvocationResult { body, .. }
|
||||
| session_store::SystemItem::FileAttachment { body, .. }
|
||||
| session_store::SystemItem::SkillActivation { body, .. }
|
||||
| session_store::SystemItem::JournalStartup { body, .. }
|
||||
| session_store::SystemItem::ResidentSummaryRefresh { body, .. }
|
||||
| session_store::SystemItem::SubjectBehaviorRefresh { body, .. }
|
||||
| session_store::SystemItem::Interrupt { body, .. } => {
|
||||
session_store::PublicSystemPresentation::Context => {
|
||||
self.task_store.apply_system_message_text(&body);
|
||||
self.blocks.push(Block::SystemMessage { text: body });
|
||||
}
|
||||
session_store::SystemItem::TaskReminder { body, .. } => {
|
||||
session_store::PublicSystemPresentation::TaskReminder => {
|
||||
self.task_store.apply_system_message_text(&body);
|
||||
self.blocks.push(Block::TaskReminder { text: body });
|
||||
}
|
||||
session_store::SystemItem::LegacyIgnored { .. } => {}
|
||||
session_store::SystemItem::LegacyKnowledgeIgnored { .. } => {}
|
||||
session_store::PublicSystemPresentation::Ignored => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4450,12 +4445,12 @@ mod completion_flow_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_legacy_workflow_system_item_is_ignored() {
|
||||
fn retired_system_input_is_ignored() {
|
||||
let mut app = App::new("test".into());
|
||||
let item = serde_json::json!({
|
||||
"kind": "workflow",
|
||||
"slug": "build",
|
||||
"body": "[Workflow /build]\nRun the build",
|
||||
"kind": "retired_input",
|
||||
"body": "",
|
||||
"presentation": { "kind": "ignored" },
|
||||
});
|
||||
app.handle_worker_event(Event::SystemItem {
|
||||
entry_id: None,
|
||||
@@ -4861,12 +4856,30 @@ mod completion_flow_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_label_does_not_select_system_input_presentation() {
|
||||
for label in ["journal_startup", "new-unregistered-feature"] {
|
||||
let mut app = App::new("test".into());
|
||||
app.handle_worker_event(Event::SystemItem {
|
||||
entry_id: None,
|
||||
item: serde_json::json!({
|
||||
"kind": label,
|
||||
"body": "explicit public content",
|
||||
"presentation": { "kind": "context" },
|
||||
}),
|
||||
});
|
||||
assert!(
|
||||
matches!(app.blocks.as_slice(), [Block::SystemMessage { text }] if text == "explicit public content")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_system_item_notification_appends_notify_block() {
|
||||
let mut app = App::new("test".into());
|
||||
let item = serde_json::json!({
|
||||
"kind": "notification",
|
||||
"message": "hi",
|
||||
"presentation": { "kind": "notification", "message": "hi" },
|
||||
"body": "[Notification] hi",
|
||||
});
|
||||
app.handle_worker_event(Event::SystemItem {
|
||||
@@ -4884,7 +4897,7 @@ mod completion_flow_tests {
|
||||
let mut app = App::new("test".into());
|
||||
let item = serde_json::json!({
|
||||
"kind": "worker_event",
|
||||
"event": { "kind": "turn_ended", "worker_name": "child" },
|
||||
"presentation": { "kind": "worker_event", "event": { "kind": "turn_ended", "worker_name": "child" } },
|
||||
"body": "[Notification] worker `child` finished a turn",
|
||||
});
|
||||
app.handle_worker_event(Event::SystemItem {
|
||||
@@ -5285,6 +5298,7 @@ mod completion_flow_tests {
|
||||
item: serde_json::json!({
|
||||
"kind": "task_reminder",
|
||||
"body": snapshot,
|
||||
"presentation": { "kind": "task_reminder" },
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -90,7 +90,8 @@ pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> {
|
||||
}
|
||||
LogEntry::AnnotatedSystemItem { entry, .. } => {
|
||||
let entry_id = Some(entry.metadata.entry_id.0.clone());
|
||||
let value = serde_json::to_value(&entry.item).expect("SystemItem is Serialize");
|
||||
let value = serde_json::to_value(entry.item.public_item())
|
||||
.expect("public system input is serializable");
|
||||
Some(Event::SystemItem {
|
||||
entry_id,
|
||||
item: value,
|
||||
@@ -370,6 +371,74 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_and_snapshot_system_projection_exclude_private_feature_fields() {
|
||||
let provenance = session_store::PromptRenderProvenance {
|
||||
workspace_id: Some("private-workspace".into()),
|
||||
source_digest: "private-source".into(),
|
||||
projection_digest: "private-projection".into(),
|
||||
logical_name: "private-template".into(),
|
||||
};
|
||||
let items = [
|
||||
session_store::SystemItem::SubjectBehaviorRefresh {
|
||||
body: "displayed context".into(),
|
||||
behavior_md: "private-raw-behavior".into(),
|
||||
prompt_provenance: Some(provenance.clone()),
|
||||
},
|
||||
session_store::SystemItem::JournalStartup {
|
||||
body: "displayed reference".into(),
|
||||
snapshot: serde_json::json!({ "internal": "private-snapshot" }),
|
||||
snapshot_sha256: "private-snapshot-digest".into(),
|
||||
prompt_provenance: provenance,
|
||||
},
|
||||
];
|
||||
for item in items {
|
||||
let entry = LogEntry::AnnotatedSystemItem {
|
||||
ts: 7,
|
||||
entry: session_store::LoggedSystemHistoryEntry {
|
||||
item,
|
||||
metadata: session_store::LoggedSessionHistoryMetadata::legacy_unknown(),
|
||||
},
|
||||
extensions: vec![],
|
||||
};
|
||||
let persisted: LogEntry =
|
||||
serde_json::from_slice(&serde_json::to_vec(&entry).unwrap()).unwrap();
|
||||
let snapshot = session_store::public_snapshot::project_current_session_snapshot(&[
|
||||
persisted.clone(),
|
||||
]);
|
||||
let Event::SystemItem {
|
||||
entry_id,
|
||||
item: live,
|
||||
} = live_log_entry_event(persisted).unwrap()
|
||||
else {
|
||||
panic!("expected live system input");
|
||||
};
|
||||
let snapshot_entry = &snapshot.entries[0];
|
||||
assert_eq!(entry_id.as_deref(), Some(snapshot_entry.entry_id.as_str()));
|
||||
let protocol::SessionSnapshotEntryData::SystemItem {
|
||||
data: Some(saved),
|
||||
content,
|
||||
..
|
||||
} = &snapshot_entry.data
|
||||
else {
|
||||
panic!("expected canonical public system input");
|
||||
};
|
||||
assert_eq!(&live, saved);
|
||||
let public: session_store::PublicSystemItem =
|
||||
serde_json::from_value(live.clone()).unwrap();
|
||||
assert_eq!(&public.body, content);
|
||||
assert!(matches!(
|
||||
public.presentation,
|
||||
session_store::PublicSystemPresentation::Context
|
||||
));
|
||||
let encoded = serde_json::to_string(&snapshot).unwrap();
|
||||
assert!(!encoded.contains("private-"));
|
||||
assert!(live.get("prompt_provenance").is_none());
|
||||
assert!(live.get("snapshot").is_none());
|
||||
assert!(live.get("behavior_md").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_input_log_entry_maps_to_user_message_event() {
|
||||
let segments = vec![protocol::Segment::text("hello from log")];
|
||||
|
||||
@@ -3087,7 +3087,7 @@ async fn socket_worker_event_turn_ended_while_idle_auto_starts_turn() {
|
||||
Ok(Some(Event::SystemItem { ref item, .. }))
|
||||
if item.get("kind").and_then(|k| k.as_str()) == Some("worker_event")
|
||||
&& item
|
||||
.pointer("/event/worker_name")
|
||||
.pointer("/presentation/event/worker_name")
|
||||
.and_then(|v| v.as_str()) == Some("child") =>
|
||||
{
|
||||
saw_worker_event_echo = true;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3943,6 +3943,7 @@ Deno.test("live system items reconcile with committed history by entry identity"
|
||||
const item = {
|
||||
kind: "resident_summary_refresh",
|
||||
body: "Resident memory refresh",
|
||||
presentation: { kind: "context" },
|
||||
};
|
||||
const event: Event = {
|
||||
event: "system_item",
|
||||
|
||||
Reference in New Issue
Block a user