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:
@@ -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`):
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user