Skip to content

Commit 3cf96f6

Browse files
jkyberneeesclaude
andauthored
feat(web_search): SearXNG-backed web_search tool + Docker sidecar (#24)
* feat(web_search): add SearXNG-backed web_search tool + compose sidecar Gives the agent local-first web search with no cloud API or keys, matching the transcribe/vision zero-setup pattern. Tool (cmd/odek/web_search_tool.go): - Native Go tool querying a self-hosted SearXNG JSON API; returns ranked results (title/url/snippet/engine) + direct answers, capped by max_results. - Output wrapped as untrusted content (SERP snippets can carry injection). - Gated as network_egress (prompt in restricted, allow in godmode), consistent with browser/http_batch. The backend URL is fixed config, not agent-supplied, so the tool has no SSRF surface (only a query string is accepted). - Registered only when web_search.base_url is set, so plain installs without a SearXNG instance don't see a dead tool. Config (internal/config): - WebSearchConfig{BaseURL, Categories, Language, MaxResults, Timeout} threaded end-to-end (FileConfig, ResolvedConfig, resolveWebSearch, overlayFile). Wiring (cmd/odek): - builtinTools' growing positional config params (Transcription, Vision) are bundled into a toolConfig struct to stop per-tool signature churn; all ~10 call sites updated. web_search is threaded into run/serve/repl/telegram/ schedule/subagent/mcp. Docker: - New `searxng` compose sidecar (pinned image), co-starting with every profile, internal-only (no host port), with depends_on wired on each odek service. - docker/searxng/settings.yml enables the JSON API and disables the anti-bot limiter, so no Redis/Valkey is needed. SEARXNG_SECRET added to .env.example. - Both bundled configs set web_search.base_url=http://searxng:8080. Tests: hermetic httptest SearXNG mock covering happy path, max_results override vs config cap, untrusted wrapping, JSON-disabled 403, unreachable backend, policy denial, empty query, schema; resolveWebSearch defaults/merge. Full suite green under -race. Docs: README, SECURITY, CHEATSHEET, CONFIG, TELEGRAM, docker/README, DOCKER_COMPOSE_USER_GUIDE. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web_search): address code-review findings (answers, SSRF, secret, cold-start) Code review of the SearXNG integration surfaced four actionable issues; fixes: #2 (confirmed) — answers parsing emitted raw answer-object JSON. SearXNG `answers` are objects ({"answer":"...","url":...,"template":...}), not bare strings, so strings.Trim(rawJSON,'"') left the {...} blob intact. Decode the `answer` field out of each object instead (also drops the fragile Trim). The test mock used a string array, masking this — corrected to objects. #3 (defense-in-depth) — the http.Client had no CheckRedirect. A compromised or misconfigured SearXNG could 3xx the client toward an internal/metadata endpoint (SSRF). Install the same per-hop re-classification guard browser and http_batch use, capped at 10 hops. New TestWebSearch_RedirectToInternalBlocked. #1 (hardening) — the mounted settings.yml hardcoded secret_key "change-me-in-dot-env". Deeper tracing showed SEARXNG_SECRET *does* override the file at app load (searx/settings_defaults.py environ_name), so the env wiring worked — but the placeholder defeated SearXNG's built-in "secret_key is not changed" warning, which only fires for the canonical "ultrasecretkey". Switch the placeholder + the compose env fallback to "ultrasecretkey" so an unset secret is loudly flagged rather than silently weak. Comments/.env.example corrected to describe the real (app-load) override mechanism. #4 (reliability) — `depends_on: [searxng]` only waits for container start, so the first web_search after `compose up` could race SearXNG readiness. Rather than a Docker healthcheck (whose probe tooling I can't verify in the upstream image — a broken probe would deadlock odek startup), make the tool resilient: retry only on ECONNREFUSED (the precise "up but not yet listening" signal), 2 extra attempts with a 1s backoff. Timeouts / genuine-down fail fast. #5 (overlay whole-pointer replace) intentionally deferred — it is consistent with the existing Skills/Memory/Transcription/Vision merge behavior; fixing only web_search would be inconsistent. Full suite green under -race; vet/gofmt clean; compose config validates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(web_search): surface in README + add standalone SearXNG recipe Close two documentation gaps from the feature review: - README discoverability: web_search was only mentioned in the prompt-injection paragraph and absent from the feature list. Add a "🌐 Local Web Search" strategic-feature blurb linking to the CHEATSHEET config reference. - Non-Docker path: the CHEATSHEET only said "run SearXNG yourself" with no recipe. Add a concrete `docker run` snippet (reusing the repo's ready-made docker/searxng/settings.yml), the matching odek config, and the two settings that matter (search.formats json + limiter off) for bring-your-own configs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web_search): vprotocol auto-repair — tolerant answer decode + doc consistency vprotocol v5.2.7 verification of the feat/searxng-web-search branch. Three confirmed findings repaired; others refuted (redirect hop-count matches the httpBatch pattern; :ro mount is non-fatal per the SearXNG entrypoint; all settings.yml keys verified valid). F1 (robustness) — the typed `answers []struct{Answer string}` decode coupled the critical `results` parse to the answers shape: a non-string "answer" value (or any foreign answer type) would fail the whole json.Unmarshal and drop ALL results. Restore the original immunity by keeping answers as []json.RawMessage and decoding each element tolerantly — a non-conforming answer is skipped, never fatal. New TestWebSearch_HeterogeneousAnswersDoNotLoseResults locks it in. F2 (consistency) — the CHEATSHEET standalone recipe used searxng/searxng:latest while compose pins 2026.6.8-f3fab143b. Pin the recipe to the same tag. F3 (docs) — the standalone recipe's `$PWD/docker/searxng/...` volume path assumed the repo root without saying so. State "run from the repo root". Full suite green under -race; vet/gofmt clean; compose validates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 903e453 commit 3cf96f6

26 files changed

Lines changed: 852 additions & 21 deletions

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ odek is not a framework. It's a **runtime** — the smallest possible surface ar
3636
Every session can run in an isolated Docker container: no network, no host mounts beyond the working directory, zero capabilities, destroyed on exit. `odek serve` enables the sandbox **by default**; `odek run` keeps it opt-in but warns when running unsandboxed. `--ctx` files are auto-injected into the container at `/workspace/`. Full security model in [docs/SANDBOXING.md](docs/SANDBOXING.md).
3737

3838
### 🛡️ Prompt-Injection-Aware
39-
External content the agent ingests (`browser`, `read_file`, `shell`, `search_files`, `multi_grep`, `transcribe`, `vision`, `session_search`, MCP tools) is wrapped in per-call nonce'd `<untrusted_content>` boundaries so the model can distinguish data from instructions. Redirect hops are re-classified (`browser`/`http_batch`), MCP tool descriptions are scanned for injection at registration, and the MCP error channel is wrapped too. The danger classifier resists 8 known shell-evasion tricks (`$()`, backticks, `$IFS`, `command`/`exec`, `\rm`, basenamed absolute paths). Approvers engage friction mode after 3 same-class approvals in 60 s. Memory episodes from tainted sessions are stored but never auto-replayed. Skill auto-save tracks provenance and pins untrusted suggestions for explicit `odek skill promote`. `odek audit <session-id>` surfaces every ingest + per-turn divergence heuristic. Full threat model in [docs/SECURITY.md](docs/SECURITY.md).
39+
External content the agent ingests (`browser`, `read_file`, `shell`, `search_files`, `multi_grep`, `transcribe`, `vision`, `web_search`, `session_search`, MCP tools) is wrapped in per-call nonce'd `<untrusted_content>` boundaries so the model can distinguish data from instructions. Redirect hops are re-classified (`browser`/`http_batch`), MCP tool descriptions are scanned for injection at registration, and the MCP error channel is wrapped too. The danger classifier resists 8 known shell-evasion tricks (`$()`, backticks, `$IFS`, `command`/`exec`, `\rm`, basenamed absolute paths). Approvers engage friction mode after 3 same-class approvals in 60 s. Memory episodes from tainted sessions are stored but never auto-replayed. Skill auto-save tracks provenance and pins untrusted suggestions for explicit `odek skill promote`. `odek audit <session-id>` surfaces every ingest + per-turn divergence heuristic. Full threat model in [docs/SECURITY.md](docs/SECURITY.md).
4040

4141
### 🧩 Sub-Agent Delegation
4242
Parallel OS-process sub-agents via `delegate_tasks`. True isolation — each sub-agent is a fresh `odek subagent` process with its own config, tools, and termination timeout. Up to 8 concurrent workers. [docs/SUBAGENTS.md](docs/SUBAGENTS.md)
@@ -74,6 +74,9 @@ Attach files to any prompt with `--ctx` / `-c` (CLI), `@filename` inline referen
7474
### 🔍 Native Tools
7575
Built-in `read_file`, `write_file`, `search_files`, `patch`, `shell`, and `browser` tools. All gated by a unified security layer (`dangerous` config) — classify operations as `allow` / `deny` / `prompt` per risk class. No third-party dependencies. [docs/SECURITY.md](docs/SECURITY.md)
7676

77+
### 🌐 Local Web Search
78+
`web_search` queries a **self-hosted [SearXNG](https://docs.searxng.org/) metasearch instance** — no cloud search API, no keys. Returns ranked results (title, url, snippet) the agent then fetches with `browser` / `http_batch`; results are wrapped as untrusted content and gated as `network_egress`. The Docker Compose setup runs a SearXNG sidecar and enables it out of the box; standalone installs point `web_search.base_url` at any SearXNG instance. [docs/CHEATSHEET.md](docs/CHEATSHEET.md#web-search)
79+
7780
---
7881

7982
## Quick Start

cmd/odek/injection_hardening_test.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"testing"
1010

1111
"github.com/BackendStack21/odek"
12-
"github.com/BackendStack21/odek/internal/config"
1312
"github.com/BackendStack21/odek/internal/danger"
1413
)
1514

@@ -244,7 +243,7 @@ func TestBuiltinTools_SessionSearchWrappedAsUntrusted(t *testing.T) {
244243
store, cleanup := seedSessionStore(t)
245244
defer cleanup()
246245

247-
tools := builtinTools(danger.DangerousConfig{}, nil, nil, 4, "", config.TranscriptionConfig{}, config.VisionConfig{}, store)
246+
tools := builtinTools(danger.DangerousConfig{}, nil, nil, 4, "", toolConfig{}, store)
248247

249248
var ss odek.Tool
250249
for _, tool := range tools {

cmd/odek/main.go

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -779,7 +779,7 @@ func run(args []string) error {
779779

780780
// Sandbox setup
781781
var sandboxCleanup func() error
782-
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency, resolved.APIKey, resolved.Transcription, resolved.Vision, nil)
782+
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency, resolved.APIKey, toolConfig{Transcription: resolved.Transcription, Vision: resolved.Vision, WebSearch: resolved.WebSearch}, nil)
783783

784784
// MCP server tools
785785
var mcpCleanup func()
@@ -1054,7 +1054,17 @@ func setupSandbox(tools []odek.Tool, cfg sandboxConfig) (containerName string, c
10541054
return containerName, cleanup, nil
10551055
}
10561056

1057-
func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver danger.Approver, maxConcurrency int, apiKey string, tc config.TranscriptionConfig, vc config.VisionConfig, store *session.Store) []odek.Tool {
1057+
// toolConfig bundles the per-tool configuration sections threaded into
1058+
// builtinTools. Grouping them keeps the builtinTools signature stable as new
1059+
// configurable tools are added (rather than growing a positional parameter
1060+
// per tool).
1061+
type toolConfig struct {
1062+
Transcription config.TranscriptionConfig
1063+
Vision config.VisionConfig
1064+
WebSearch config.WebSearchConfig
1065+
}
1066+
1067+
func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver danger.Approver, maxConcurrency int, apiKey string, tcfg toolConfig, store *session.Store) []odek.Tool {
10581068
tools := []odek.Tool{
10591069
&shellTool{
10601070
dangerousConfig: dc,
@@ -1088,8 +1098,8 @@ func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver d
10881098
&base64Tool{dangerousConfig: dc},
10891099
&trTool{dangerousConfig: dc},
10901100
&wordCountTool{dangerousConfig: dc},
1091-
newTranscribeTool(dc, tc),
1092-
newVisionTool(dc, vc),
1101+
newTranscribeTool(dc, tcfg.Transcription),
1102+
newVisionTool(dc, tcfg.Vision),
10931103
// session_search returns content from arbitrary past sessions —
10941104
// including sessions that ingested untrusted content. That path
10951105
// otherwise bypasses the memory taint gate and the audit log, so
@@ -1098,6 +1108,13 @@ func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver d
10981108
newBrowserTool(dc),
10991109
}
11001110

1111+
// web_search is registered only when a SearXNG backend is configured —
1112+
// without a base_url there is no instance to query, so the tool would just
1113+
// confuse the agent. The Docker compose setup sets this automatically.
1114+
if tcfg.WebSearch.BaseURL != "" {
1115+
tools = append(tools, newWebSearchTool(dc, tcfg.WebSearch))
1116+
}
1117+
11011118
if sm != nil {
11021119
tools = append(tools,
11031120
&skills.SkillLoadTool{Manager: sm},
@@ -1599,7 +1616,7 @@ func continueCmd(args []string) error {
15991616
"./.odek/skills",
16001617
)
16011618
}
1602-
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency, resolved.APIKey, resolved.Transcription, resolved.Vision, store)
1619+
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency, resolved.APIKey, toolConfig{Transcription: resolved.Transcription, Vision: resolved.Vision, WebSearch: resolved.WebSearch}, store)
16031620
var sandboxCleanup func() error
16041621

16051622
// MCP server tools

cmd/odek/main_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ func TestRun_NoAPIKey(t *testing.T) {
203203
}
204204

205205
func TestBuiltinTools(t *testing.T) {
206-
tools := builtinTools(danger.DangerousConfig{}, nil, nil, 3, "", config.TranscriptionConfig{}, config.VisionConfig{}, nil)
206+
tools := builtinTools(danger.DangerousConfig{}, nil, nil, 3, "", toolConfig{}, nil)
207207
if len(tools) == 0 {
208208
t.Fatal("builtinTools() returned empty slice")
209209
}

cmd/odek/mcp.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ Flags:
7373
}
7474

7575
// Build tools
76-
toolSet := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency, resolved.APIKey, config.TranscriptionConfig{}, config.VisionConfig{}, nil)
76+
toolSet := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency, resolved.APIKey, toolConfig{WebSearch: resolved.WebSearch}, nil)
7777

