mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
fix: redact before truncation and preserve settled log tails
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user