fix(host_api): redact credentials in model previews instead of dropping them

A deployed agent could not return the IronHub catalog. The payload was fine —
13,755 bytes, complete, well under every size bound. It never reached the model.

`result_preview_parts` built the preview, then discarded it because
`ModelResultPreview::new` refuses any content containing a credential marker
("access token", "api key", "bearer ", "password", "secret", ...). One catalog
entry's summary says "no API key" — describing the ABSENCE of one — and that
phrase refused the entire catalog. The caller's `else` arm drops the preview AND
the continuation metadata that travels with it, so the model received a bare
result reference with no preview, no total_bytes and no next_offset: unreadable
and unpageable. The logs show it then trying result_read (which returned a
reference to a reference), curl, wget, python3, an HTTP fetch that 404'd, and
five more identical searches.

Masking, not refusal, for model-visible CONTENT:

- `credential_redaction::redact_credential_text` masks credential markers (at a
  word boundary, so "Secretary" survives) and credential-shaped tokens (sk-,
  ghp_, AKIA...) with [redacted], preserving everything else.
- `ModelResultPreview::redacted` falls back to the masked text when the strict
  contract refuses; `ModelResultPreview::new` is unchanged for callers that can
  legitimately reject an operation.
- The preview path uses it, so content and its continuation metadata survive.

The security property is unchanged: credential material still never reaches the
model. Only the disposal changed — mask the span rather than discard the payload.
The existing resolution test now asserts exactly that: the secret is absent from
the preview AND the surrounding content survives.

REVISIT: the marker list is credential *vocabulary*, so prose like "no API key
required" is masked despite containing no credential.
`contains_unredacted_credential_value` already models the sharper "label followed
by a value" rule and its own doc notes that vocabulary alone is valid diagnostic
context. Narrowing this is a separate decision about a shared credential boundary
and is deliberately not made here — masking is strictly better than today's
wholesale refusal.

Co-authored-by: neo-sky <brandon.m.henderson93@gmail.com>
This commit is contained in:
serrrfirat
2026-07-29 01:35:59 +03:00
parent 6ea7897fe9
commit 4b85acce6b
3 changed files with 201 additions and 7 deletions

View File