7878
// MCP server tools — connect and discover before sandbox
7979
var mcpCleanup func()

cmd/odek/repl.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ func replCmd(args []string) error {
7777
"./.odek/skills",
7878
)
7979
}
80-
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency, resolved.APIKey, config.TranscriptionConfig{}, config.VisionConfig{}, nil)
80+
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency, resolved.APIKey, toolConfig{WebSearch: resolved.WebSearch}, nil)
8181
var sandboxCleanup func() error
8282

8383
// MCP server tools

cmd/odek/schedule.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -570,7 +570,7 @@ func runTaskHeadless(ctx context.Context, resolved config.ResolvedConfig, system
570570
resolved.Dangerous.NonInteractive = &deny
571571
}
572572

573-
tools := builtinTools(resolved.Dangerous, nil, nil, resolved.MaxConcurrency, resolved.APIKey, resolved.Transcription, resolved.Vision, nil)
573+
tools := builtinTools(resolved.Dangerous, nil, nil, resolved.MaxConcurrency, resolved.APIKey, toolConfig{Transcription: resolved.Transcription, Vision: resolved.Vision, WebSearch: resolved.WebSearch}, nil)
574574
tools = append(tools, mcpTools...)
575575

576576
// Capture cumulative token usage from the final iteration so the Runner

