Let General Chat search the web, and only General Chat

Search is the one capability that fits the profile holding none: every other capability either
reads this installation or changes it, and chat's whole contract is that it can do neither. The
assistant deliberately does not get it — its contract is that answers come from this install's
own documents, and a web result there is an answer that looks sourced and is not. A chat question
about this machine hands off to the assistant before the search would run, so it never reaches
the internet even with the box ticked.

Off by default, and not because it is dangerous: searching sends the operator's question outside
the house, which is theirs to decide. Asked for per turn as well as enabled in conf.

Provider-agnostic, as asked — searxng, brave, tavily. Only whichever is configured here can be
verified; all three read every field by name, so a shape that changes underneath yields no
results rather than wrong ones.

The explain fixtures asserted chat holds no capabilities at all, which is exactly the guarantee
worth keeping. caps=only: now states the set rather than its emptiness, so the check still fails
the day something else is granted there.
This commit is contained in:
Gmer4Lfe
2026-08-09 22:42:43 -04:00
parent 47861b0dc3
commit 613634473a
10 changed files with 405 additions and 12 deletions
+227
View File
@@ -0,0 +1,227 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Web search for the chat profile. Turns a question into a handful of titled snippets with
// their URLs, for the worker to put in front of the model as context it can cite.
//
// OPERATIONAL MODEL
// One provider at a time, named by AI_WEB_SEARCH_PROVIDER. Three are supported because the
// right answer depends on what the operator is willing to run: a self-hosted SearXNG needs no
// key and no third party, and a hosted API needs no container. They differ only in how the
// request is shaped and where the fields sit in the reply, so everything after parsing is one
// code path.
//
// DESIGN PRINCIPLES
// Off by default, and not because it is dangerous.
// Searching sends the operator's question to something outside this house. That is a
// decision for the person whose question it is, so AI_WEB_SEARCH_ENABLED starts false and
// nothing turns it on.
//
// Chat only, and that is a capability, not a check in here.
// General chat holds no other capability precisely because it cannot write anything. Search
// is a read, so it is the one capability that fits there. The Varaverk assistant does not
// get it: its whole contract is that answers come from this installation's own documents,
// and a web result in that context is an answer that looks sourced and is not.
//
// Snippets, never pages.
// Only what the search API returns. Fetching the pages themselves would mean running an
// HTML parser over whatever the internet handed back, on a box that already has enough to
// do, for text the model has a 16k window to hold.
//
// OPERATIONAL SAFEGUARDS
// Bounded everywhere. A timeout on the call, a cap on the number of results, and a cap on the
// length of each snippet — the context this competes for is the same one retrieval needs.
//
// The query is the operator's question and nothing else. No conf values, no host names, no log
// contents are ever appended to it: this is the one path in the plugin that sends text off the
// machine, and what leaves has to be exactly what the operator typed.
//
// Redacted before it is logged, like every other question in this subsystem.
//
// DEPENDS ON
// include/ai.php vv_ai_redact(), vv_conf_vars()
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once __DIR__ . '/ai.php';
const VV_AI_WEB_PROVIDERS = ['searxng', 'brave', 'tavily'];
const VV_AI_WEB_MAX_QUERY = 300; // characters sent to the provider
const VV_AI_WEB_MAX_SNIPPET = 400; // characters kept from each result
function vv_ai_web_enabled(): bool {
if (!vv_ai_enabled()) return false;
return strtolower(trim((string)(vv_conf_vars()['AI_WEB_SEARCH_ENABLED'] ?? 'false'))) === 'true';
}
function vv_ai_web_provider(): string {
$p = strtolower(trim((string)(vv_conf_vars()['AI_WEB_SEARCH_PROVIDER'] ?? 'searxng')));
return in_array($p, VV_AI_WEB_PROVIDERS, true) ? $p : 'searxng';
}
function vv_ai_web_results_max(): int {
return max(1, min(10, (int)(vv_conf_vars()['AI_WEB_SEARCH_RESULTS'] ?? 4)));
}
function vv_ai_web_timeout(): int {
return max(2, min(30, (int)(vv_conf_vars()['AI_WEB_SEARCH_TIMEOUT'] ?? 6)));
}
// Per-host, because a SearXNG URL is an address on this network and an API key is a credential —
// both belong in host*.conf by the same rule that puts every other one there.
function vv_ai_web_searx_url(): string {
return rtrim(trim((string)(vv_conf_vars()[strtoupper(vv_detect_host()) . '_SEARXNG_URL'] ?? '')), '/');
}
function vv_ai_web_api_key(): string {
return trim((string)(vv_conf_vars()[strtoupper(vv_detect_host()) . '_WEB_SEARCH_API_KEY'] ?? ''));
}
// Is this provider actually set up? Answered separately from "is it switched on", because the
// two failures need different sentences: one is a setting, the other is a missing address or key.
function vv_ai_web_ready(): array {
if (!vv_ai_web_enabled()) {
return ['ok' => false, 'error' => 'AI_WEB_SEARCH_ENABLED is false'];
}
$p = vv_ai_web_provider();
if ($p === 'searxng' && vv_ai_web_searx_url() === '') {
return ['ok' => false, 'error' => strtoupper(vv_detect_host()) . '_SEARXNG_URL is empty'];
}
if ($p !== 'searxng' && vv_ai_web_api_key() === '') {
return ['ok' => false, 'error' => strtoupper(vv_detect_host()) . '_WEB_SEARCH_API_KEY is empty'];
}
return ['ok' => true, 'provider' => $p];
}
// One HTTP call, returning the decoded body or null. curl rather than file_get_contents so the
// timeout is real and a proxy-less environment does not hang the worker.
function vv_ai_web_http(string $url, array $headers = [], ?array $postJson = null): ?array {
$ch = curl_init($url);
if ($ch === false) return null;
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => vv_ai_web_timeout(),
CURLOPT_CONNECTTIMEOUT => min(5, vv_ai_web_timeout()),
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_HTTPHEADER => array_merge(['Accept: application/json'], $headers),
CURLOPT_USERAGENT => 'Varaverk',
]);
if ($postJson !== null) {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postJson));
curl_setopt($ch, CURLOPT_HTTPHEADER,
array_merge(['Accept: application/json', 'Content-Type: application/json'], $headers));
}
$body = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false || $code < 200 || $code >= 300) return null;
$d = json_decode((string)$body, true);
return is_array($d) ? $d : null;
}
// Everything below reduces to the same three fields, so the worker never learns which provider
// answered: title, url, snippet.
function vv_ai_web_normalise(array $rows): array {
$out = [];
foreach ($rows as $r) {
$url = trim((string)($r['url'] ?? ''));
// Only http(s). A provider returning anything else is either broken or hostile, and this
// string ends up in an href.
if (!preg_match('#^https?://#i', $url)) continue;
$title = trim((string)($r['title'] ?? ''));
$snip = trim(preg_replace('/\s+/u', ' ', (string)($r['snippet'] ?? '')));
if ($title === '' && $snip === '') continue;
$out[] = ['title' => mb_substr($title !== '' ? $title : $url, 0, 160),
'url' => $url,
'snippet' => mb_substr($snip, 0, VV_AI_WEB_MAX_SNIPPET)];
}
return $out;
}
// ── Providers ────────────────────────────────────────────────────────────────────────────────
// Each returns raw {title,url,snippet} rows or null when the call failed. Only the one this
// installation is configured for has ever been exercised against a live service; the other two
// are written from their published response shapes and are unverified here. Whichever is in use,
// a shape that changes underneath produces zero results rather than wrong ones, because every
// field is read by name and anything missing is dropped by vv_ai_web_normalise().
function vv_ai_web_searxng(string $q): ?array {
$base = vv_ai_web_searx_url();
if ($base === '') return null;
// format=json must be enabled in SearXNG's own settings.yml; it is off in the default image.
$url = $base . '/search?format=json&safesearch=0&q=' . rawurlencode($q);
$d = vv_ai_web_http($url);
if ($d === null) return null;
return array_map(fn($r) => ['title' => $r['title'] ?? '', 'url' => $r['url'] ?? '',
'snippet' => $r['content'] ?? ''],
array_slice($d['results'] ?? [], 0, vv_ai_web_results_max()));
}
function vv_ai_web_brave(string $q): ?array {
$key = vv_ai_web_api_key();
if ($key === '') return null;
$url = 'https://api.search.brave.com/res/v1/web/search?count=' . vv_ai_web_results_max()
. '&q=' . rawurlencode($q);
$d = vv_ai_web_http($url, ['X-Subscription-Token: ' . $key]);
if ($d === null) return null;
return array_map(fn($r) => ['title' => $r['title'] ?? '', 'url' => $r['url'] ?? '',
'snippet' => $r['description'] ?? ''],
array_slice($d['web']['results'] ?? [], 0, vv_ai_web_results_max()));
}
function vv_ai_web_tavily(string $q): ?array {
$key = vv_ai_web_api_key();
if ($key === '') return null;
$d = vv_ai_web_http('https://api.tavily.com/search', [],
['api_key' => $key, 'query' => $q, 'max_results' => vv_ai_web_results_max(),
'search_depth' => 'basic']);
if ($d === null) return null;
return array_map(fn($r) => ['title' => $r['title'] ?? '', 'url' => $r['url'] ?? '',
'snippet' => $r['content'] ?? ''],
array_slice($d['results'] ?? [], 0, vv_ai_web_results_max()));
}
// The one function the worker calls.
//
// Returns ['ok'=>bool, 'results'=>[…], 'provider'=>…, 'error'=>…]. An empty result set is not an
// error: "the web had nothing" and "the search did not happen" are different things to tell the
// model, and collapsing them is how an answer ends up asserting that nothing exists.
function vv_ai_web_search(string $question): array {
$ready = vv_ai_web_ready();
if (!($ready['ok'] ?? false)) {
return ['ok' => false, 'results' => [], 'provider' => vv_ai_web_provider(),
'error' => $ready['error']];
}
$q = trim(preg_replace('/\s+/u', ' ', $question));
if ($q === '') return ['ok' => false, 'results' => [], 'provider' => vv_ai_web_provider(),
'error' => 'empty query'];
$q = mb_substr($q, 0, VV_AI_WEB_MAX_QUERY);
$provider = vv_ai_web_provider();
$raw = match ($provider) {
'brave' => vv_ai_web_brave($q),
'tavily' => vv_ai_web_tavily($q),
default => vv_ai_web_searxng($q),
};
if ($raw === null) {
return ['ok' => false, 'results' => [], 'provider' => $provider,
'error' => $provider . ' did not answer'];
}
return ['ok' => true, 'provider' => $provider,
'results' => array_slice(vv_ai_web_normalise($raw), 0, vv_ai_web_results_max())];
}
// The block that goes in front of the model, and the sources the page renders beside the answer.
// Numbered from an offset so web results can sit after retrieved passages without either set
// renumbering the other.
function vv_ai_web_context(array $results, int $offset = 0): string {
if (!$results) return '';
$s = "Web search results. These are from outside this installation — cite them by number, and\n"
. "say when something is a general answer rather than one about this machine.\n\n";
foreach ($results as $i => $r) {
$s .= '[' . ($offset + $i + 1) . '] ' . $r['title'] . ' — ' . $r['url'] . "\n"
. $r['snippet'] . "\n\n";
}
return $s;
}