mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-27 12:40:51 +08:00
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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user