@@ -74,6 +74,98 @@ fn contains_marker_at_word_boundary(haystack: &str, marker: &str) -> bool {
false
}
pub(crate) const CREDENTIAL_REDACTION_PLACEHOLDER: &str = "[redacted]";
/// Mask credential markers and credential-shaped tokens in `value`, preserving
/// the surrounding content.
///
/// This is the redacting counterpart to [`contains_credential_marker`] /
/// [`contains_secret_like_token`]: where those answer "should this be refused",
/// this answers "what can safely be shown". A caller holding model-visible
/// content should prefer masking the offending span over discarding the whole
/// payload — dropping it loses legitimate output and, on the preview path, the
/// continuation metadata that travels with it.
///
/// NOTE (revisit): the marker list is credential *vocabulary*, so a description
/// that merely mentions "no API key required" is masked even though it contains
/// no credential. `contains_unredacted_credential_value` already models the
/// sharper "label followed by an actual value" rule; moving this to that
/// predicate would stop masking harmless prose. Deliberately not changed here —
/// masking is strictly better than today's wholesale refusal, and narrowing the
/// rule is a separate decision about a shared credential boundary.
pub(crate) fn redact_credential_text(value: &str) -> String {
let mut redacted = String::with_capacity(value.len());
let mut rest = value;
// Markers are matched case-insensitively at a word boundary, mirroring
// `contains_credential_marker`, so "Secretary" is left alone.
'outer: while !rest.is_empty() {
let lower = rest.to_ascii_lowercase();
let mut best: Option<(usize, usize)> = None;
for marker in CREDENTIAL_MARKERS {
let mut from = 0;
while let Some(found) = lower[from..].find(marker) {
let start = from + found;
let end = start + marker.len();
if marker_at_word_boundary(&lower, start, end) {
if best.is_none_or(|(best_start, _)| start < best_start) {
best = Some((start, end));
}
break;
}
from = start + 1;
}
}
match best {
Some((start, end)) => {
redacted.push_str(&rest[..start]);
redacted.push_str(CREDENTIAL_REDACTION_PLACEHOLDER);
rest = &rest[end..];
}
None => {
redacted.push_str(rest);
break 'outer;
}
}
}
redact_secret_like_tokens(&redacted)
}
/// Replace whole tokens with a credential-shaped prefix (`sk-`, `ghp_`, `AKIA…`).
fn redact_secret_like_tokens(value: &str) -> String {
let mut out = String::with_capacity(value.len());
let mut token = String::new();
let flush = |out: &mut String, token: &mut String| {
if !token.is_empty() {
if has_secret_like_prefix(&token.to_ascii_lowercase()) {
out.push_str(CREDENTIAL_REDACTION_PLACEHOLDER);
} else {
out.push_str(token);
}
token.clear();
}
};
for character in value.chars() {
if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') {
token.push(character);
} else {
flush(&mut out, &mut token);
out.push(character);
}
}
flush(&mut out, &mut token);
out
}
fn marker_at_word_boundary(lower: &str, start: usize, end: usize) -> bool {
let before_ok = lower
.get(..start)
.is_none_or(|prefix| !prefix.ends_with(|c: char| c.is_ascii_alphanumeric()));
let after_ok = lower
.get(end..)
.is_none_or(|suffix| !suffix.starts_with(|c: char| c.is_ascii_alphanumeric()));
before_ok && after_ok
}
/// True when any whitespace/punctuation-delimited token in `lower` (already
/// lowercased) begins with a credential-shaped prefix (`sk-`, `ghp_`, `AKIA…`).
pub(crate) fn contains_secret_like_token(lower: &str) -> bool {

View File

@@ -46,6 +46,27 @@ impl ModelResultPreview {
Ok(Self(value))
}
/// Construct a preview from content that may contain credential markers or
/// credential-shaped tokens, masking those spans instead of refusing the
/// whole payload.
///
/// The refusing [`Self::new`] is right for a caller that can reject the
/// operation. It is wrong for model-visible *content*: dropping the preview
/// also drops the continuation metadata that travels with it, so the model
/// 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();
match validate_model_result_preview(&value) {
Ok(()) => Ok(Self(value)),
Err(_) => {
let redacted = crate::credential_redaction::redact_credential_text(&value);
validate_model_result_preview(&redacted)?;
Ok(Self(redacted))
}
}
}
pub fn as_str(&self) -> &str {
&self.0
}
@@ -122,6 +143,69 @@ fn validate_model_result_preview(value: &str) -> Result<(), HostApiError> {
mod tests {
use super::*;
/// The production incident: one catalog entry's summary said "no API key",
/// `ModelResultPreview::new` refused the WHOLE 13.7 KB payload, and the
/// caller dropped the preview *and* its continuation metadata — the model
/// got an opaque reference it could neither read nor page.
#[test]
fn redacted_masks_credential_vocabulary_instead_of_refusing_the_payload() {
let catalog = concat!(
r#"{"catalog_total":61,"entries":["#,
r#"{"name":"attio","description":"Attio CRM. Authenticated with a workspace API key."},"#,
r#"{"name":"bitcoin-reddit-sentiment","description":"Reads Bitcoin posts (no API key needed)."},"#,
r#"{"name":"near-rpc","description":"NEAR Protocol JSON-RPC integration."}"#,
r#"]}"#
);
// Precondition: today's strict constructor refuses all of it.
assert!(
ModelResultPreview::new(catalog).is_err(),
"fixture must reproduce the refusal that caused the incident"
);
let preview = ModelResultPreview::redacted(catalog).expect("redacted preview is built");
let text = preview.as_str();
// The payload survives: entries the user needed are still readable.
assert!(text.contains("attio"), "content must survive redaction");
assert!(text.contains("near-rpc"));
assert!(text.contains("bitcoin-reddit-sentiment"));
assert!(
text.contains(r#""catalog_total":61"#),
"totals must survive"
);
// The offending vocabulary is masked rather than taking the payload with it.
assert!(!text.to_ascii_lowercase().contains("api key"));
assert!(text.contains("[redacted]"));
}
/// Redaction must not fire on ordinary words that merely contain a marker.
#[test]
fn redacted_leaves_non_credential_words_alone() {
let text = "The Secretary reviewed the passwordless login and the bearers of the note.";
let preview = ModelResultPreview::redacted(text).expect("clean text passes through");
assert_eq!(
preview.as_str(),
text,
"word-boundary markers must not over-match"
);
}
/// A genuine credential-shaped token is masked, not preserved.
#[test]
fn redacted_masks_secret_like_tokens() {
let text = "deploy finished; token sk-abc123def456 was rotated";
let preview = ModelResultPreview::redacted(text).expect("redacted preview is built");
assert!(
!preview.as_str().contains("sk-abc123def456"),
"secret-shaped token must be masked"
);
assert!(
preview.as_str().contains("deploy finished"),
"surrounding content survives"
);
}
#[test]
fn preserves_delimiter_and_multiline_content() {
// Structured/JSON output with delimiters and newlines is legitimate

View File

@@ -572,11 +572,14 @@ fn result_preview_parts(
else {
return empty;
};
// `.ok()` intentionally degrades content that fails the credential redaction
// contract to an absent preview (a pure text-to-redacted-content conversion);
// the full output stays reachable through the result ref, and without inline
// content the continuation metadata is useless, so drop both.
let Some(preview) = ModelResultPreview::new(text).ok() else {
// Content that trips the credential contract is MASKED, not discarded.
// Dropping it used to take the continuation metadata with it (see the
// `empty` arm), leaving the model an opaque reference it could neither read
// nor page — observed in production when one catalog entry's summary said
// "no API key required" and the whole 13.7 KB catalog preview vanished.
// Masking shows the rest of the payload; the full output remains reachable
// through the result ref either way.
let Some(preview) = ModelResultPreview::redacted(text).ok() else {
return empty;
};
let referenced_result_ref = if result_ref == own_result_ref.as_str() {
@@ -1608,7 +1611,22 @@ mod tests {
// Structured content with delimiters + "Secretary" retained verbatim.
let content = "{\"office\": \"Secretary of the Treasury\", \"rows\": [1, 2, 3]}";
assert_eq!(refs_preview(content).as_deref(), Some(content));
// A genuine credential in the content drops the inline preview to None.
assert_eq!(refs_preview("token sk-ant-abc123def456").as_deref(), None);
// A genuine credential is MASKED, not allowed through — the security
// property is unchanged. What changed is the disposal: the preview used
// to be dropped entirely, which also dropped the continuation metadata
// and left the model an unreadable, unpageable reference. Masking keeps
// the surrounding output visible while the secret still never reaches
// the model.
let masked =
refs_preview("token sk-ant-abc123def456").expect("preview is masked, not dropped");
assert!(
!masked.contains("sk-ant-abc123def456"),
"credential material must never reach the model: {masked}"
);
assert!(
masked.contains("token"),
"surrounding content must survive redaction: {masked}"
);
}
}