Preserve complete semantic task prose while redacting credential values

This commit is contained in:
Dotta 2026-09-11 15:26:07 -05:00
parent 4416052336
commit 42298ba04c
1 changed files with 137 additions and 18 deletions

View File

@ -1597,6 +1597,28 @@ pub(crate) fn sanitize_semantic_tool_input(
input: &Value,
) -> Result<Value, DurableRunnerError> {
let mut sanitized = sanitize_value(input);
// Mutation prose is the user's intended work, not a diagnostic. Preserve
// ordinary references to a token in these declared text fields; credential
// syntax and high-confidence secret values are still scrubbed. All other
// fields and operations retain the strict diagnostic policy.
let prose_fields: &[&str] = match operation_id {
"create_task" => &["title", "description", "initialPlan"],
"create_project" => &["name", "description"],
"write_document" => &["title", "body", "changeSummary"],
_ => &[],
};
if let Some(sanitized_input) = sanitized.as_object_mut() {
for field in prose_fields {
if let Some(text) = input.get(*field).and_then(Value::as_str) {
sanitized_input.insert(
(*field).to_owned(),
// The tool/API schema bounds business content. A diagnostic
// preview limit must never truncate a plan or document.
Value::String(redact_sensitive_text_values_with_context(text, true)),
);
}
}
}
if !matches!(operation_id, "paperclip_finish" | "paperclip_block") {
return Ok(sanitized);
}
@ -1720,6 +1742,10 @@ pub(crate) fn redact_text(input: &str) -> String {
}
fn redact_sensitive_text_values(input: &str) -> String {
redact_sensitive_text_values_with_context(input, false)
}
fn redact_sensitive_text_values_with_context(input: &str, semantic_prose: bool) -> String {
let normalized = input.to_ascii_lowercase();
let bytes = normalized.as_bytes();
let mut ranges: Vec<(usize, usize)> = Vec::new();
@ -1893,6 +1919,7 @@ fn redact_sensitive_text_values(input: &str) -> String {
("ghu_", 20),
("ghs_", 20),
("ghr_", 20),
("github_pat_", 20),
] {
for (start, _) in normalized.match_indices(prefix) {
if start > 0 && is_name_byte(bytes[start - 1]) {
@ -2088,12 +2115,12 @@ fn redact_sensitive_text_values(input: &str) -> String {
.any(|delimiter| before.ends_with(delimiter))
};
let has_hyphenated_count_lead = token_phrase_has_lead("one-");
// An explicitly literal/exact token is a requested text value (for
// example an acceptance identifier), not a diagnostic credential pair.
// Keep this grammatical exception separate from credential syntax:
// assignments, quoted values/keys, CLI and compound keys still redact,
// as do the independent Bearer, key-prefix and JWT scanners above.
let is_literal_token_reference = key == "token"
// A bare token reference in declared mutation prose can be an output
// requirement. Auth/access/session context, explicit assignment,
// quoted credentials and CLI/compound names remain credential pairs.
// Known key/JWT/Bearer values are independently scrubbed above.
let is_semantic_token_reference = semantic_prose
&& key == "token"
&& !key_is_compound
&& whitespace_start == start + key.len()
&& separator > whitespace_start
@ -2102,9 +2129,21 @@ fn redact_sensitive_text_values(input: &str) -> String {
.iter()
.all(|value| matches!(value, b' ' | b'\t'))
&& quoted_value_start(separator).1.is_none()
&& ["literal ", "exact "]
.iter()
.any(|lead| token_phrase_has_lead(lead));
&& ![
"auth ",
"authentication ",
"authorization ",
"access ",
"refresh ",
"session ",
"api ",
"security ",
"secret ",
"credential ",
"bearer ",
]
.iter()
.any(|lead| token_phrase_has_lead(lead));
let is_benign_token_noun_phrase = key == "token"
&& (!key_is_compound || has_hyphenated_count_lead)
&& whitespace_start == start + key.len()
@ -2168,7 +2207,7 @@ fn redact_sensitive_text_values(input: &str) -> String {
let has_whitespace_separator = separator > whitespace_start
&& (key != "authorization" || key_is_compound || has_authorization_scheme)
&& !is_benign_token_noun_phrase
&& !is_literal_token_reference;
&& !is_semantic_token_reference;
if !has_assignment_separator && !has_whitespace_separator {
continue;
}
@ -3178,9 +3217,14 @@ mod tests {
}
#[test]
fn semantic_handoff_preserves_literal_acceptance_identifiers() {
let description = "The document body must contain the literal token CHAT250ed7e4dc071. No code changes needed.";
let plan = "## Plan\n- Include the literal token CHAT250ed7e4dc071 in the document body.\n- Save the output document.";
fn semantic_handoff_preserves_acceptance_identifiers_in_declared_prose() {
let description =
"The document body must contain the token CHAT250ed7e4dc071. No code changes needed.";
let plan = format!(
"## Plan\n{}\n- The token CHAT250ed7e4dc071 included somewhere in the body.\n- Save the output document.",
"Relevant task context. ".repeat(300),
);
assert!(plan.len() > 4096);
let input = json!({
"title": "Write project description",
"description": description,
@ -3193,23 +3237,69 @@ mod tests {
);
for text in [
description,
plan,
plan.as_str(),
"Must include the literal token `CHAT66e7813a4f9d1` somewhere in the text.",
"Include the exact token ACCEPTANCE-42 in the final output.",
] {
assert_eq!(redact_text(text), text);
assert_eq!(
sanitize_value(&json!({"body": text})),
sanitize_semantic_tool_input("write_document", &json!({"body": text})).unwrap(),
json!({"body": text})
);
assert_ne!(
redact_text(text),
text,
"diagnostics keep their strict policy"
);
}
let config = config(PathBuf::from("unused"));
let mut state = DurableState::new(&config);
state
.enqueue_executor_event(
&config,
"provider-create-task".to_owned(),
"semantic_tool.input".to_owned(),
EventPriority::P0,
json!({"semantic_tool": {
"schema": "paperclip.prp.semantic_tool.v1",
"schemaVersion": 1,
"phase": "input",
"operationId": "create_task",
"content": {"digest": semantic_value_digest(&input)},
"input": input,
}}),
)
.unwrap();
let transmitted = state.outbox[0]
.envelope
.pointer("/payload/payload/semantic_tool/input")
.unwrap();
assert_eq!(transmitted, &input);
assert_eq!(
state.outbox[0]
.envelope
.pointer("/payload/payload/semantic_tool/content/digest"),
Some(&json!(semantic_value_digest(transmitted))),
);
let document = format!(
"{}\nAuthorization: Bearer late-credential\nFINAL-ACCEPTANCE-42",
"Document content. ".repeat(400)
);
let safe =
sanitize_semantic_tool_input("write_document", &json!({"body": document})).unwrap();
let body = safe["body"].as_str().unwrap();
assert!(body.len() > 4096);
assert!(body.ends_with("FINAL-ACCEPTANCE-42"));
assert!(!body.contains("late-credential"));
}
#[test]
fn literal_token_prose_does_not_exempt_credential_syntax_or_shapes() {
fn semantic_prose_does_not_exempt_credential_syntax_or_shapes() {
for text in [
"auth token opaque-credential",
"the token opaque-credential",
"access token opaque-credential",
"session token opaque-credential",
"refresh token opaque-credential",
"authentication token opaque-credential",
"literal token=opaque-credential",
"literal token:opaque-credential",
"literal --token opaque-credential",
@ -3218,14 +3308,29 @@ mod tests {
"literal token \"opaque-credential\"",
] {
assert!(!redact_text(text).contains("opaque-credential"), "{text}");
let input = json!({"description": text, "initialPlan": text});
assert!(
!sanitize_semantic_tool_input("create_task", &input)
.unwrap()
.to_string()
.contains("opaque-credential"),
"{text}"
);
}
for secret in [
"sk-proj-secretvalue123456",
"ghp_secretvalue12345678901234567890",
"github_pat_secretvalue12345678901234567890",
"eyJhbGciOiJIUzI1NiJ9.c2VjcmV0LWNsYWlt.signaturesecret",
] {
let text = format!("Include the literal token {secret} in the document.");
assert!(!redact_text(&text).contains(secret), "{text}");
assert!(
!sanitize_semantic_tool_input("write_document", &json!({"body": text}))
.unwrap()
.to_string()
.contains(secret)
);
}
let input = json!({
"description": "Include the literal token ACCEPTANCE-42. Authorization: Bearer opaque-credential",
@ -3237,6 +3342,20 @@ mod tests {
.unwrap()
.contains("ACCEPTANCE-42"));
assert!(!safe.to_string().contains("opaque-credential"));
let diagnostic = json!({"description": "the token opaque-credential"});
assert!(
!sanitize_semantic_tool_input("get_task_context", &diagnostic)
.unwrap()
.to_string()
.contains("opaque-credential")
);
assert!(!sanitize_semantic_tool_input(
"create_task",
&json!({"diagnostic": "token opaque-credential"})
)
.unwrap()
.to_string()
.contains("opaque-credential"));
}
#[test]