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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user