From 917ff43795e73b8cf61e6d7db3a7f3233c6d400f Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Thu, 6 Aug 2026 20:51:19 -0400 Subject: [PATCH] Profile permissions belong in one table, because scattered they hid a capability chat was never meant to have --- Plugin/unraid/Tools/ai_chat_worker.php | 36 ++++++++++-------- Plugin/unraid/api/ai.php | 2 +- Plugin/unraid/include/ai.php | 52 ++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 17 deletions(-) diff --git a/Plugin/unraid/Tools/ai_chat_worker.php b/Plugin/unraid/Tools/ai_chat_worker.php index 88593fe..9677891 100644 --- a/Plugin/unraid/Tools/ai_chat_worker.php +++ b/Plugin/unraid/Tools/ai_chat_worker.php @@ -92,6 +92,11 @@ if (!preg_match('#/[0-9a-f]{32}\.json$#', $jobFile)) exit(1); $profile = in_array($profile, ['varaverk', 'chat', 'code', 'troubleshoot'], true) ? $profile : 'varaverk'; +// Every "is this profile allowed X" question in this file goes through here. Written as a closure +// over $profile so no call site can accidentally ask about a different one, which is the shape the +// old scattered comparisons kept taking. +$can = fn(string $cap): bool => vv_ai_profile_can($profile, $cap); + function jw(string $f, array $d): void { file_put_contents($f, json_encode($d)); } @@ -114,7 +119,7 @@ $sources = []; $context = ''; $tRetrieve = 0.0; -if ($profile === 'varaverk' || $profile === 'troubleshoot') { +if ($can('retrieve')) { jw($jobFile, ['status' => 'retrieving']); // A definitional question with no explicit filter goes to the narrative docs. Left alone, @@ -160,18 +165,16 @@ if ($profile === 'varaverk' || $profile === 'troubleshoot') { // — nothing had failed, so nothing in the question said failure. It retrieved the documentation // on where logs live and answered with directions to a page the operator already had open. The // question is about a run that happened, so the run itself has to be in context. -// -// Restricted to the two profiles that are allowed Varaverk's own material. chat is promised, as -// its first and most absolute rule, that it has not been shown this installation — attaching a -// log tail to it because the phrasing matched would break exactly the guarantee that stops it -// inventing confident answers about the operator's system. -$runOutcome = ($profile === 'varaverk' || $profile === 'troubleshoot') && (bool)preg_match( +$runOutcome = $can('run_evidence') && (bool)preg_match( '/\b(how did|how.d|did .{0,24}\b(run|go|finish|complete)|last run|latest run|last night|' . 'go last|went last|how long did|run record|rundown|summar(y|ise|ize)|recap)\b/i', $question ); -$diagnostic = $profile === 'troubleshoot' || $runOutcome || ($profile === 'varaverk' && (bool)preg_match( +// Permission first, need second: the capability decides whether live state may be attached at +// all, and only then does the phrasing decide whether this particular question warrants it. +// troubleshoot needs no phrasing test — the operator opened a log to get there. +$diagnostic = $can('health') && ($profile === 'troubleshoot' || $runOutcome || (bool)preg_match( '/\b(why|fail(ed|ing|ure)?|error|broken?|not work|isn.t work|wrong|stuck|hang|' . 'never runs?|didn.t|won.t|debug|troubleshoot|diagnos)/i', $question @@ -206,7 +209,7 @@ if ($diagnostic) { // question. The second half is what makes a run-outcome question work from any view: asking how // the daily orchestrator went while looking at the suggestions list is the ordinary case, not an // edge one, and requiring them to open the log first is asking them to do the lookup themselves. -$runTarget = ($profile === 'troubleshoot' && $scope !== '') ? $scope : ''; +$runTarget = ($can('scoped_log') && $scope !== '') ? $scope : ''; if ($runTarget === '' && $runOutcome) $runTarget = vv_ai_resolve_run_target($question); $scopedLog = null; @@ -241,7 +244,10 @@ if ($runTarget !== '' && vv_ai_scope_ok($runTarget)) { // What has gone wrong with this same thing before, and what actually fixed it. Operator-written, // so it outranks anything the model would infer from the log — it is the only input here that // records a confirmed outcome rather than a reading of evidence. -if ($scope !== '') { +// Gated on the capability, which it was not before: this block keyed only on a scope being +// present, so General Chat opened against a script was handed the operator's own incident notes +// about it — the same leak as the log, one block further down. +if ($can('incidents') && $scope !== '') { $past = vv_ai_incidents_for($scope, 4); if ($past) { $diagBlock .= "PREVIOUSLY ON THIS, WRITTEN BY THE OPERATOR AFTER IT WAS RESOLVED\n" @@ -255,7 +261,7 @@ if ($scope !== '') { // Where a named conf key really lives, resolved before the model sees the question. Deterministic // so the answer cannot be a guess: the operator may be certain a setting is in master.conf when // it is in the host conf, and the useful reply names the file and line rather than not finding it. -if ($profile === 'varaverk' || $profile === 'troubleshoot') { +if ($can('conf_lookup')) { $seen = []; if (preg_match_all('/\b([A-Z][A-Z0-9_]{4,})\b/', $question, $km)) { foreach (array_slice(array_unique($km[1]), 0, 4) as $k) { @@ -472,9 +478,7 @@ if ($diagBlock !== '') { // the instruction is repeated immediately before the user's message, where it is hardest to // ignore. Detection only ever makes the model MORE cautious, so a false positive costs a // redirect rather than a wrong answer. -if ($profile === 'chat' - && (vv_ai_mentions_varaverk($question) - || preg_match('/\b[\w.-]+\.sh\b|\b[A-Z][A-Z0-9]*(_[A-Z0-9]+)+\b/', $question))) { +if ($profile === 'chat' && vv_ai_chat_needs_varaverk($question)) { $system .= "NOTE: the operator's message appears to name a specific Varaverk component. " . "You cannot see the documentation in this mode, so you do not know what it does. " . "Say that plainly, point them at the Varaverk Assistant profile, and do not " @@ -543,7 +547,7 @@ if ($answer === '') { // expensive mistake is not tangled logic — it is a simple script pointed one directory too high. // Scans only fenced code, so prose mentioning "rm" does not trip it. $warnings = []; -if ($profile === 'code' && preg_match_all('/```(?:\w+)?\n(.*?)```/s', $answer, $blocks)) { +if ($can('code_scan') && preg_match_all('/```(?:\w+)?\n(.*?)```/s', $answer, $blocks)) { $code = implode("\n", $blocks[1]); $checks = [ '/(^|[;&|\s])rm\s+(-[a-zA-Z]*\s+)*/m' => 'deletes files (rm)', @@ -569,7 +573,7 @@ if ($profile === 'code' && preg_match_all('/```(?:\w+)?\n(.*?)```/s', $answer, $ // the guard lives here, in code, not in the instruction that asked for it. A prompt is a // request; this is the part that decides. $bugFiled = null; -if ($profile === 'troubleshoot' +if ($can('file_bugs') && preg_match('/\[VARAVERK-BUG\](.*?)\[\/VARAVERK-BUG\]/s', $answer, $bm)) { $answer = trim(preg_replace('/\[VARAVERK-BUG\].*?\[\/VARAVERK-BUG\]/s', '', $answer)); diff --git a/Plugin/unraid/api/ai.php b/Plugin/unraid/api/ai.php index 52df843..990639b 100644 --- a/Plugin/unraid/api/ai.php +++ b/Plugin/unraid/api/ai.php @@ -252,7 +252,7 @@ if ($action === 'ask') { if ($scope !== '' && !vv_ai_scope_ok($scope)) $scope = ''; // The retrieval filter only means anything to the profile that retrieves. - $kind = $profile === 'varaverk' ? trim($_POST['kind'] ?? '') : ''; + $kind = vv_ai_profile_can($profile, 'kind_filter') ? trim($_POST['kind'] ?? '') : ''; if ($kind !== '' && !in_array($kind, VV_AI_KINDS, true)) { echo json_encode(['ok' => false, 'error' => 'Unknown kind: ' . $kind]); exit; } diff --git a/Plugin/unraid/include/ai.php b/Plugin/unraid/include/ai.php index f7c8a49..fb5d801 100644 --- a/Plugin/unraid/include/ai.php +++ b/Plugin/unraid/include/ai.php @@ -82,6 +82,58 @@ require_once __DIR__ . '/config.php'; define('VV_AI_JOB_DIR', '/tmp/varaverk_ai_jobs'); const VV_AI_KINDS = ['header', 'readme', 'manual', 'template', 'doc']; +// ── What each profile is allowed to see and do ─────────────────────────────────────────────── +// A profile is a contract plus a set of inputs, and the inputs are the half that has to be +// enforced rather than requested. This table is that half, in one place. +// +// It exists because the alternative already failed. The same permissions used to live as a dozen +// `$profile === 'varaverk' || $profile === 'troubleshoot'` conditions spread across the worker, +// and answering "may chat ever be shown a log?" meant reading all of them. It could — a gate +// added for run-outcome questions granted it by omission, and the chat profile, whose entire +// value is that it has NOT been shown this installation, was one phrasing away from being handed +// a health sweep and 120 lines of log. Nothing about that was visible at the point of the +// mistake. Here it would have been one missing word on one line. +// +// A capability is permission, not need. varaverk holds 'health' but only attaches it when the +// question looks diagnostic; troubleshoot attaches it always. The gates decide whether an input +// is warranted, this decides whether it is allowed, and a gate can never widen the grant. +// +// The ordering is deliberate: chat holds nothing, and that emptiness is a guarantee, not an +// oversight. Anything added to it stops being general chat and becomes an assistant that +// sometimes lies about this installation. +const VV_AI_CAPS = [ + // retrieval passages from the index, and the kind filter the page exposes for them + 'retrieve' => ['varaverk', 'troubleshoot'], + 'kind_filter' => ['varaverk'], + // live health sweep measured at question time + 'health' => ['varaverk', 'troubleshoot'], + // run record + log tail for a script named in the question + 'run_evidence' => ['varaverk', 'troubleshoot'], + // log tail for whatever the operator currently has open + 'scoped_log' => ['troubleshoot'], + // operator-written history of what previously went wrong with this thing + 'incidents' => ['varaverk', 'troubleshoot'], + // deterministic "where does this conf key actually live" lookup + 'conf_lookup' => ['varaverk', 'troubleshoot'], + // may file a bug report against Varaverk itself + 'file_bugs' => ['troubleshoot'], + // destructive-operation scan of generated shell + 'code_scan' => ['code'], +]; + +function vv_ai_profile_can(string $profile, string $cap): bool { + return in_array($profile, VV_AI_CAPS[$cap] ?? [], true); +} + +// Whether a General Chat message is really about this installation. Shared by the deterministic +// backstop and the handoff, so both agree by construction: a question the backstop would have +// refused is exactly the one the handoff should escalate, and drift between the two would leave +// a class of question that is neither answered nor deferred. +function vv_ai_chat_needs_varaverk(string $question): bool { + return vv_ai_mentions_varaverk($question) + || (bool)preg_match('/\b[\w.-]+\.sh\b|\b[A-Z][A-Z0-9]*(_[A-Z0-9]+)+\b/', $question); +} + function vv_ai_config(): array { static $cfg = null; if ($cfg !== null) return $cfg;