Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3f84562
fix(loop): allow security prose in recovered context
serrrfirat Aug 10, 2026
48f72f7
test(turns): align prompt safety contract coverage
serrrfirat Aug 10, 2026
5899d4b
fix(loop): address review feedback on prompt recovery (#7434)
serrrfirat Aug 10, 2026
0f27f54
Merge remote-tracking branch 'origin/main' into codex/review-7434-178…
serrrfirat Aug 10, 2026
e71995b
fix(loop): reject filler-separated credentials (#7434)
serrrfirat Aug 10, 2026
4b53502
Merge remote-tracking branch 'origin/main' into fix-pr-7434-conflicts
serrrfirat Aug 11, 2026
6847653
fix(safety): redact model-bound secrets without rejecting turns
serrrfirat Aug 11, 2026
b5a4713
fix(safety): preserve non-secret sha256 fingerprints
serrrfirat Aug 11, 2026
484fb9b
test(safety): align channel context with gateway redaction
serrrfirat Aug 11, 2026
6eff8b9
fix(gateway): address coderabbit review — preserve redacted JSON shap…
serrrfirat Aug 11, 2026
570abd5
fix(safety): redact quoted structured credentials
serrrfirat Aug 11, 2026
940ea74
fix(safety): scan encoded tool result content
serrrfirat Aug 11, 2026
25dd109
fix(safety): redact structured credential values
serrrfirat Aug 11, 2026
269c020
fix(safety): redact character-dump credentials
serrrfirat Aug 11, 2026
7e66ee0
fix(safety): close provider-bound redaction gaps (#7509)
serrrfirat Aug 11, 2026
1d899b0
Merge remote-tracking branch 'origin/main' into codex/fix-thread-reco…
serrrfirat Aug 11, 2026
0feeac5
fix(safety): close structured redaction review gaps (#7509)
serrrfirat Aug 11, 2026
362966a
fix(safety): redact nested schema and URL fragment secrets (#7509)
serrrfirat Aug 11, 2026
63dfe88
Merge remote-tracking branch 'origin/main' into codex/fix-thread-reco…
serrrfirat Aug 12, 2026
5dfb18a
Merge remote-tracking branch 'origin/main' into codex/fix-thread-reco…
serrrfirat Aug 12, 2026
36e5873
Merge remote-tracking branch 'origin/main' into codex/fix-thread-reco…
serrrfirat Aug 12, 2026
c4ec257
test(integration): align prompt trust expectation (#7509)
serrrfirat Aug 12, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -676,12 +676,18 @@ fn reborn_contracts_crates_carry_a_checked_size_ceiling() {
// 18_974 -> 18_994 (#7076 takeover): the
// `RuntimeCredentialTarget::Basic` declaration, username validation,
// and wire-contract vocabulary; RFC 7617 composition remains in
// ironclaw_host_runtime.
// 18_994 -> 19_017 (#7525): add the typed
// ironclaw_host_runtime. 18_994 -> 19_063 (2026-08-11, #7509 plus
// #7484's merged host-context contract):
// `ModelResultPreview` now redacts credential-keyed values inside
// nested and line-numbered JSON before marker masking can destroy the
// key/value relationship; `main` adds the bounded context-window
// watermark DTO. Count read from this test's own failure message.
// 19_063 -> 19_086 (#7525, merged after #7509): add the typed
// `UnattendedQuestionEndingResponse` invalid-output reason and its
// sanitized user-facing summary. Classification and recovery remain in
// ironclaw_agent_loop; this crate owns only shared failure vocabulary.
("ironclaw_host_api", 19_017),
// sanitized user-facing summary. Classification and recovery remain
// in ironclaw_agent_loop; this crate owns only shared failure
// vocabulary. Count read from this test's own failure message.
("ironclaw_host_api", 19_086),
// 14_479 -> 13_949 (2026-08-07, #7157): downward re-capture after the
// delivery-heuristic vocabulary (stored trigger delivery targets and
// their run-profile plumbing) left this crate with the two-lane
Expand Down Expand Up @@ -753,7 +759,11 @@ fn reborn_contracts_crates_carry_a_checked_size_ceiling() {
// the batch-ordering port contract now defaults to ordered entry and
// documents the explicit opt-in required for concurrent singles.
// Scheduling and wrapper behavior remain in their owning loop crates.
("ironclaw_loop_contracts", 13_345),
// 13_345 -> 13_524 (2026-08-12, #7509 prompt recovery hardening):
// production prompt validation checks structural limits and control
// characters only; decoded Basic-auth samples remain test-only. Count
// read from this test's own failure message after merging #7416.
("ironclaw_loop_contracts", 13_524),
// Raised 15_685 -> 15_758 by #7220 (operator inspector API): the growth
// is bounded, output-only read-view descriptors. Capture, retention,
// authorization, and transport behavior remain in their owning
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -364,11 +364,6 @@ const PATH_TERM_COLLISIONS: &[(&str, &str, &str)] = &[
"model-visible Diagnostic scrub knows Slack token shapes (xox…) — \
the leak-scanner carve-out domain (#5965)",
),
(
"crates/ironclaw_loop_contracts/src/prompt_text.rs",
"github",
"credential-prefix redaction (github_pat_)",
),
(
"crates/ironclaw_auth/src/lib.rs",
"gmail",
Expand Down
71 changes: 70 additions & 1 deletion crates/contracts/ironclaw_host_api/src/model_result_preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,16 @@ impl ModelResultPreview {
/// receives an opaque reference it cannot read or page. Prefer this when the
/// alternative is showing nothing.
pub fn redacted(value: impl Into<String>) -> Result<Self, HostApiError> {
let value = value.into();
let mut value = value.into();
match validate_model_result_preview(&value) {
Ok(()) => Ok(Self(value)),
Err(_) => {
if let Ok(mut structured) = serde_json::from_str(&value)

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.

🔍 IronLoop review · Inline finding

🔴 High · Redact unstructured credential assignments before masking their labels

The added paired-value pass runs only when the whole preview parses as JSON. A raw paged result such as `password: hunter2` falls through to marker-only redaction, yielding `[redacted]: hunter2`; the later provider-bound scanner no longer has the credential label and leaves the weak value intact. Raw `result_read` previews can take this path and reach a later provider request. Redact paired values (or fail closed) before masking labels for top-level unstructured previews.

&& redact_structured_credential_values(&mut structured, 0)
{
value = serde_json::to_string(&structured)
.unwrap_or_else(|_| "[redacted]".to_string());
}
let redacted = crate::credential_redaction::redact_credential_text(&value);
validate_model_result_preview(&redacted)?;
Ok(Self(redacted))
Expand All @@ -76,6 +82,69 @@ impl ModelResultPreview {
}
}

fn redact_structured_credential_values(value: &mut serde_json::Value, depth: usize) -> bool {
if depth >= 16 {
*value = serde_json::Value::String("[redacted]".to_string());
return true;
}
match value {
serde_json::Value::Object(fields) => {
fields.iter_mut().fold(false, |changed, (key, value)| {
if crate::credential_redaction::contains_credential_marker(
&key.to_ascii_lowercase(),
) {
*value = serde_json::Value::String("[redacted]".to_string());
true
} else {
redact_structured_credential_values(value, depth + 1) || changed
}
})
}
serde_json::Value::Array(values) => {
let mut changed = false;
for value in values {
changed |= redact_structured_credential_values(value, depth + 1);
}
changed
}
serde_json::Value::String(text) => redact_embedded_structured_text(text, depth + 1),
_ => false,
}
}

fn redact_embedded_structured_text(text: &mut String, depth: usize) -> bool {
let range = if serde_json::from_str::<serde_json::Value>(text).is_ok() {
0..text.len()
} else {
let Some(start) = text.find(['{', '[']) else {
return redact_unparsed_credential_text(text);
};
let Some(end) = text.rfind(['}', ']']).map(|end| end + 1) else {
return redact_unparsed_credential_text(text);
};
start..end
};
let Ok(mut nested) = serde_json::from_str(&text[range.clone()]) else {
return redact_unparsed_credential_text(text);
};
if !redact_structured_credential_values(&mut nested, depth) {
return false;
}
let replacement = serde_json::to_string(&nested).unwrap_or_else(|_| "[redacted]".to_string());
text.replace_range(range, &replacement);
true
}

fn redact_unparsed_credential_text(text: &mut String) -> bool {
if !crate::credential_redaction::contains_unredacted_credential_value(
&text.to_ascii_lowercase(),
) {
return false;
}
*text = "[redacted]".to_string();
true
}

impl TryFrom<String> for ModelResultPreview {
type Error = HostApiError;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
use ironclaw_host_api::model_result_preview::ModelResultPreview;
use serde_json::json;

#[test]
fn redacts_nested_and_malformed_structured_credentials() {
let canary = "never-before-uploaded-canary-host-contract";
let malformed = json!({
"marker": "safe-context",
"content": format!(r#"1| {{"password":"{canary}"}}]"#),
})
.to_string();
let mut deeply_nested = json!({"password": canary});
for _ in 0..20 {
deeply_nested = json!([deeply_nested]);
}
let deep = json!({"marker": "safe-context", "content": deeply_nested.to_string()}).to_string();

for input in [malformed, deep] {
let preview = ModelResultPreview::redacted(input).expect("preview is redacted");
assert!(preview.as_str().contains("safe-context"));
assert!(!preview.as_str().contains(canary));
}
}
27 changes: 26 additions & 1 deletion crates/contracts/ironclaw_loop_contracts/src/host/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ use super::error::AgentLoopHostError;
use super::model::PromptMode;
use super::refs::{LoopInputCursorToken, origin_input_cursor_token};
use super::run_context::LoopRunContext;
use crate::SkillTrustLevel;
use crate::{
SkillTrustLevel,
prompt_text::{PromptTextSurface, validate_model_safe_text, validate_prompt_text},
};

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoopContextRequest {
Expand Down Expand Up @@ -96,6 +99,28 @@ pub struct LoopContextSnippet {
pub metadata: Option<LoopContextSnippetMetadata>,
}

impl LoopContextSnippet {
/// Construct a model-visible snippet from untrusted memory after validation.
pub fn from_untrusted_memory(
snippet_ref: String,
model_content: String,
) -> Result<Self, AgentLoopHostError> {
let model_content = validate_prompt_text(
model_content,
"memory context content",
PromptTextSurface::GenericModelContent,
)?;
let safe_summary =
validate_model_safe_text("memory context snippet".to_string(), "memory summary")?;
Ok(Self {
snippet_ref,
model_content,
safe_summary,
metadata: None,
})
}
}

#[async_trait]
pub trait LoopContextPort: Send + Sync {
async fn load_loop_context(
Expand Down
94 changes: 74 additions & 20 deletions crates/contracts/ironclaw_loop_contracts/src/instruction_bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -717,10 +717,7 @@ fn push_visible_surface(
tracing::warn!(
capability_id = descriptor.capability_id.as_str(),
field = error.field,
matched_pattern = error
.rejection
.matched_pattern()
.unwrap_or("structural prompt-text check"),
check = "structural prompt-text check",
error_safe_summary = %error.rejection.host_error().safe_summary,
"capability omitted from model prompt because its descriptor is not model-safe"
);
Expand Down Expand Up @@ -1086,7 +1083,47 @@ mod tests {
assert_eq!(bundle.materialized_messages[0].model_content, inline_body);
}

fn auth_vocabulary_surface(trust: CapabilityDescriptionTrust) -> VisibleCapabilitySurface {
#[test]
fn instruction_bundle_replays_security_context_without_blocking_thread_recovery() {
let model_content = concat!(
"The report documents an authorization flow and API key rotation.\n",
"The captured fixture was stored under /Users/alice/security/report.json.\n",
"All credential values in this report are redacted."
)
.to_string();
let context_bundle = LoopContextBundle {
memory_snippets: vec![LoopContextSnippet {
snippet_ref: "memory:security-report".to_string(),
model_content: model_content.clone(),
safe_summary: "security report".to_string(),
metadata: None,
}],
..LoopContextBundle::default()
};

let bundle = InstructionBundleBuilder::new(test_context())
.build(InstructionBundleRequest {
context_bundle,
visible_surface: None,
safety_context: None,
runtime_context: None,
inline_messages: Vec::new(),
})
.expect("ordinary security context must not make a persisted thread unrecoverable");

assert!(
bundle
.materialized_messages
.iter()
.any(|message| message.model_content == model_content),
"recovered context must remain available to the model"
);
}

fn capability_description_surface(
trust: CapabilityDescriptionTrust,
description: &str,
) -> VisibleCapabilitySurface {
VisibleCapabilitySurface {
version: crate::CapabilitySurfaceVersion::new("surface:auth-vocab").unwrap(),
descriptors: vec![CapabilityDescriptorView {
Expand All @@ -1097,7 +1134,7 @@ mod tests {
provider: None,
runtime: ironclaw_host_api::runtime::RuntimeKind::FirstParty,
safe_name: "extension_register_hosted_mcp".to_string(),
safe_description: "Choose oauth for a browser authorization-code flow.".to_string(),
safe_description: description.to_string(),
description_trust: trust,
concurrency_hint: crate::ConcurrencyHint::Exclusive,
parameters_schema: serde_json::json!({"type": "object"}),
Expand All @@ -1106,11 +1143,11 @@ mod tests {
}
}

fn surface_summary_for(trust: CapabilityDescriptionTrust) -> String {
fn surface_summary_for(trust: CapabilityDescriptionTrust, description: &str) -> String {
let bundle = InstructionBundleBuilder::new(test_context())
.build(InstructionBundleRequest {
context_bundle: LoopContextBundle::default(),
visible_surface: Some(auth_vocabulary_surface(trust)),
visible_surface: Some(capability_description_surface(trust, description)),
safety_context: None,
runtime_context: None,
inline_messages: Vec::new(),
Expand All @@ -1125,30 +1162,47 @@ mod tests {
.clone()
}

/// Host-verified descriptions legitimately mention auth flows
/// ("browser authorization-code flow"); the credential denylist must not
/// silently drop them from the prompt's capability surface.
/// Host-verified descriptions legitimately mention auth flows and remain
/// intact until the source-independent provider-bound redaction pass.
#[test]
fn verified_catalog_descriptions_with_auth_vocabulary_stay_on_the_surface() {
let summary = surface_summary_for(CapabilityDescriptionTrust::VerifiedCatalog);
let summary = surface_summary_for(
CapabilityDescriptionTrust::VerifiedCatalog,
"Choose oauth for a browser authorization-code flow.",
);
assert!(
summary.contains("builtin.extension_register_hosted_mcp"),
"verified-catalog description must stay on the prompt surface: {summary}"
);
}

/// The strict scan still governs untrusted provenance: the same
/// description from an unverified source stays off the surface.
/// Ordinary security vocabulary is data, not a credential or an authority
/// claim, even when its provenance is untrusted.
#[test]
fn untrusted_descriptions_with_auth_vocabulary_are_omitted_from_the_surface() {
let summary = surface_summary_for(CapabilityDescriptionTrust::Untrusted);
fn untrusted_descriptions_with_auth_vocabulary_stay_on_the_surface() {
let summary = surface_summary_for(
CapabilityDescriptionTrust::Untrusted,
"Choose oauth for a browser authorization-code flow.",
);
assert!(
!summary.contains("builtin.extension_register_hosted_mcp"),
"untrusted description must be omitted from the prompt surface: {summary}"
summary.contains("builtin.extension_register_hosted_mcp"),
"ordinary auth vocabulary must stay on the prompt surface: {summary}"
);
}

#[test]
fn untrusted_descriptions_with_credential_values_reach_final_redaction_boundary() {
let summary = surface_summary_for(
CapabilityDescriptionTrust::Untrusted,
"Use Authorization: Bearer ghp_secretvalue123.",
);
assert!(
summary.contains("builtin.extension_register_hosted_mcp"),
"credential content must not remove the capability from the prompt surface: {summary}"
);
assert!(
summary.contains("(none)"),
"an all-omitted surface must render the empty marker: {summary}"
summary.contains("ghp_secretvalue123"),
"the contract preserves source data for the provider-bound redaction pass: {summary}"
);
}

Expand Down
Loading
Loading