Skip to content

web_search: add domains allowlist + score field#209

Merged
gnanam1990 merged 2 commits into
mainfrom
feat/websearch-enrich
Jun 15, 2026
Merged

web_search: add domains allowlist + score field#209
gnanam1990 merged 2 commits into
mainfrom
feat/websearch-enrich

Conversation

@Vasanthdev2004

@Vasanthdev2004 Vasanthdev2004 commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Enriches the existing web_search tool (merged in #175) with two model-facing additions: a domains allowlist (the zero-only prompt-injection defense) and provider score surfacing. See commit body for details.

Summary by CodeRabbit

  • New Features

    • Web search now supports an optional domain allowlist to restrict results to specific hosts.
    • Search results can now include and display provider relevance scores when available.
  • Bug Fixes

    • Improved robustness when normalizing and matching domain allowlist entries and handling malformed result URLs.
    • Added clearer error handling when the domain allowlist eliminates all results or is provided in an invalid format.
  • Tests

    • Expanded automated coverage for domain allowlisting, score parsing/rendering, host normalization, and allowlist/port handling.

The web_search tool now accepts an optional 'domains' array that filters
results to the listed hostnames before they reach the model. This is the
zero-only prompt-injection defense that none of the major CLIs ship: a
model that the user has constrained to a known-good set of domains cannot
be tricked into fetching arbitrary pages from search results.

Also surfaces the provider's optional 'score' field on each result row
when present (zero/absent scores are not rendered, to keep the common
case tidy). The 'score' field is parsed from the generic JSON shape
already understood by the httpSearchBackend.

Filters are applied after the search returns, before the empty-result
short-circuit, so a non-empty allowlist that ate every hit surfaces as a
clear 'no results matched domains' error rather than a misleading
'no results' message.

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: 037317f4910e
Changed files (2): internal/tools/web_search.go, internal/tools/web_search_test.go

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

web_search gains an optional domains allowlist parameter that filters search results by canonicalized hostname, including scheme and www. normalization with fail-closed URL parsing. Search results also gain an optional Score float64 field parsed from provider JSON responses and rendered as a suffix when non-zero.

Changes

web_search: domains allowlist and score field

Layer / File(s) Summary
Data contract and tool schema
internal/tools/web_search.go, internal/tools/web_search_test.go
searchResult gains a Score float64 field for optional provider relevance. Tool schema gains an optional domains array-of-strings parameter. A schema test asserts the parameter shape and description.
Domain canonicalization and filtering helpers
internal/tools/web_search.go, internal/tools/web_search_test.go
parseDomains reads the argument tolerating both []any and []string. normalizeDomains trims, strips schemes and www., lowercases, and deduplicates entries. filterByDomains extracts result URL hostnames (fail-closed on unparseable URLs) and returns an error when the allowlist eliminates all results. Unit tests cover each helper, port handling, and canonicalization directly.
Execution flow: argument parsing, filtering, score parsing
internal/tools/web_search.go
parseDomains is called during Run argument validation. filterByDomains is applied after backend search completes and before the "no results" short-circuit. parseSearchResults and result construction populate Score from provider JSON, defaulting missing scores to zero.
Rendering and integration tests
internal/tools/web_search.go, internal/tools/web_search_test.go
formatSearchResults appends — score XX.XX when Score > 0. Integration tests verify domains filtering end-to-end (type tolerance, normalization, www. stripping, malformed URL resilience, all-filtered error), score rendering/parsing, fail-closed behavior when the allowlist is entirely invalid, and port-handling contracts (explicit ports in allowlist entries still match hostnames, canonicalization strips ports).

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 34.62% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the two main additions: domains allowlist filtering and score field support for web_search results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/websearch-enrich

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
internal/tools/web_search_test.go (1)

365-374: ⚡ Quick win

Add regression tests for invalid-only domains and host:port normalization.

Current tests don’t cover the case where domains is provided but normalizes to empty, or where allowlist input includes host:port. Add both so these security/correctness contracts stay locked in.

Also applies to: 418-471

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tools/web_search_test.go` around lines 365 - 374, Add two regression
test functions to cover critical security and correctness contracts: first, add
a test near TestWebSearchDomainsFilterRejectsBadArgType (around 365-374) that
verifies the tool properly handles the case where domains are provided but
normalize to empty after validation, and second, add another test around the
418-471 range that verifies the allowlist properly normalizes and handles
host:port formatted inputs. These tests ensure that edge cases in domain
filtering and host:port normalization are locked in and don't regress in future
changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/tools/web_search.go`:
- Around line 395-401: The host canonicalization uses parsed.Host which can
include the port (e.g., "react.dev:443"), while another location extracts hosts
using Hostname() which excludes the port. This inconsistency causes allowlist
entries with ports to fail matching. Replace the call to parsed.Host with
parsed.Hostname() in the canonicalization logic to ensure both sides
consistently compare the hostname without the port component, allowing allowlist
entries like "https://react.dev:443/path" to match against
"https://react.dev/...".
- Around line 134-137: The domains filtering is bypassed when all domain entries
are invalid and normalized to empty, causing unrestricted results to be returned
instead of failing safely. Modify the logic to detect when domains are
originally provided by the caller but normalize to empty (in the normalization
logic that drops invalid entries), and return an error in this case. The
stringListArgWebSearch call at line 134-137 retrieves the domains argument,
which is then normalized (dropping invalid entries) before being checked for
filtering at line 153-159. Add a validation check that errors if domains were
provided but became empty after normalization, ensuring the fail-closed
guarantee is maintained.

---

Nitpick comments:
In `@internal/tools/web_search_test.go`:
- Around line 365-374: Add two regression test functions to cover critical
security and correctness contracts: first, add a test near
TestWebSearchDomainsFilterRejectsBadArgType (around 365-374) that verifies the
tool properly handles the case where domains are provided but normalize to empty
after validation, and second, add another test around the 418-471 range that
verifies the allowlist properly normalizes and handles host:port formatted
inputs. These tests ensure that edge cases in domain filtering and host:port
normalization are locked in and don't regress in future changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: bf9d0f85-116e-4d75-aa80-72b3ab65f6ec

📥 Commits

Reviewing files that changed from the base of the PR and between 9ddef0e and 88d427e.

📒 Files selected for processing (2)
  • internal/tools/web_search.go
  • internal/tools/web_search_test.go

Comment thread internal/tools/web_search.go Outdated
Comment thread internal/tools/web_search.go
…calize

CodeRabbit review fixes:

1. canonicalizeWebSearchHost: switch parsed.Host -> parsed.Hostname() so
   an allowlist entry like 'https://react.dev:443/path' normalizes the same
   as 'react.dev'. The previous code left ':443' in the string, which
   silently broke every match against a result URL on the default port.

2. stringListArgWebSearch: return a 'provided' bool so Run can distinguish
   'no allowlist' from 'allowlist that normalized to empty'. The previous
   code returned len(domains)==0 in both cases, and Run fell through to
   the unfiltered path — defeating the prompt-injection defense the
   parameter exists to provide. Now we error fail-closed.

3. Tests:
   - TestWebSearchDomainsFilterRejectsAllInvalidInputs: locks in the
     fail-closed contract for all-whitespace, all-empty, embedded-space,
     and mixed-invalid allowlists.
   - TestWebSearchDomainsFilterAcceptsHostPort: locks in the
     Hostname()-strips-port fix for the scheme'd case and asserts that
     scheme-less 'host:port' is rejected.
   - TestCanonicalizeWebSearchHostStripsPort: the direct unit-level
     regression test for canonicalizeWebSearchHost.

Co-Authored-By: Claude <noreply@anthropic.com>
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Fixed all three issues in 037317f:

Issue 1 (port stripping): canonicalizeWebSearchHost now uses parsed.Hostname() instead of parsed.Host, so allowlist entries like https://react.dev:443/path normalize to react.dev and match results on the default port. Test: TestCanonicalizeWebSearchHostStripsPort.

Issue 2 (fail-closed on all-invalid allowlist): stringListArgWebSearch now returns a provided bool so Run can distinguish no allowlist from allowlist that normalized to empty. The latter now errors with 'domains' argument was provided but contained no valid hostnames before the backend is called, preserving the prompt-injection defense. Test: TestWebSearchDomainsFilterRejectsAllInvalidInputs (4 sub-cases: all-whitespace, all-empty, embedded-space, mixed-invalid). All assert the backend is NOT called.

Issue 3 (host:port regression test): TestWebSearchDomainsFilterAcceptsHostPort covers the scheme'd case (https://react.dev:443/path -> react.dev) AND the scheme-less case (react.dev:9999 is rejected as not-a-valid-hostname). The earlier 9999 case I had was wrong — scheme-less host:port is not a valid hostname and the tool correctly rejects it.

All 23 web_search tests pass. go vet, gofmt, go build ./... clean. Full internal/tools suite green. Same pre-existing TestRunExecUsesProjectConfigAndOpenAICompatibleProvider failure as before (verified unrelated on clean main).

Re-running the review, please.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
internal/tools/web_search.go (1)

361-361: 💤 Low value

Ineffectual assignment to provided (static analysis).

The assignment provided = true is never read because every return path after this point explicitly returns true as the second value. You could either remove the assignment or use named return values to simplify the returns.

Current code is correct but the linter flags it as dead. Consider:

Option: remove the redundant assignment
-	provided = true
 	raw, ok := value.([]any)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tools/web_search.go` at line 361, The variable `provided` is
assigned the value `true` in the web_search.go file but this assignment is never
actually used because every return statement after this point explicitly returns
`true` as the second return value. Remove the redundant assignment `provided =
true` statement since it has no effect on the function's behavior - the explicit
`true` values in the return statements already handle what needs to be returned.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/tools/web_search.go`:
- Line 361: The variable `provided` is assigned the value `true` in the
web_search.go file but this assignment is never actually used because every
return statement after this point explicitly returns `true` as the second return
value. Remove the redundant assignment `provided = true` statement since it has
no effect on the function's behavior - the explicit `true` values in the return
statements already handle what needs to be returned.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4d99cbf3-4717-49d8-b6fc-03c51ee564fa

📥 Commits

Reviewing files that changed from the base of the PR and between 88d427e and 037317f.

📒 Files selected for processing (2)
  • internal/tools/web_search.go
  • internal/tools/web_search_test.go

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — web_search domains allowlist + score

Nice, security-minded addition. The domains allowlist is a genuine prompt-injection mitigation and the fail-closed handling is exactly right:

  • domains provided but all entries normalize away → hard error (not a silent unconstrained search);
  • allowlist eats every result → a clear "no results matched domains" error instead of a misleading "no results";
  • unparseable result URLs are dropped when an allowlist is set.

Host canonicalization (scheme/port/path/www. stripping, lowercase, dedup, []any and []string tolerance) is thorough, and the tests cover the meaningful paths. score is cleanly optional (zero = absent). LGTM.

Non-blocking notes

  1. Exact-host vs subdomain semantics. filterWebSearchByDomains matches the exact host (after www. strip), so domains:["react.dev"] keeps react.dev/www.react.dev but not blog.react.dev. For a security allowlist that's the safer default — worth a one-line doc note in the param description so callers aren't surprised (or consider opt-in suffix matching later).
  2. Merge overlap with #208. Both PRs edit the convert closure in parseSearchResults. #208 (staticcheck nits) rewrote that struct literal as searchResult(item) (S1016); this PR rewrites it as an explicit literal with Score. Whichever lands second needs a trivial conflict fixup — since rawResult and searchResult will share field order incl. Score, searchResult(item) still compiles, so collapsing to that post-merge is cleanest. Just flagging so it isn't a surprise.

Gate-wise the change is stdlib-only and looks consistent with the existing tool conventions.

@gnanam1990
gnanam1990 merged commit de5d402 into main Jun 15, 2026
7 checks passed
gnanam1990 added a commit that referenced this pull request Jun 16, 2026
…active search (#218)

* web_search: fix #209 follow-ups (tolerant score, render gate, redaction, FQDN)

Verified-and-fixed defects from a review of #209:
  - Tolerant score decode (lenientScore): a stringified/odd/null "score" no longer
    makes json.Unmarshal discard the entire response; it degrades to absent.
  - Score render gate rounds first (>= 0.01) so tiny positives and negatives no
    longer print a noisy "score 0.00".
  - Domain-filter error now goes through redactWebSearchText, matching the other
    error paths.
  - Trailing-dot FQDN symmetry: "react.dev." and "react.dev" normalize the same
    on both the allowlist and result sides.

Left as-is (intended, tested): scheme-less host:port allowlist entries. Includes
mixed-type domains-array tests.

* web_search: only register the built-in tool when a backend is configured

When no search backend is set (ZERO_WEBSEARCH_BASE_URL / ZERO_WEBSEARCH_BACKEND),
CoreNetworkTools no longer offers the built-in web_search. Previously it was always
registered, so a model with an MCP search tool (e.g. Exa) would burn several calls
+ high-risk permission prompts on the built-in tool — which can only return
"no backend configured" — before falling back to the working MCP tool.

Now: no backend -> only web_fetch (+ any MCP search tool) is offered, so the model
uses the MCP search directly. A configured backend behaves exactly as before.

Tests: registry/web_search/specialist tests that asserted web_search is always in
CoreTools now configure a backend; added a test that it is omitted when none is set.

(Pre-existing, unrelated: internal/cli doctor/exec tests fail in the sandbox on
origin/main too — network/provider-dependent, not touched here.)

* tui: copy selected transcript text to the native OS clipboard

Selecting transcript text auto-copies on mouse release, but the copy went only
through OSC52 (ansi.SetSystemClipboard), which macOS Terminal.app doesn't support
at all and iTerm2 / some Windows Terminal builds have off by default — so the
selection silently never reached the clipboard.

Copy via the native clipboard (pbcopy / clip.exe / xclip) first, which works on
local terminals regardless of OSC52, and fall back to OSC52 for remote/SSH
sessions where no local clipboard utility is reachable. atotto/clipboard was
already in the module graph (indirect); promoted to a direct import — no new
dependency.

Gates: gofmt/vet/build(host+linux+windows)/tui test -race/staticcheck all pass.

* tui: collapse long tool output by default with click-to-expand

Long, noisy tool output (web-search / MCP / read dumps) flooded the transcript.
Now a tool result whose output exceeds the live body cap renders collapsed by
default — head + a "▸ N lines — click to expand" footer — and you click the card
(while it is live) to expand (▾) / collapse. Mirrors the existing reasoning-row
collapse, so collapsed rows also flush to scrollback clean.

Scoped to avoid hiding what matters:
  - diff tools (edit_file / apply_patch / write_file) never collapse — their body
    must stay reviewable (matches the deeper flush cap intent);
  - the uncapped detailed transcript view (Ctrl+O, bodyCap==0) never collapses —
    it still shows full output;
  - short output (<= cardBodyMaxLines) stays inline as before.

Reuses transcriptRow.expanded + the row toggle (toggleReasoningRow generalized to
toggleTranscriptRow); the card head is a clickable toggle line. Running tool
cards (spinner) are unchanged.

Tests: collapse/expand, short-output inline, diff tools never collapse, toggle,
and the head exposes a click toggle; existing diff + detailed-view tests updated.
Gates: gofmt/vet/build(host+linux+windows)/tui -race/staticcheck all clean.

* cli: fall back to a usable saved provider instead of re-running onboarding

If the active provider lacked a credential (e.g. it relies on an env var that
isn't set, or an OAuth login that isn't stored), the TUI forced first-run
onboarding on every launch — even when other saved providers were fully
configured. Each onboarding then appended yet another provider.

Now, when the active provider isn't usable, fall back to the first usable saved
provider (prefer a remote keyed one over a local endpoint) for the session, and
skip onboarding. Onboarding only runs when no configured provider is usable — a
genuinely fresh setup. Saved logins persist; switch the active provider with
`zero provider use <name>`.

Tests: firstUsableProvider prefers remote keyed, falls back to local, accepts a
keyless local proxy, and reports none-usable. Gates: gofmt/vet/build
(host+linux+windows)/staticcheck clean; the only failing cli tests
(doctor/exec, network-dependent) are pre-existing on origin/main.

* tui: hold the scroll position while output streams in

The chat viewport scroll offset is measured from the bottom, so when the
transcript grew (a streaming turn) the window followed the new bottom and yanked
the user off whatever they had scrolled up to read.

syncChatScroll now pins the viewport: while the user is scrolled up, it bumps the
offset by however many lines the body grew, so the absolute view holds where they
are reading; at the bottom (offset 0) it follows the tail as before. Hooked into
the single Update funnel; only the scrolled-up path renders the body, so the
common (at-bottom) case stays cheap.

Tests: pins by the growth delta while scrolled up; resets/follows at the bottom.
Gates: gofmt/vet/build(host+linux+windows)/tui -race/staticcheck clean.

* agent: search the web proactively instead of answering "I don't know"

When a web search/fetch tool is available and the user asks about something
external the model is unsure of or that may be current (a product, library,
company, price, version, recent event), the system prompt now tells it to search
and read results before answering, rather than replying that it doesn't know
without checking. Addresses the model saying "I don't know about that" until the
user explicitly asked it to search.

Tests: prompt includes the new guidance ("search the web and read"). Existing
prompt assertions use Contains, so the addition is non-breaking.

* tui: show clean labels for MCP tool cards (mcp_exa_web_search_exa → web search)

* agent: sharpen web-search guidance (disambiguate, refine, scale)

Strengthen the search rule so the model searches before answering about an
external entity/product/library/model/version/release it doesn't recognize,
doesn't assume the most common meaning of an ambiguous name (disambiguates by the
conversation's domain), and refines + re-searches when results look off-topic —
rather than confidently answering the wrong interpretation (e.g. reading "Fable 5"
as a video game instead of an AI model). Also: don't web-search timeless facts or
workspace-answerable questions (use the file tools), keep queries short, and scale
the number of searches to the question.

Tests: prompt includes the new guidance ("search the web before answering",
"do not recognize"). Existing assertions use Contains, so non-breaking.

* test: gofmt setup_fallback_test.go (comment alignment)

* Address review feedback on web_search + chat UX

Resolve CodeRabbit review comments on this PR:

- firstUsableProvider: skip a profile whose CatalogID no longer resolves
  *and* that has no explicit BaseURL (no endpoint → unusable). A stale
  CatalogID with a BaseURL still works as a custom endpoint and is kept.
- providerProfileIsLocal: classify by parsed URL hostname (exact
  localhost / 127.0.0.1 / ::1) instead of substring matching, so hosts
  like "notlocalhost.com" are no longer misread as local.
- lenientScore: reject non-finite scores (ParseFloat accepts "NaN"/"Inf")
  so the documented score filter actually holds and the renderer never
  emits a non-finite value.
- syncChatScroll: shift the pinned from-bottom offset by the signed line
  delta so the view also holds when the body shrinks (card collapse,
  transcript clear), clamped at zero; previously only growth was handled.
- copyTranscriptSelectionCmd: report "Copy failed" when both the native
  clipboard and the OSC52 fallback fail, instead of claiming success;
  keep the selection so the user can retry.
- Drop vestigial ZERO_WEBSEARCH_BACKEND test env: the native backend was
  removed (MCP path), so the var is read by no product code and the
  references misrepresented it as a config knob.

Adds tests for the local-host parsing, the catalog/BaseURL skip, and the
non-finite score rejection.
@Vasanthdev2004
Vasanthdev2004 deleted the feat/websearch-enrich branch June 28, 2026 08:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants