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
Original file line number Diff line number Diff line change
Expand Up @@ -727,7 +727,12 @@ fn reborn_contracts_crates_carry_a_checked_size_ceiling() {
// union — the #7147 parallel-baseline lesson applied. Framing/render
// vocabulary only — scope filtering stays in the memory providers
// and host runtime. Count read from this test's own failure message.
("ironclaw_loop_contracts", 13_306),
// 13_306 -> 13_399 (2026-08-10, prompt recovery hardening): the
// contract-owned prompt validator now recognizes decoded Basic auth
// credentials, and `LoopContextSnippet::from_untrusted_memory` keeps
// memory admission on that same credential-value policy. Runtime
// retrieval, sanitization, and budgeting remain in host_runtime.
("ironclaw_loop_contracts", 13_399),
// 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
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 @@ -88,6 +91,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 {
Comment thread
serrrfirat marked this conversation as resolved.
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