Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/ironclaw_event_projections/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,8 @@ pub struct CapabilityActivityProjection {
pub process_id: Option<ProcessId>,
pub output_bytes: Option<u64>,
pub error_kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_summary: Option<String>,
#[serde(default)]
pub first_cursor: EventCursor,
pub last_cursor: EventCursor,
Expand Down
5 changes: 5 additions & 0 deletions crates/ironclaw_event_projections/src/runtime_projection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,9 +299,13 @@ fn apply_capability_activity_event(
| CapabilityActivityStatus::Completed
) {
activity.error_kind = None;
activity.error_summary = None;
} else if sanitized_error_kind.is_some() {
activity.error_kind = sanitized_error_kind;
}
if matches!(status, CapabilityActivityStatus::Failed) && event.error_summary.is_some() {
activity.error_summary = event.error_summary.clone();
}
Comment on lines +306 to +308

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Re-sanitize error_summary before materializing projections.

Unlike error_kind, event.error_summary is copied directly. A custom or legacy backend can provide an unsanitized RuntimeEvent, allowing sensitive text to reach CapabilityActivityProjection and live projections. Use the canonical validated/redacted summary boundary during replay and add a regression covering path/token input.

As per path instructions, projection error details must be sanitized during replay and raw secrets or host paths must not enter externally surfaced projections.

Also applies to: 329-329

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_event_projections/src/runtime_projection.rs` around lines 306
- 308, The projection currently copies event.error_summary without sanitization,
allowing paths or secrets into surfaced projections. In the status handling
within the runtime projection materialization logic, pass event.error_summary
through the canonical validated/redacted error-summary sanitizer before
assigning activity.error_summary, preserving None and only applying it for
Failed events. Add a regression test covering path and token input to verify the
projected summary contains neither raw secret nor host-path data.

Source: Path instructions

activity.last_cursor = entry.cursor;
activity.updated_at = event.timestamp;
}
Expand All @@ -322,6 +326,7 @@ fn capability_activity_projection_for_entry(
process_id: event.process_id,
output_bytes: event.output_bytes,
error_kind: event.error_kind.clone().map(sanitize_error_kind),
error_summary: event.error_summary.clone(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new field needs the same projection-boundary redaction as error_kind. A custom/directly constructed RuntimeEvent can carry an unsafe error_summary, and this clone will put raw paths/tokens into serialized projection DTOs. Please validate/drop the summary here and in the update path, and add it to the existing custom-backend redaction regression.

first_cursor: entry.cursor,
last_cursor: entry.cursor,
updated_at: event.timestamp,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2045,6 +2045,7 @@ async fn replay_projection_re_sanitizes_unsanitized_runtime_events_from_custom_b
process_id: Some(ProcessId::new()),
output_bytes: None,
error_kind: Some(raw.to_string()),
error_summary: None,
hook_id: None,
hook_point: None,
hook_trust_class: None,
Expand Down Expand Up @@ -2758,6 +2759,7 @@ async fn hook_runtime_events_project_with_sanitized_hook_metadata() {
process_id: None,
output_bytes: None,
error_kind: None,
error_summary: None,
hook_id: Some("0123456789abcdef".repeat(4)), // 64-char blake3 hex
hook_point: Some("before_capability".to_string()),
hook_trust_class: Some("installed".to_string()),
Expand All @@ -2777,6 +2779,7 @@ async fn hook_runtime_events_project_with_sanitized_hook_metadata() {
process_id: None,
output_bytes: None,
error_kind: None,
error_summary: None,
hook_id: Some("0123456789abcdef".repeat(4)),
hook_point: None,
hook_trust_class: None,
Expand All @@ -2796,6 +2799,7 @@ async fn hook_runtime_events_project_with_sanitized_hook_metadata() {
process_id: None,
output_bytes: None,
error_kind: None,
error_summary: None,
hook_id: Some("fedcba9876543210".repeat(4)),
hook_point: None,
hook_trust_class: None,
Expand Down Expand Up @@ -2874,6 +2878,7 @@ async fn non_hook_runtime_events_project_with_no_hook_metadata() {
process_id: Some(ProcessId::new()),
output_bytes: Some(42),
error_kind: None,
error_summary: None,
hook_id: None,
hook_point: None,
hook_trust_class: None,
Expand Down Expand Up @@ -2940,6 +2945,7 @@ async fn hook_runtime_events_do_not_alter_run_status_projection() {
process_id: None,
output_bytes: None,
error_kind: None,
error_summary: None,
hook_id: None,
hook_point: None,
hook_trust_class: None,
Expand All @@ -2962,6 +2968,7 @@ async fn hook_runtime_events_do_not_alter_run_status_projection() {
process_id: None,
output_bytes: None,
error_kind: None,
error_summary: None,
hook_id: Some("0123456789abcdef".repeat(4)),
hook_point: None,
hook_trust_class: None,
Expand All @@ -2981,6 +2988,7 @@ async fn hook_runtime_events_do_not_alter_run_status_projection() {
process_id: None,
output_bytes: None,
error_kind: None,
error_summary: None,
hook_id: Some("0123456789abcdef".repeat(4)),
hook_point: None,
hook_trust_class: None,
Expand Down Expand Up @@ -3043,6 +3051,7 @@ async fn hook_only_runtime_events_default_run_status_to_running() {
process_id: None,
output_bytes: None,
error_kind: None,
error_summary: None,
hook_id: Some("0123456789abcdef".repeat(4)),
hook_point: Some("before_capability".to_string()),
hook_trust_class: Some("installed".to_string()),
Expand Down
2 changes: 2 additions & 0 deletions crates/ironclaw_event_streams/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ pub enum ThreadLiveProjectionItem {
output_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
error_kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
error_summary: Option<String>,
},
WorkSummary {
id: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ fn capability_activity(scope: &ProjectionScope, cursor: u64) -> CapabilityActivi
process_id: None,
output_bytes: Some(12),
error_kind: None,
error_summary: None,
first_cursor: EventCursor::new(cursor),
last_cursor: EventCursor::new(cursor),
updated_at: chrono::Utc::now(),
Expand Down
56 changes: 56 additions & 0 deletions crates/ironclaw_events/src/runtime_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ pub struct RuntimeEvent {
pub process_id: Option<ProcessId>,
pub output_bytes: Option<u64>,
pub error_kind: Option<String>,
pub error_summary: Option<String>,
/// Hex-encoded blake3 hook identity. Present only on hook events.
pub hook_id: Option<String>,
/// Closed-vocabulary hook point label (e.g. `before_capability`). Present
Expand Down Expand Up @@ -130,6 +131,8 @@ struct RuntimeEventWire {
#[serde(default, skip_serializing_if = "Option::is_none")]
error_kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
error_summary: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
hook_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
hook_point: Option<String>,
Expand Down Expand Up @@ -164,6 +167,7 @@ impl Serialize for RuntimeEvent {
process_id: self.process_id,
output_bytes: self.output_bytes,
error_kind: self.error_kind.clone().map(sanitize_error_kind),
error_summary: self.error_summary.clone().and_then(sanitize_error_summary),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Make error_summary a genuinely redacted boundary value.

sanitize_error_summary preserves paths, bearer tokens, API-key-like values, and backend/provider text; it only strips controls and truncates. Because direct construction and with_error_summary can feed this value into event serialization, sensitive content can be persisted or emitted before downstream product validation.

Use an event-owned validated summary type or reject/collapse unsafe input at this boundary, with durable serialization tests for path and token-shaped values.

As per path instructions, events and audit records are redacted by contract; raw secrets, host paths, and unredacted content must not enter ironclaw_events.

Also applies to: 266-266, 291-291, 632-632, 678-680, 827-850

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_events/src/runtime_event.rs` at line 170, Make error_summary
a truly redacted event-boundary value across the constructors, builders, and
serialization paths including sanitize_error_summary, with_error_summary, and
the additionally referenced call sites. Replace the current control-character
stripping/truncation with an event-owned validated summary type or strict
rejection/collapse of unsafe path, token/API-key-shaped, backend/provider, and
other sensitive content before serialization. Add durable serialization tests
proving path and token-shaped inputs cannot persist or emit unredacted content.

Source: Path instructions

hook_id: self.hook_id.clone().map(sanitize_hook_id),
hook_point: self.hook_point.clone().map(sanitize_hook_label),
hook_trust_class: self.hook_trust_class.clone().map(sanitize_hook_label),
Expand Down Expand Up @@ -208,6 +212,8 @@ struct TrustedRuntimeEventWire {
#[serde(default)]
error_kind: Option<String>,
#[serde(default)]
error_summary: Option<String>,
#[serde(default)]
hook_id: Option<String>,
#[serde(default)]
hook_point: Option<String>,
Expand Down Expand Up @@ -257,6 +263,7 @@ impl RuntimeEventWire {
process_id: self.process_id,
output_bytes: self.output_bytes,
error_kind: self.error_kind.map(sanitize_error_kind),
error_summary: self.error_summary.and_then(sanitize_error_summary),
hook_id: self.hook_id.map(sanitize_hook_id),
hook_point: self.hook_point.map(sanitize_hook_label),
hook_trust_class: self.hook_trust_class.map(sanitize_hook_label),
Expand All @@ -281,6 +288,7 @@ impl TrustedRuntimeEventWire {
process_id: self.process_id,
output_bytes: self.output_bytes,
error_kind: self.error_kind.map(sanitize_error_kind),
error_summary: self.error_summary.and_then(sanitize_error_summary),
hook_id: self.hook_id.map(sanitize_hook_id),
hook_point: self.hook_point.map(sanitize_hook_label),
hook_trust_class: self.hook_trust_class.map(sanitize_hook_label),
Expand Down Expand Up @@ -320,6 +328,7 @@ impl RuntimeEvent {
process_id: None,
output_bytes: None,
error_kind: None,
error_summary: None,
hook_id: None,
hook_point: None,
hook_trust_class: None,
Expand All @@ -344,6 +353,7 @@ impl RuntimeEvent {
process_id: None,
output_bytes: None,
error_kind: None,
error_summary: None,
hook_id: None,
hook_point: None,
hook_trust_class: None,
Expand All @@ -369,6 +379,7 @@ impl RuntimeEvent {
process_id: None,
output_bytes: Some(output_bytes),
error_kind: None,
error_summary: None,
hook_id: None,
hook_point: None,
hook_trust_class: None,
Expand All @@ -394,6 +405,7 @@ impl RuntimeEvent {
process_id: None,
output_bytes: None,
error_kind: Some(sanitize_error_kind(error_kind)),
error_summary: None,
hook_id: None,
hook_point: None,
hook_trust_class: None,
Expand Down Expand Up @@ -425,6 +437,7 @@ impl RuntimeEvent {
process_id: None,
output_bytes: None,
error_kind: Some(sanitize_error_kind(error_kind)),
error_summary: None,
hook_id: None,
hook_point: None,
hook_trust_class: None,
Expand Down Expand Up @@ -464,6 +477,7 @@ impl RuntimeEvent {
process_id: None,
output_bytes: None,
error_kind: Some(sanitize_error_kind(error_kind)),
error_summary: None,
hook_id: None,
hook_point: None,
hook_trust_class: None,
Expand All @@ -487,6 +501,7 @@ impl RuntimeEvent {
process_id: None,
output_bytes: None,
error_kind: None,
error_summary: None,
hook_id: None,
hook_point: None,
hook_trust_class: None,
Expand All @@ -512,6 +527,7 @@ impl RuntimeEvent {
process_id: Some(process_id),
output_bytes: None,
error_kind: None,
error_summary: None,
hook_id: None,
hook_point: None,
hook_trust_class: None,
Expand All @@ -537,6 +553,7 @@ impl RuntimeEvent {
process_id: Some(process_id),
output_bytes: None,
error_kind: None,
error_summary: None,
hook_id: None,
hook_point: None,
hook_trust_class: None,
Expand All @@ -563,6 +580,7 @@ impl RuntimeEvent {
process_id: Some(process_id),
output_bytes: None,
error_kind: Some(sanitize_error_kind(error_kind)),
error_summary: None,
hook_id: None,
hook_point: None,
hook_trust_class: None,
Expand All @@ -588,6 +606,7 @@ impl RuntimeEvent {
process_id: Some(process_id),
output_bytes: None,
error_kind: None,
error_summary: None,
hook_id: None,
hook_point: None,
hook_trust_class: None,
Expand All @@ -610,6 +629,7 @@ impl RuntimeEvent {
process_id: payload.process_id,
output_bytes: payload.output_bytes,
error_kind: payload.error_kind,
error_summary: payload.error_summary.and_then(sanitize_error_summary),
hook_id: payload.hook_id,
hook_point: payload.hook_point,
hook_trust_class: payload.hook_trust_class,
Expand Down Expand Up @@ -655,6 +675,11 @@ impl RuntimeEvent {
}
}

pub fn with_error_summary(mut self, error_summary: Option<String>) -> Self {
self.error_summary = error_summary.and_then(sanitize_error_summary);
self
}

/// Construct a [`RuntimeEventKind::HookDispatched`] event.
///
/// `hook_id` is the hex form of the hook's blake3-derived identity. `point`
Expand All @@ -678,6 +703,7 @@ impl RuntimeEvent {
process_id: None,
output_bytes: None,
error_kind: None,
error_summary: None,
hook_id: Some(sanitize_hook_id(hook_id)),
hook_point: Some(sanitize_hook_label(point)),
hook_trust_class: Some(sanitize_hook_label(trust_class)),
Expand Down Expand Up @@ -708,6 +734,7 @@ impl RuntimeEvent {
process_id: None,
output_bytes: None,
error_kind: None,
error_summary: None,
hook_id: Some(sanitize_hook_id(hook_id)),
hook_point: None,
hook_trust_class: None,
Expand Down Expand Up @@ -735,6 +762,7 @@ impl RuntimeEvent {
process_id: None,
output_bytes: None,
error_kind: None,
error_summary: None,
hook_id: Some(sanitize_hook_id(hook_id)),
hook_point: None,
hook_trust_class: None,
Expand All @@ -754,6 +782,7 @@ struct RuntimeEventPayload {
process_id: Option<ProcessId>,
output_bytes: Option<u64>,
error_kind: Option<String>,
error_summary: Option<String>,
hook_id: Option<String>,
hook_point: Option<String>,
hook_trust_class: Option<String>,
Expand All @@ -768,6 +797,7 @@ pub const UNCLASSIFIED_ERROR_KIND: &str = "Unclassified";

const MAX_ERROR_KIND_LEN: usize = 64;
const MAX_ERROR_KIND_SEGMENT_LEN: usize = 24;
const MAX_ERROR_SUMMARY_LEN: usize = 2048;

/// Collapse any error_kind value that does not match the stable classification
/// shape into the single `Unclassified` token. This is the redaction guard
Expand All @@ -794,6 +824,32 @@ pub fn sanitize_error_kind(error_kind: impl Into<String>) -> String {
}
}

fn sanitize_error_summary(error_summary: impl Into<String>) -> Option<String> {
let value = error_summary.into();
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}

let mut sanitized = String::with_capacity(trimmed.len().min(MAX_ERROR_SUMMARY_LEN));
for ch in trimmed.chars() {
if ch == '\0' || (ch.is_control() && ch != '\n' && ch != '\t') {
continue;
}
let next_len = sanitized.len() + ch.len_utf8();
if next_len > MAX_ERROR_SUMMARY_LEN {
break;
}
sanitized.push(ch);
}

if sanitized.trim().is_empty() {
None
} else {
Some(sanitized)
}
}

fn is_safe_error_kind(value: &str) -> bool {
if value.is_empty() || value.len() > MAX_ERROR_KIND_LEN {
return false;
Expand Down
1 change: 1 addition & 0 deletions crates/ironclaw_events/tests/durable_log_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,7 @@ async fn direct_construction_serialize_path_resanitizes_error_kind() {
// Free-form raw text with a path-like fragment — exactly what the
// redaction invariant forbids in durable storage.
error_kind: Some("/Users/alice/token=secret raw error".to_string()),
error_summary: None,
hook_id: None,
hook_point: None,
hook_trust_class: None,
Expand Down
18 changes: 16 additions & 2 deletions crates/ironclaw_host_runtime/src/first_party_tools/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,11 +252,11 @@ fn shell_error(error: shell_core::ShellExecutionError) -> FirstPartyCapabilityEr
}

fn process_error(error: RuntimeProcessError) -> FirstPartyCapabilityError {
let kind = match error {
let kind = match &error {
RuntimeProcessError::Timeout(_) => RuntimeDispatchErrorKind::Resource,
RuntimeProcessError::ExecutionFailed(_) => RuntimeDispatchErrorKind::Executor,
};
FirstPartyCapabilityError::new(kind)
FirstPartyCapabilityError::with_safe_summary(kind, error.to_string())
}

#[cfg(test)]
Expand Down Expand Up @@ -303,6 +303,20 @@ mod tests {
assert!(!rendered.contains("/tmp/command.log"));
}

#[test]
fn process_error_preserves_backend_safe_summary() {
let error = process_error(RuntimeProcessError::ExecutionFailed(
"failed to spawn command: command not found".to_string(),
));

assert_eq!(error.kind(), Some(RuntimeDispatchErrorKind::Executor));
assert!(
error.safe_summary().is_some_and(
|summary| summary.contains("failed to spawn command: command not found")
)
);
}

#[test]
fn render_shell_output_reports_stream_cap() {
let rendered = render_shell_output(
Expand Down
Loading
Loading