Prove a repair before writing it, and gate writing separately from looking

A model can be confident a port should be 8686; a probe can report that 8686 answered, and
only the second is a fact. Detecting and repairing are also separate things to trust, so
the feature runs read-only until the write switch is turned on under it.
This commit is contained in:
Gmer4Lfe
2026-08-09 19:23:57 -04:00
parent 083b5ec5ad
commit a8654280a7
2 changed files with 190 additions and 0 deletions
+172
View File
@@ -111,6 +111,7 @@ function vv_ai_conf_is_toggle(string $key): bool {
// May the sweep write this without being asked? Two conditions, both required: a probe actually
// answered on the proposed value, and the key is not a toggle.
function vv_ai_finding_may_autofix(array $f): bool {
if (!vv_ai_repair_autofix_enabled()) return false;
if (empty($f['proven'])) return false;
if (($f['proposed'] ?? null) === null) return false;
if (vv_ai_conf_is_toggle((string)($f['conf_key'] ?? ''))) return false;
@@ -143,6 +144,27 @@ function vv_ai_finding_severity(array $f): string {
};
}
// ── Gates ────────────────────────────────────────────────────────────────────────────────────
// Two switches, because detecting and repairing are separate things to trust.
//
// AI_REPAIR_ENABLED alone gives a system that reads logs, files findings and offers fixes, and
// writes nothing. That is the state this should live in first — long enough to read what it
// found and disagree with some of it. A subsystem that starts by editing conf has to be believed
// before there is any evidence for believing it.
//
// AI_REPAIR_AUTOFIX_ENABLED is what lets a proven value be written without being asked, and it
// is meaningless on its own: nothing to write if nothing is looking. Both must be true, in the
// same layered way AI_ENABLED is necessary but never sufficient.
function vv_ai_repair_enabled(): bool {
if (!vv_ai_config()['enabled']) return false;
return strtolower(trim((string)(vv_conf_vars()['AI_REPAIR_ENABLED'] ?? 'false'))) === 'true';
}
function vv_ai_repair_autofix_enabled(): bool {
if (!vv_ai_repair_enabled()) return false;
return strtolower(trim((string)(vv_conf_vars()['AI_REPAIR_AUTOFIX_ENABLED'] ?? 'false'))) === 'true';
}
function vv_ai_findings_dir(): string {
$d = AI_DATA_DIR . '/ai_findings';
if (!is_dir($d)) @mkdir($d, 0755, true);
@@ -502,3 +524,153 @@ function vv_ai_triage_log(array $lines, string $sourceLog = ''): array {
}
return array_values($found);
}
// ── Proving a candidate ──────────────────────────────────────────────────────────────────────
// The guard the whole unattended path rests on: nothing is written that has not answered.
//
// A model can be confident that a port should be 8686. A probe can report that 8686 answered.
// Only the second is a fact, and only facts get written to conf without being asked. Everything
// a probe cannot settle becomes a conversation instead — which is not a lesser outcome, it is
// the honest one for a value that cannot be derived from this machine.
//
// Deliberately narrow. These check reachability and identity, never correctness of behaviour:
// that Lidarr answers on 8686 does not prove 8686 is the port you meant, only that something is
// listening there and calling itself Lidarr. Proving intent is not a probe's job.
function vv_ai_probe_timeout(): int {
return max(1, (int)(vv_conf_vars()['AI_PROBE_TIMEOUT'] ?? 4));
}
// Does this URL answer at all? Any HTTP status counts, including 401 — a refusal is proof that
// something is listening and speaking HTTP, which is exactly what an address probe is asking.
// Distinguishing "wrong address" from "wrong credential" is the point of having both kinds.
function vv_ai_probe_url(string $url): array {
if (!preg_match('#^https?://[^\s/$.?\#][^\s]*$#i', $url)) {
return ['ok' => false, 'reason' => 'not a url'];
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_NOBODY => true,
CURLOPT_TIMEOUT => vv_ai_probe_timeout(),
CURLOPT_CONNECTTIMEOUT => vv_ai_probe_timeout(),
CURLOPT_SSL_VERIFYPEER => false, // these are LAN and Tailscale endpoints, often self-signed
CURLOPT_SSL_VERIFYHOST => false,
]);
curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$err = curl_error($ch);
curl_close($ch);
return $code > 0
? ['ok' => true, 'code' => $code]
: ['ok' => false, 'reason' => $err !== '' ? $err : 'no response'];
}
// Every container on this host, by exact name. The membership test for unknown_target findings,
// and one docker call rather than one per candidate.
function vv_ai_container_names(): array {
static $names = null;
if ($names !== null) return $names;
$out = [];
@exec('timeout ' . vv_ai_probe_timeout() . " docker ps -a --format '{{.Names}}' 2>/dev/null", $out, $rc);
return $names = ($rc === 0) ? array_values(array_filter(array_map('trim', $out))) : [];
}
// Candidate corrections for a URL whose host or port stopped answering.
//
// Only two transformations, both conservative: the same host on a port that some other conf key
// already uses, and the same port on a host some other conf key already names. Both draw
// exclusively from values already present in this installation's conf — nothing is invented, and
// a scan of the port range is deliberately not attempted. Finding *a* listening port is not the
// same as finding the right service, and a probe that accepts any answer would happily point
// Lidarr at Sonarr.
function vv_ai_url_candidates(string $observed): array {
$parts = @parse_url($observed);
if (!is_array($parts) || empty($parts['host'])) return [];
$hosts = $ports = [];
foreach (vv_conf_vars() as $k => $v) {
$v = trim((string)$v);
if (!preg_match('#^https?://#i', $v)) continue;
$p = @parse_url($v);
if (!is_array($p) || empty($p['host'])) continue;
$hosts[$p['host']] = true;
if (!empty($p['port'])) $ports[(int)$p['port']] = true;
}
$scheme = $parts['scheme'] ?? 'http';
$path = $parts['path'] ?? '';
$out = [];
foreach (array_keys($ports) as $port) {
$c = $scheme . '://' . $parts['host'] . ':' . $port . $path;
if ($c !== $observed) $out[$c] = true;
}
foreach (array_keys($hosts) as $host) {
$port = !empty($parts['port']) ? ':' . $parts['port'] : '';
$c = $scheme . '://' . $host . $port . $path;
if ($c !== $observed) $out[$c] = true;
}
return array_keys($out);
}
// Try to prove a correction for one finding. Returns the finding with 'proposed' and 'proven'
// filled in, or unchanged when nothing could be proven — which is the common case and not a
// failure.
//
// auth_rejected and missing_value are never proven here on purpose. A credential cannot be
// derived from this host by definition: if it could be read from somewhere, it would not be a
// credential. Those go straight to the operator.
function vv_ai_probe_finding(array $f): array {
$kind = (string)($f['kind'] ?? '');
if ($kind === 'unknown_target') {
$observed = (string)($f['observed'] ?? '');
// Exact membership only. A container name is a literal, and "close to an existing name"
// is how a repair renames the wrong thing.
if (in_array($observed, vv_ai_container_names(), true)) {
return $f; // it exists after all — nothing to correct
}
return $f + ['state' => 'needs_operator'];
}
if ($kind !== 'unreachable') {
// Nothing on this machine can supply a credential, so there is nothing to prove.
$f['state'] = 'needs_operator';
return $f;
}
$observed = (string)($f['observed'] ?? '');
// If the observed address answers now, the fault has cleared on its own — a host that was
// rebooting, most often. Recording a proposal here would repair something already working.
if (vv_ai_probe_url($observed)['ok']) {
$f['state'] = 'resolved';
$f['note'] = 'Answered when probed — the address was reachable again by the time this ran.';
return $f;
}
$answered = [];
foreach (vv_ai_url_candidates($observed) as $candidate) {
if (vv_ai_probe_url($candidate)['ok']) $answered[] = $candidate;
}
// Exactly one, or none. Two addresses answering means the probe cannot say which is the
// right one, and picking either is the guess this exists to prevent.
if (count($answered) === 1) {
$f['proposed'] = $answered[0];
$f['proven'] = true;
$f['note'] = 'Probed ' . $answered[0] . ' and it answered; ' . $observed . ' did not.';
return $f;
}
$f['proven'] = false;
$f['state'] = 'needs_operator';
$f['note'] = $answered
? 'Several addresses answered (' . implode(', ', $answered) . '), so none was written.'
: 'Nothing answered at ' . $observed . ', and no address in the conf answered either.';
return $f;
}