fix(assistant): app_logs redacts credential shapes before model context (S5)

The browser broker redacted log lines (password=/token=/macaroon key=value,
64+ hex, 64+ base64) while the node-side tool only untrusted-wrapped — so a
log line carrying rpcpassword=<32-hex> crossed to cloud backends below the
egress screen's threshold. Port the broker's three patterns to the tool
boundary as a pure line redactor + JSON walker; unit-tested (124 assistant
tests green).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-07 07:12:48 -04:00
co-authored by Claude
parent 3a3ceb2238
commit d746fbd812
+78
View File
@@ -386,6 +386,44 @@ fn strip_network_pii(diagnostics: serde_json::Value) -> serde_json::Value {
d
}
/// Redact credential-shaped material from one line of log text before it
/// enters model context. Mirrors the browser broker's `redactLogLine`
/// (neode-ui `contextBroker.ts`) — the two log paths must not diverge:
/// the browser redacted while the node-side tool did not, so a log line
/// carrying `rpcpassword=<32-hex>` or a 40-char token crossed to cloud
/// backends below the egress screen's 64-hex threshold (S5).
fn redact_log_line(line: &str) -> String {
use std::sync::LazyLock;
static KV: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"(?i)\b((?:password|passwd|secret|token|apikey|api_key|macaroon|rpcpassword|rpcauth)\s*[=:]\s*)\S+").unwrap()
});
static HEX64: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"\b[0-9a-fA-F]{64,}\b").unwrap());
static B64: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"\b[A-Za-z0-9+/]{64,}={0,2}\b").unwrap());
let s = KV.replace_all(line, "$1[REDACTED]");
let s = HEX64.replace_all(&s, "[REDACTED_KEY]");
B64.replace_all(&s, "[REDACTED_TOKEN]").into_owned()
}
/// Walk an arbitrary tool-result JSON and redact every string value
/// line-wise — log payloads come back in whatever shape the container RPC
/// serializes, so the redactor cannot assume one.
fn redact_secrets_in_json(value: serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::String(s) => serde_json::Value::String(
s.lines().map(redact_log_line).collect::<Vec<_>>().join("\n"),
),
serde_json::Value::Array(items) => {
serde_json::Value::Array(items.into_iter().map(redact_secrets_in_json).collect())
}
serde_json::Value::Object(map) => serde_json::Value::Object(
map.into_iter().map(|(k, v)| (k, redact_secrets_in_json(v))).collect(),
),
other => other,
}
}
/// `mesh_status` — category `Network`, read-only. Mesh radio status,
/// device info, peer count.
pub fn mesh_status_tool() -> ToolDef {
@@ -719,6 +757,7 @@ pub async fn dispatch(name: &str, args: &ToolArgs, handler: &RpcHandler) -> Resu
handler
.assistant_dispatch_tool("container-logs", Some(params))
.await
.map(redact_secrets_in_json)
.map_err(|e| format!("tool execution failed: {e}"))
}
"bitcoin_status" => handler
@@ -1361,4 +1400,43 @@ mod tests {
let already_clean = json!({ "nat_type": null, "dns_working": false });
assert_eq!(strip_network_pii(already_clean.clone()), already_clean);
}
/// S5: `app_logs` results enter model context (cloud backends included)
/// — credential-shaped material must be redacted at the tool boundary,
/// mirroring the browser broker. A 32-hex `rpcpassword` value is below
/// the egress screen's 64-hex threshold, so the key=value pattern is
/// the one that catches it.
#[test]
fn app_logs_redacts_credential_shapes() {
assert_eq!(
redact_log_line("rpcpassword=0123456789abcdef0123456789abcdef"),
"rpcpassword=[REDACTED]"
);
assert_eq!(
redact_log_line("2026-08-07 INFO token: abcdef1234567890abcdef1234567890abcdef12 ok"),
"2026-08-07 INFO token: [REDACTED] ok"
);
// 64+ hex run → redacted as a key even without a keyword
let hex = "a".repeat(64);
assert_eq!(redact_log_line(&format!("seed {hex}")), "seed [REDACTED_KEY]");
// 64+ base64 run → redacted as a token
let b64 = "Q".repeat(68);
assert_eq!(redact_log_line(&format!("macaroon blob {b64}")), "macaroon blob [REDACTED_TOKEN]");
// ...and as a key=value pair the keyword rule fires first
assert_eq!(redact_log_line(&format!("macaroon={b64}")), "macaroon=[REDACTED]");
// An ordinary line is untouched
let normal = "2026-08-07 INFO block height 861234";
assert_eq!(redact_log_line(normal), normal);
// The walker reaches nested strings whatever the payload shape
let walked = redact_secrets_in_json(json!({
"lines": ["password=hunter2", "all clear"],
"meta": { "note": "api_key=abc123" },
"count": 2
}));
let s = walked.to_string();
assert!(!s.contains("hunter2"), "nested password survived: {s}");
assert!(!s.contains("abc123"), "nested api_key survived: {s}");
assert!(s.contains("all clear"));
assert_eq!(walked["count"], json!(2));
}
}