cmd/odek/serve.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ func newServeAgent(resolved config.ResolvedConfig, system string, sendFn func(v
267267
approver := newWSApprover(sendFn)
268268
resolved.Dangerous.Approver = approver
269269

270-
tools := builtinTools(resolved.Dangerous, sm, approver, resolved.MaxConcurrency, resolved.APIKey, config.TranscriptionConfig{}, config.VisionConfig{}, nil)
270+
tools := builtinTools(resolved.Dangerous, sm, approver, resolved.MaxConcurrency, resolved.APIKey, toolConfig{WebSearch: resolved.WebSearch}, nil)
271271

272272
// Find the delegateTasksTool to wire up sub-agent log streaming
273273
var subagentTool *delegateTasksTool

cmd/odek/subagent.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ func subagentCmd(args []string) error {
291291
"./.odek/skills",
292292
)
293293
}
294-
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency, resolved.APIKey, config.TranscriptionConfig{}, config.VisionConfig{}, nil)
294+
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency, resolved.APIKey, toolConfig{WebSearch: resolved.WebSearch}, nil)
295295
var sandboxCleanup func() error
296296

297297
// MCP server tools

cmd/odek/subagent_contract_test.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import (
1212
"time"
1313

1414
"github.com/BackendStack21/odek"
15-
"github.com/BackendStack21/odek/internal/config"
1615
"github.com/BackendStack21/odek/internal/danger"
1716
"github.com/BackendStack21/odek/internal/llm"
1817
)
@@ -320,7 +319,7 @@ func TestSubagent_ExitCodeThree(t *testing.T) {
320319
// ── 4. delegate_tasks Tool Schema ───────────────────────────────────
321320

322321
func TestDelegateTasksTool_Exists(t *testing.T) {
323-
tools := builtinTools(danger.DangerousConfig{}, nil, nil, 3, "", config.TranscriptionConfig{}, config.VisionConfig{}, nil)
322+
tools := builtinTools(danger.DangerousConfig{}, nil, nil, 3, "", toolConfig{}, nil)
324323
if len(tools) == 0 {
325324
t.Fatal("builtinTools() returned empty slice")
326325
}
@@ -338,7 +337,7 @@ func TestDelegateTasksTool_Exists(t *testing.T) {
338337
}
339338

340339
func TestDelegateTasksTool_HasSchema(t *testing.T) {
341-
tools := builtinTools(danger.DangerousConfig{}, nil, nil, 3, "", config.TranscriptionConfig{}, config.VisionConfig{}, nil)
340+
tools := builtinTools(danger.DangerousConfig{}, nil, nil, 3, "", toolConfig{}, nil)
342341

343342
var tool odek.Tool
344343
for _, t2 := range tools {
@@ -432,7 +431,7 @@ func TestDelegateTasksTool_HasSchema(t *testing.T) {
432431
}
433432

434433
func TestDelegateTasksTool_Description(t *testing.T) {
435-
tools := builtinTools(danger.DangerousConfig{}, nil, nil, 3, "", config.TranscriptionConfig{}, config.VisionConfig{}, nil)
434+
tools := builtinTools(danger.DangerousConfig{}, nil, nil, 3, "", toolConfig{}, nil)
436435

437436
var tool odek.Tool
438437
for _, t2 := range tools {

0 commit comments

Comments
 (0)