fix(assistant): network_status strips WAN IP + Wi-Fi SSID from model context (S1)

The Network permission's Settings label promises 'no IP addresses', and the
browser-side broker honours it — but the node-side tool forwarded
network.diagnostics verbatim, so a granted Network category sent the node's
WAN IP and SSID (both location-identifying) to cloud model backends. Strip
both at the tool boundary; NAT/UPnP/Tor/DNS connectivity shape stays.
Pure helper + unit test (123 assistant tests green).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-07 06:14:15 -04:00
co-authored by Claude
parent c9de1e6c53
commit 9f579162d1
+48 -3
View File
@@ -352,8 +352,10 @@ pub fn bitcoin_status_tool() -> ToolDef {
}
/// `network_status` — category `Network`, read-only. Visibility +
/// diagnostics — no IP addresses beyond what diagnostics already surfaces
/// to the operator elsewhere in the UI.
/// diagnostics with the WAN IP and Wi-Fi SSID stripped at the tool
/// boundary (`strip_network_pii`): the permission's operator-facing label
/// promises "no IP addresses", and this result enters model context —
/// including cloud backends — so the promise must hold here too.
pub fn network_status_tool() -> ToolDef {
ToolDef {
name: "network_status",
@@ -368,6 +370,22 @@ pub fn network_status_tool() -> ToolDef {
}
}
/// Remove operator-identifying fields from a `network.diagnostics` result
/// before it enters model context. The Network permission's Settings label
/// promises "Connection status, peer count — no IP addresses or keys", and
/// the browser-side broker honours that (booleans only); a tool result that
/// carries the WAN IP or Wi-Fi SSID (both location-identifying) to a cloud
/// backend would make that consent label false. NAT type, UPnP, Tor and DNS
/// booleans stay — they are connectivity shape, not identity.
fn strip_network_pii(diagnostics: serde_json::Value) -> serde_json::Value {
let mut d = diagnostics;
if let Some(obj) = d.as_object_mut() {
obj.remove("wan_ip");
obj.remove("wifi_ssid");
}
d
}
/// `mesh_status` — category `Network`, read-only. Mesh radio status,
/// device info, peer count.
pub fn mesh_status_tool() -> ToolDef {
@@ -716,7 +734,7 @@ pub async fn dispatch(name: &str, args: &ToolArgs, handler: &RpcHandler) -> Resu
.assistant_dispatch_tool("network.diagnostics", None)
.await
.map_err(|e| format!("tool execution failed: {e}"))?;
Ok(json!({ "visibility": visibility, "diagnostics": diagnostics }))
Ok(json!({ "visibility": visibility, "diagnostics": strip_network_pii(diagnostics) }))
}
"mesh_status" => handler
.assistant_dispatch_tool("mesh.status", None)
@@ -1316,4 +1334,31 @@ mod tests {
"expected exactly 4 destructive tools: app_start, app_stop, app_restart, settings_set"
);
}
/// The `network_status` tool result enters model context — including
/// cloud backends — under a permission whose operator-facing label
/// promises "no IP addresses". The strip must remove the WAN IP and
/// Wi-Fi SSID while keeping the connectivity shape the tool
/// description advertises (NAT type, UPnP, Tor, DNS).
#[test]
fn network_status_strips_wan_ip_and_wifi_ssid() {
let stripped = strip_network_pii(json!({
"wan_ip": "203.0.113.7",
"nat_type": "full_cone",
"upnp_available": true,
"tor_connected": true,
"dns_working": true,
"recommendations": [],
"wifi_ssid": "Pretty Fly for a Wi-Fi",
}));
let obj = stripped.as_object().expect("diagnostics stays an object");
assert!(!obj.contains_key("wan_ip"), "WAN IP must not enter model context");
assert!(!obj.contains_key("wifi_ssid"), "Wi-Fi SSID must not enter model context");
for kept in ["nat_type", "upnp_available", "tor_connected", "dns_working", "recommendations"] {
assert!(obj.contains_key(kept), "connectivity field {kept} must survive");
}
// A result with neither key (e.g. offline diagnostics) passes through untouched.
let already_clean = json!({ "nat_type": null, "dns_working": false });
assert_eq!(strip_network_pii(already_clean.clone()), already_clean);
}
}