diff --git a/AI/README-AI.md b/AI/README-AI.md index d12c41e..ad2add7 100644 --- a/AI/README-AI.md +++ b/AI/README-AI.md @@ -299,6 +299,31 @@ Reopened chats render as plain turns: sources, reasoning and timings describe on are not stored, because redrawing them beside a transcript that may be continued under a different profile would be citing evidence for an answer no longer being made. +### Secrets are redacted on the way to disk + +A conversation about settings is a conversation containing credentials — asking for an API key to +be changed means typing one. Message bodies are redacted in `vv_ai_chat_save()`, and the question +is redacted again before it reaches `ai.log`. + +**On the way out, never in flight.** The live turn keeps the real value, because the model needs +it to carry out what was asked. What it does not need is that value still in the transcript a week +later — and a stored chat is replayed into a later prompt when reopened, so an unredacted one +would hand the credential back on every subsequent turn, indefinitely. + +Two passes, because they catch different things: + +| Pass | Catches | Method | +|---|---|---| +| Known values | a credential this host already holds | exact match against secret-shaped conf keys, longest first | +| Assignment shapes | a credential arriving that is not in the conf yet | `NAME=value`, `"api_key": value`, "set the token to …" | + +The second pass is the one that matters for settings changes: *"change the Emby API key to X"* is +a secret arriving, and X matches nothing on disk until after the write it is requesting. + +Ordinary prose is left alone — the patterns anchor on a secret-shaped *name*, so `CACHE_WARN_GB=100` +and "turn off the zfs scrub" pass through untouched. `vv_conf_key_is_secret()` is shared with the +conf audit log, so the two cannot disagree about what counts as a secret. + ## ━━━ TOKEN ACCOUNTING ━━━ Every completed `ask` appends one row to `AI_TOKEN_DB` (`data/ai/ai_token_history.db`): diff --git a/Plugin/unraid/Tools/ai_chat_worker.php b/Plugin/unraid/Tools/ai_chat_worker.php index 8498350..c3fda33 100644 --- a/Plugin/unraid/Tools/ai_chat_worker.php +++ b/Plugin/unraid/Tools/ai_chat_worker.php @@ -162,7 +162,7 @@ if ($profile === 'chat') { if ($to !== '' && $to !== $profile) { $profile = $to; $escalated = true; - wlog('handoff chat -> ' . $to . ': ' . mb_substr($question, 0, 80)); + wlog('handoff chat -> ' . $to . ': ' . mb_substr(vv_ai_redact($question), 0, 80)); } } diff --git a/Plugin/unraid/api/ai.php b/Plugin/unraid/api/ai.php index 1167db0..e6e84f2 100644 --- a/Plugin/unraid/api/ai.php +++ b/Plugin/unraid/api/ai.php @@ -386,8 +386,11 @@ if ($action === 'ask') { . ' >/dev/null 2>&1 true, 'token' => $token]); exit; diff --git a/Plugin/unraid/include/ai.php b/Plugin/unraid/include/ai.php index 314c7ed..085df7a 100644 --- a/Plugin/unraid/include/ai.php +++ b/Plugin/unraid/include/ai.php @@ -78,6 +78,9 @@ // ═══════════════════════════════════════════════════════════════════════════════════════════════ require_once __DIR__ . '/config.php'; +// vv_conf_key_is_secret() — the same definition of "this key holds a credential" that decides +// what the conf audit log redacts. One list, so the two cannot disagree about what a secret is. +require_once __DIR__ . '/confform.php'; // VV_AI_JOB_DIR and VV_AI_TOKEN_CACHE_DIR are defined in config.php with every other cache path, // read from master.conf so this layer and load_config.sh cannot disagree about where they are. @@ -1216,6 +1219,84 @@ function vv_ai_chats_dir(): string { return $d; } +// ── Secret redaction ───────────────────────────────────────────────────────────────────────── +// A conversation about settings is a conversation that contains credentials. Asking the +// assistant to change an API key means typing the key, and a stored transcript is replayed into +// a later prompt when the thread is reopened — so without this a secret would be written to +// disk in cleartext and then handed back to the model on every subsequent turn, indefinitely. +// +// Redaction happens on the way to disk and to the log, never to the live turn. The model needs +// the real value to carry out the change being asked for; what it does not need is that value +// still sitting in the transcript a week later. +const VV_AI_REDACTED = '[redacted]'; + +// The values this host actually holds, longest first so a key that contains another as a +// substring cannot be half-replaced. Only secret-shaped conf keys contribute, and only values +// long enough to be a credential — redacting every occurrence of a two-character setting would +// shred ordinary prose. +function vv_ai_known_secrets(): array { + static $cache = null; + if ($cache !== null) return $cache; + + $vals = []; + foreach (vv_conf_vars() as $k => $v) { + $v = trim((string)$v); + if (strlen($v) < 8) continue; + if (!vv_conf_key_is_secret((string)$k)) continue; + // A value that is still a ${...} reference is a template, not a credential. + if (str_contains($v, '${')) continue; + $vals[] = $v; + } + $vals = array_values(array_unique($vals)); + usort($vals, fn($a, $b) => strlen($b) <=> strlen($a)); + + return $cache = $vals; +} + +// Two passes, because they catch different things. +// +// Known values catch a credential this host already holds, however it is worded — pasted bare, +// quoted, or buried mid-sentence. Exact string matching, so there are no false positives. +// +// Assignment shapes catch the credential that is not in the conf yet, which is precisely the +// case that matters here: "change the Emby API key to " is a secret arriving, and it +// will not match anything on disk until after the write it is asking for. +function vv_ai_redact(string $text): string { + if ($text === '') return $text; + + foreach (vv_ai_known_secrets() as $secret) { + $text = str_replace($secret, VV_AI_REDACTED, $text); + } + + // NAME=value / "api_key": value / api-key: value — anchored on a secret-shaped name so an + // ordinary "count=12" is untouched. + $text = preg_replace( + '/\b([A-Za-z0-9_\-]*(?:api[_\-]?key|password|passwd|secret|token|_pass|apikey)[A-Za-z0-9_\-]*)' + . '(\s*["\']?\s*[:=]\s*["\']?)([^\s"\',;]{6,})/i', + '$1$2' . VV_AI_REDACTED, + $text + ) ?? $text; + + // "set the api key to ", "password is " — the same secret arriving as prose + // rather than as an assignment. + $text = preg_replace( + '/\b((?:api[ _\-]?key|password|passphrase|secret|token)\s+(?:to|is|as|=)\s+)["\']?([^\s"\',;]{6,})/i', + '$1' . VV_AI_REDACTED, + $text + ) ?? $text; + + return $text; +} + +// Redacts every message body in place, leaving roles and any other metadata alone. +function vv_ai_redact_messages(array $messages): array { + foreach ($messages as &$m) { + if (isset($m['content']) && is_string($m['content'])) $m['content'] = vv_ai_redact($m['content']); + } + unset($m); + return $messages; +} + function vv_ai_chats_max(): int { $n = (int)(vv_conf_vars()['AI_CHAT_HISTORY_MAX'] ?? 10); return max(1, min(50, $n)); @@ -1308,6 +1389,12 @@ function vv_ai_chat_save(string $id, string $profile, array $messages, string $s $prev = vv_ai_chat_read($id); $created = (int)($prev['created'] ?? time()); + // Redacted here rather than at the point the message was composed, because the live turn + // needs the real value to carry out what was asked. This is the boundary between "in flight" + // and "on disk", and it is the last place the cleartext exists. The title is derived after, + // so a credential cannot survive in the conversation list either. + $messages = vv_ai_redact_messages($messages); + // Scope travels with the conversation. A Scheduler thread is bound to what the operator had // open — a script, a log, a conf key — and its turns carry log excerpts chosen for that // thing. Storing the scope means reopening the thread anywhere restores the context it was