From d746fbd812c038a5f9c1cefec105f60485eaa486 Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 7 Aug 2026 07:12:48 -0400 Subject: [PATCH] fix(assistant): app_logs redacts credential shapes before model context (S5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- core/archipelago/src/assistant/tools.rs | 78 +++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/core/archipelago/src/assistant/tools.rs b/core/archipelago/src/assistant/tools.rs index e4b61bbf..ff2918a5 100644 --- a/core/archipelago/src/assistant/tools.rs +++ b/core/archipelago/src/assistant/tools.rs @@ -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 = 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 = + LazyLock::new(|| regex::Regex::new(r"\b[0-9a-fA-F]{64,}\b").unwrap()); + static B64: LazyLock = + 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::>().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)); + } }