fix: redact before truncation and preserve settled log tails

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-06 12:55:05 -05:00
1 parent 5f13596127
commit cf830d631b
3 files changed
+67 -4

No files matched your search

@@ -2052,6 +2052,9 @@ fn validate_semantic_tool_input_digest(
}
pub(crate) fn redact_text(input: &str) -> String {
// Cutting a secret first leaves an unmatchable plaintext fragment.
let redacted = redact_sensitive_text_values(input);
let input = redacted.as_str();
let (bounded, truncated) = if input.len() > 4096 {
let boundary = input
.char_indices()
@@ -2063,7 +2066,7 @@ pub(crate) fn redact_text(input: &str) -> String {
} else {
(input, false)
};
let mut redacted = redact_sensitive_text_values(bounded);
let mut redacted = bounded.to_owned();
if truncated {
redacted.push_str("…[truncated]");
}
@@ -4263,6 +4266,42 @@ mod tests {
);
}
#[test]
fn identity_redaction_precedes_multibyte_truncation() {
let prefix = "MC4CAQAwBQYDK2VwBCIEIA";
let body = format!("{prefix}{}", "A".repeat(64 - prefix.len()));
let key = format!("-----BEGIN PRIVATE KEY-----\n{body}\n-----END PRIVATE KEY-----\n");
// Give only this subprocess the key; do not race other tests' environment.
if std::env::var_os("PAPERCLIP_IDENTITY_TRUNCATION_TEST").is_none() {
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"durable::state::tests::identity_redaction_precedes_multibyte_truncation",
"--nocapture",
])
.env("PAPERCLIP_IDENTITY_TRUNCATION_TEST", "1")
.env("PAPERCLIP_AGENT_PRIVATE_KEY", &key)
.output()
.unwrap();
assert!(
output.status.success(),
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return;
}
for material in [&body, &key] {
let input = format!("{}{material}", "é".repeat(2025));
let output = redact_text(&input);
assert!(!output.contains(prefix));
assert!(output.contains("[REDACTED]"));
assert!(output.len() <= 4096);
let persisted = sanitize_value(&json!({"text": input}));
assert!(!persisted.to_string().contains(prefix));
}
}
#[test]
fn diagnostic_redaction_still_bounds_retained_text() {
let output = redact_text(&format!("token=secret-value {}", "x".repeat(4_096)));
@@ -172,6 +172,28 @@ describe("agent cryptographic identity", () => {
}
}, 60_000);
it("persists ordinary stdout and stderr endings after settling the identity buffer", async () => {
const agent = await oldAgent();
await db.update(agents).set({ adapterType: "process", adapterConfig: {
command: process.execPath, cwd: directory,
args: ["-e", "process.stdout.write('safe-'); process.stderr.write('safeM');"],
} }).where(eq(agents.id, agent.id));
const heartbeat = heartbeatService(db);
try {
const run = await heartbeat.invoke(agent.id, "on_demand", {}, "manual");
await drainHeartbeatRunsToQuiescence(db, heartbeat);
const finished = await heartbeat.getRun(run!.id);
expect(finished?.status).toBe("succeeded");
expect(finished?.stdoutExcerpt).toMatch(/safe-$/);
expect(finished?.stderrExcerpt).toMatch(/safeM$/);
const lines = (await heartbeat.readLog(run!.id)).content.trim().split("\n").map(line => JSON.parse(line));
expect(lines.filter(line => line.stream === "stdout").map(line => line.chunk).join("")).toContain("safe-");
expect(lines.filter(line => line.stream === "stderr").map(line => line.chunk).join("")).toContain("safeM");
} finally {
await drainHeartbeatRunsToQuiescence(db, heartbeat);
}
}, 60_000);
it("disaster recovery retains identity; both development seed modes omit it and mint fresh keys", async () => {
const agent = await oldAgent();
const original = await agentIdentityService(db).ensureAgentIdentity(companyId, agent.id);
+5 -3
View File
@@ -23446,9 +23446,9 @@ export function heartbeatService(
const currentUserRedactionOptions =
await getCurrentUserRedactionOptions();
const onLog = async (stream: "stdout" | "stderr", chunk: string) => {
const appendIdentityRedactedLog = async (stream: "stdout" | "stderr", chunk: string) => {
const sanitizedChunk = compactRunLogChunk(
redactCurrentUserText(identityRedactor.chunk(stream, chunk), currentUserRedactionOptions),
redactCurrentUserText(chunk, currentUserRedactionOptions),
);
if (stream === "stdout")
stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk);
@@ -23521,6 +23521,8 @@ export function heartbeatService(
},
});
};
const onLog = (stream: "stdout" | "stderr", chunk: string) =>
appendIdentityRedactedLog(stream, identityRedactor.chunk(stream, chunk));
if (runScopedMentionedSkillKeys.length > 0) {
await onLog(
"stdout",
@@ -25169,7 +25171,7 @@ export function heartbeatService(
adapterResult = identityRedactor.redact(adapterResult);
for (const stream of ["stdout", "stderr"] as const) {
const tail = identityRedactor.finish(stream);
if (tail) await onLog(stream, tail);
if (tail) await appendIdentityRedactedLog(stream, tail);
}
if (instructionSave) adapterResult.resultJson = { ...adapterResult.resultJson, instructionSave };
adapterResult = applyWorkspaceRestoreFailure(adapterResult);