diff --git a/core/archipelago/src/api/handler/mod.rs b/core/archipelago/src/api/handler/mod.rs
index 1c7c5177..48e25788 100644
--- a/core/archipelago/src/api/handler/mod.rs
+++ b/core/archipelago/src/api/handler/mod.rs
@@ -441,7 +441,7 @@ impl ApiHandler {
// from the cookie inside handle_model_proxy — it does not trust
// nginx to have gated the request already, the same "don't trust
// the front door" discipline as /lnd-connect-info below.
- (_, p) if p.starts_with("/aiui/api/claude/") || p.starts_with("/aiui/api/ollama/") => {
+ (_, p) if p.starts_with("/aiui/api/claude/") || p.starts_with("/aiui/api/ollama/") || p.starts_with("/aiui/api/web-search") => {
self.handle_model_proxy(req_with_bytes, p).await
}
diff --git a/core/archipelago/src/api/handler/model_proxy.rs b/core/archipelago/src/api/handler/model_proxy.rs
index 2db43f91..28c4cb01 100644
--- a/core/archipelago/src/api/handler/model_proxy.rs
+++ b/core/archipelago/src/api/handler/model_proxy.rs
@@ -29,6 +29,11 @@ const CLAUDE_UPSTREAM: &str = "https://api.anthropic.com/";
/// Local Ollama. No key — the session gate exists purely to stop anonymous
/// consumption of local GPU/CPU inference (T-13-11), not to protect a secret.
const OLLAMA_UPSTREAM: &str = "http://127.0.0.1:11434/";
+/// Local SearXNG. Same gate rationale as Ollama: anonymous web search
+/// through this endpoint attributes arbitrary queries to the node's IP at
+/// external engines (S4 — the old nginx location proxied straight to :8888
+/// with no session check at all).
+const SEARXNG_UPSTREAM: &str = "http://127.0.0.1:8888/";
/// Generous enough for a multi-turn tool-call round trip; `mesh/listener/
/// assist.rs`'s OLLAMA_TIMEOUT (60s) is airtime-tuned for LoRa and not
/// reusable here — this path has no such constraint (13-AI-SPEC.md Pitfall 6).
@@ -66,6 +71,8 @@ async fn route_model_proxy(
forward_claude(req, rest, data_dir).await
} else if let Some(rest) = path.strip_prefix("/aiui/api/ollama/") {
forward_ollama(req, rest).await
+ } else if path.starts_with("/aiui/api/web-search") {
+ forward_web_search(req, data_dir).await
} else {
// Unreachable given the caller's prefix match in mod.rs, but never
// fall through to an unauthenticated 200 on an unrecognized path.
@@ -107,6 +114,32 @@ fn key_not_configured() -> Response
{
.unwrap_or_else(|_| Response::new(Body::from("Claude is not configured")))
}
+/// S3: a forwarded body/query carried secret-shaped content (BIP39 words,
+/// key/token shapes, or a literal value from this node's secrets dir). The
+/// backends' egress screen never sees the forwarder path — the standalone
+/// frontend posts FULL history and images straight here — so the forwarder
+/// screens for itself. 400, plain-language, never naming what matched.
+fn blocked_secret_shaped() -> Response {
+ let body = serde_json::json!({
+ "error": "Blocked: this request contained secret-shaped content (e.g. a seed phrase, key, or token). It was not sent anywhere."
+ });
+ Response::builder()
+ .status(StatusCode::BAD_REQUEST)
+ .header("Content-Type", "application/json")
+ .body(Body::from(serde_json::to_vec(&body).unwrap_or_default()))
+ .unwrap_or_else(|_| Response::new(Body::from("Blocked: secret-shaped content")))
+}
+
+/// Screen a string about to leave the node through the forwarder against
+/// the assistant's secret-shape rules (G-B1) with this node's own secrets
+/// as the deny corpus. Returns Some(kind) — kind only, never the value —
+/// when the content must not leave.
+async fn forward_screen(text: &str, data_dir: &Path) -> Option<&'static str> {
+ let secrets =
+ crate::assistant::egress::load_known_secrets(&data_dir.join("secrets")).await;
+ crate::assistant::egress::scan_secret_shapes(text, &secrets)
+}
+
fn bad_gateway(msg: &str) -> Response {
let body = serde_json::json!({ "error": msg });
Response::builder()
@@ -128,6 +161,20 @@ async fn forward_claude(req: Request, rest: &str, data_dir: &Path) -> Resu
return Ok(key_not_configured());
}
};
+ // S3: screen the outbound body before it leaves. The forwarder also
+ // serves the STANDALONE frontend, whose requests carry full history and
+ // base64 images with no assistant loop (and no egress screen) behind
+ // them — an operator pasting a seed phrase into standalone chat would
+ // otherwise send it straight to Anthropic.
+ let (parts, body) = req.into_parts();
+ let payload = hyper::body::to_bytes(body)
+ .await
+ .map_err(|e| anyhow::anyhow!("read request payload: {e}"))?;
+ if let Some(kind) = forward_screen(&String::from_utf8_lossy(&payload), data_dir).await {
+ tracing::error!(kind, "model proxy: blocked claude forward — secret-shaped content");
+ return Ok(blocked_secret_shaped());
+ }
+ let req = Request::from_parts(parts, Body::from(payload));
forward(
req,
rest,
@@ -147,6 +194,59 @@ async fn forward_ollama(req: Request, rest: &str) -> Result
forward(req, rest, OLLAMA_UPSTREAM, "127.0.0.1:11434", &[]).await
}
+/// Build the upstream SearXNG path from the inbound query string, forcing
+/// `format=json` — the AIUI client speaks only JSON, SearXNG answers HTML
+/// unless asked, and the old nginx location passed the query through
+/// untouched, so "web search" could 200 with a page that parsed as nothing.
+/// A caller-supplied `format=` is stripped first so it cannot win.
+fn web_search_upstream_path(query: &str) -> String {
+ let kept: Vec<&str> = query
+ .split('&')
+ .filter(|pair| !pair.is_empty() && !pair.starts_with("format="))
+ .collect();
+ if kept.is_empty() {
+ "search?format=json".to_string()
+ } else {
+ format!("search?{}&format=json", kept.join("&"))
+ }
+}
+
+/// Forward an already-authenticated GET to the node's local SearXNG. GET
+/// only; 30s is plenty for a metasearch round trip and keeps a wedged
+/// upstream from pinning a daemon task. The query is screened (S3): SearXNG
+/// fans it out to upstream engines, so a pasted seed phrase would leave the
+/// node here exactly as surely as in a Claude body.
+async fn forward_web_search(req: Request, data_dir: &Path) -> Result> {
+ if req.method() != Method::GET {
+ return Ok(Response::builder()
+ .status(StatusCode::METHOD_NOT_ALLOWED)
+ .header("Content-Type", "application/json")
+ .body(Body::from("{\"error\":\"GET only\"}"))
+ .unwrap_or_else(|_| Response::new(Body::from("GET only"))));
+ }
+ let query = req.uri().query().unwrap_or_default();
+ // Minimal decode for the word-shape scan: percent-encoded spaces and
+ // `+` are how a pasted phrase's words separate inside a query string.
+ let decoded = query.replace('+', " ").replace("%20", " ");
+ if let Some(kind) = forward_screen(&decoded, data_dir).await {
+ tracing::error!(kind, "model proxy: blocked web-search query — secret-shaped content");
+ return Ok(blocked_secret_shaped());
+ }
+ let rest = web_search_upstream_path(query);
+ let client = reqwest::Client::builder()
+ .timeout(Duration::from_secs(30))
+ .build()
+ .map_err(|e| anyhow::anyhow!("client build: {e}"))?;
+ let url = format!("{}{}", SEARXNG_UPSTREAM, rest);
+ match client.get(&url).send().await {
+ Ok(resp) => stream_response(resp),
+ Err(e) => {
+ tracing::warn!("model proxy: searxng upstream failed: {}", e);
+ Ok(bad_gateway("web search upstream unavailable"))
+ }
+ }
+}
+
/// Shared forwarding core for both backends. Copies ONLY the inbound
/// `content-type`/`accept` request headers plus whatever `extra_headers`
/// the caller supplies (the Claude key + version pin) — the inbound
@@ -294,6 +394,27 @@ mod tests {
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
+ #[tokio::test]
+ async fn web_search_without_session_is_401() {
+ let store = test_store().await;
+ let data_dir = tempfile::tempdir().unwrap();
+ let req = req_with_cookie("GET", "/aiui/api/web-search?q=bitcoin", None);
+ let resp = route_model_proxy(&store, data_dir.path(), req, "/aiui/api/web-search")
+ .await
+ .unwrap();
+ assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
+ }
+
+ #[test]
+ fn web_search_query_forces_json_and_strips_format() {
+ assert_eq!(web_search_upstream_path("q=bitcoin"), "search?q=bitcoin&format=json");
+ assert_eq!(
+ web_search_upstream_path("q=bitcoin+halving&format=html"),
+ "search?q=bitcoin+halving&format=json"
+ );
+ assert_eq!(web_search_upstream_path(""), "search?format=json");
+ }
+
#[tokio::test]
async fn claude_with_invalid_session_is_401() {
let store = test_store().await;
@@ -332,6 +453,74 @@ mod tests {
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
+ fn req_with_cookie_and_body(method: &str, path: &str, cookie: Option<&str>, body: &'static str) -> Request {
+ let mut builder = Request::builder().method(method).uri(path);
+ if let Some(c) = cookie {
+ builder = builder.header("cookie", format!("session={c}"));
+ }
+ builder.body(Body::from(body)).unwrap()
+ }
+
+ async fn store_and_keyed_dir() -> (SessionStore, tempfile::TempDir) {
+ let store = test_store().await;
+ let data_dir = tempfile::tempdir().unwrap();
+ std::fs::create_dir_all(data_dir.path().join("secrets")).unwrap();
+ std::fs::write(
+ data_dir.path().join("secrets/claude-api-key"),
+ "sk-ant-test-KEYVALUE-should-never-leak",
+ )
+ .unwrap();
+ (store, data_dir)
+ }
+
+ #[tokio::test]
+ async fn claude_body_carrying_node_secret_is_blocked() {
+ let (store, data_dir) = store_and_keyed_dir().await;
+ let token = store.create().await;
+ let req = req_with_cookie_and_body(
+ "POST",
+ "/aiui/api/claude/v1/messages",
+ Some(&token),
+ r#"{"messages":[{"role":"user","content":"remember this: sk-ant-test-KEYVALUE-should-never-leak"}]}"#,
+ );
+ let resp = route_model_proxy(&store, data_dir.path(), req, "/aiui/api/claude/v1/messages")
+ .await
+ .unwrap();
+ assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
+ }
+
+ #[tokio::test]
+ async fn claude_body_carrying_bip39_is_blocked() {
+ let (store, data_dir) = store_and_keyed_dir().await;
+ let token = store.create().await;
+ // The canonical checksum-valid test mnemonic.
+ let req = req_with_cookie_and_body(
+ "POST",
+ "/aiui/api/claude/v1/messages",
+ Some(&token),
+ r#"{"messages":[{"role":"user","content":"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"}]}"#,
+ );
+ let resp = route_model_proxy(&store, data_dir.path(), req, "/aiui/api/claude/v1/messages")
+ .await
+ .unwrap();
+ assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
+ }
+
+ #[tokio::test]
+ async fn web_search_query_carrying_bip39_is_blocked() {
+ let (store, data_dir) = store_and_keyed_dir().await;
+ let token = store.create().await;
+ let req = req_with_cookie(
+ "GET",
+ "/aiui/api/web-search?q=abandon+abandon+abandon+abandon+abandon+abandon+abandon+abandon+abandon+abandon+abandon+about",
+ Some(&token),
+ );
+ let resp = route_model_proxy(&store, data_dir.path(), req, "/aiui/api/web-search")
+ .await
+ .unwrap();
+ assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
+ }
+
/// Minimal local capture server (hyper 0.14, same crate `server.rs`
/// already builds on) standing in for an upstream — records the headers
/// of the one request it receives so the test can assert what actually
diff --git a/core/archipelago/src/assistant/egress.rs b/core/archipelago/src/assistant/egress.rs
index b8170ba7..24137b4b 100644
--- a/core/archipelago/src/assistant/egress.rs
+++ b/core/archipelago/src/assistant/egress.rs
@@ -233,27 +233,43 @@ fn has_bip39_length_word_run(body: &str) -> bool {
// token may carry punctuation (a JSON quote closing the string) — take
// its leading alphabetic segment, and treat anything alphanumeric AFTER
// that segment as the end of the phrase.
+ //
+ // A token can also carry SEVERAL words glued together by JSON
+ // punctuation — `{"content":"abandon` has the phrase's first word glued
+ // to its key. Scanning only the leading word DROPS that first word, and
+ // an exactly-12-word seed pasted as a bare string value then yields an
+ // 11-member run that neither checksum-parses nor reaches the implausible
+ // -run backstop — the canonical leak walked straight through. So after a
+ // NON-member word (a key can never be seed material) keep scanning the
+ // token's remainder; after a member word whose rest carries
+ // alphanumerics the phrase has ended (clear, then keep scanning for a
+ // new run). Member chains across values remain possible exactly as
+ // before only when the boundary word is itself a member — the
+ // checksum window is what keeps that precise, as it did for 13-10.
for token in body.split_whitespace() {
- let lead = token.trim_start_matches(|c: char| !c.is_ascii_alphabetic());
- let word_len = lead
- .find(|c: char| !c.is_ascii_alphabetic())
- .unwrap_or(lead.len());
- let (word, rest) = lead.split_at(word_len);
- let is_member = !word.is_empty()
- && word.chars().all(|c| c.is_ascii_lowercase())
- && wordlist.binary_search(&word).is_ok();
+ let mut seg = token.trim_start_matches(|c: char| !c.is_ascii_alphabetic());
+ while !seg.is_empty() {
+ let word_len = seg
+ .find(|c: char| !c.is_ascii_alphabetic())
+ .unwrap_or(seg.len());
+ let (word, rest) = seg.split_at(word_len);
+ let is_member = !word.is_empty()
+ && word.chars().all(|c| c.is_ascii_lowercase())
+ && wordlist.binary_search(&word).is_ok();
- if is_member {
- run.push(word);
- if run_is_seed_material(&run) {
- return true;
- }
- // `accident"` ends a string — the phrase stopped there.
- if rest.chars().any(|c| c.is_ascii_alphanumeric()) {
+ if is_member {
+ run.push(word);
+ if run_is_seed_material(&run) {
+ return true;
+ }
+ // `accident"` ends a string — the phrase stopped there.
+ if rest.chars().any(|c| c.is_ascii_alphanumeric()) {
+ run.clear();
+ }
+ } else {
run.clear();
}
- } else {
- run.clear();
+ seg = rest.trim_start_matches(|c: char| !c.is_ascii_alphabetic());
}
}
false
diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf
index 142e0010..e699bb0a 100644
--- a/image-recipe/configs/nginx-archipelago.conf
+++ b/image-recipe/configs/nginx-archipelago.conf
@@ -126,12 +126,16 @@ server {
proxy_send_timeout 120s;
}
- # AIUI web search proxy — SearXNG on port 8888
+ # AIUI web search — session-gated through the daemon's model proxy
+ # (S4; it re-derives auth from the session cookie and forces JSON
+ # upstream). Never proxy straight to SearXNG: that left an open search
+ # relay attributing arbitrary queries to this node's IP.
location /aiui/api/web-search {
- proxy_pass http://127.0.0.1:8888/search;
+ proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header Cookie $http_cookie;
proxy_connect_timeout 30s;
proxy_read_timeout 30s;
error_page 502 503 =503 @searxng_unavailable;
@@ -1049,6 +1053,18 @@ server {
proxy_read_timeout 300s;
proxy_send_timeout 120s;
}
+ # Session-gated web search (S4) — same rationale and shape as the HTTP
+ # server block above; both blocks must carry it (T-13-15).
+ location /aiui/api/web-search {
+ proxy_pass http://127.0.0.1:5678;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header Cookie $http_cookie;
+ proxy_connect_timeout 30s;
+ proxy_read_timeout 30s;
+ error_page 502 503 =503 @searxng_unavailable;
+ }
# Icons, favicon, manifest — always revalidate (no heuristic caching)
location ~* ^/(favicon\.ico|manifest\.webmanifest|assets/icon/) {