Redact credentials on the way into a stored chat

A stored transcript is replayed into a later prompt when reopened, so a key typed while
changing a setting would be handed back to the model on every subsequent turn.
This commit is contained in:
Gmer4Lfe
2026-08-09 19:07:34 -04:00
parent 67eabdc17c
commit 500f9d92c8
4 changed files with 117 additions and 2 deletions
+1 -1
View File
@@ -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));
}
}
+4 -1
View File
@@ -386,8 +386,11 @@ if ($action === 'ask') {
. ' >/dev/null 2>&1 </dev/null &';
$out = []; $rc = 0;
exec($cmd, $out, $rc);
// Redacted before it is logged, for the same reason the stored transcript is: asking the
// assistant to set a credential means typing one, and ai.log is neither 0600 nor pruned.
vv_ai_log(sprintf('ask token=%s rc=%d profile=%s kind=%s q=%s',
substr($token, 0, 12), $rc, $profile, $kind ?: '-', mb_substr($question, 0, 80)));
substr($token, 0, 12), $rc, $profile, $kind ?: '-',
mb_substr(vv_ai_redact($question), 0, 80)));
echo json_encode(['ok' => true, 'token' => $token]);
exit;
+87
View File
@@ -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 <new value>" 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 <value>", "password is <value>" — 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