Files
Termix/plugins/ai/backend/redaction.ts
T
LukeGus 504710b065 feat: ship the AI assistant as a first-party in-process plugin
Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.
2026-09-21 15:20:05 -05:00

91 lines
3.0 KiB
TypeScript

/**
* Defense in depth for anything about to leave the server.
*
* Read tools already select explicit field allowlists rather than spreading
* rows, so nothing secret should reach here. This exists because "should" is
* not a guarantee: a future tool that forgets to project its fields would
* otherwise ship credentials to a third-party model provider.
*/
const SECRET_KEY_PATTERN =
/^(password|passwd|pass|secret|token|api_?key|apikey|private_?key|privatekey|key_?password|keypassword|passphrase|client_?secret|authorization|auth_?token|access_?token|refresh_?token|totp_?secret|backup_?codes|session_?token|cookie|credential|ssh_?cert|data_?key|dek)$/i;
/** Substring markers for keys that are not exact matches but still sensitive. */
const SECRET_KEY_SUBSTRINGS = [
"password",
"secret",
"privatekey",
"private_key",
"apikey",
"api_key",
"passphrase",
];
const VALUE_PATTERNS: Array<{ pattern: RegExp; label: string }> = [
{
pattern:
/-----BEGIN[^-]*PRIVATE KEY-----[\s\S]*?-----END[^-]*PRIVATE KEY-----/g,
label: "[redacted private key]",
},
{ pattern: /\bsk-[A-Za-z0-9_-]{16,}\b/g, label: "[redacted api key]" },
{ pattern: /\bsk-ant-[A-Za-z0-9_-]{16,}\b/g, label: "[redacted api key]" },
{ pattern: /\bghp_[A-Za-z0-9]{20,}\b/g, label: "[redacted token]" },
{ pattern: /\btmx_[A-Za-z0-9_-]{16,}\b/g, label: "[redacted token]" },
{
pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+\b/g,
label: "[redacted token]",
},
{
pattern: /\bBearer\s+[A-Za-z0-9._-]{16,}/gi,
label: "Bearer [redacted]",
},
];
export const REDACTED = "[redacted]";
function isSecretKey(key: string): boolean {
if (SECRET_KEY_PATTERN.test(key)) return true;
const lower = key.toLowerCase();
return SECRET_KEY_SUBSTRINGS.some((marker) => lower.includes(marker));
}
export function redactString(value: string): string {
let output = value;
for (const { pattern, label } of VALUE_PATTERNS) {
output = output.replace(pattern, label);
}
return output;
}
/**
* Recursively drops secret-named fields and masks secret-shaped values.
* Depth is bounded so a cyclic or pathological structure cannot hang the loop.
*/
export function redact(value: unknown, depth = 0): unknown {
if (depth > 12) return REDACTED;
if (typeof value === "string") return redactString(value);
if (value === null || typeof value !== "object") return value;
if (Array.isArray(value)) {
return value.map((entry) => redact(entry, depth + 1));
}
const output: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
if (isSecretKey(key)) {
// Preserve the shape so the model can still reason about presence,
// without ever seeing the value.
output[key] = entry === null || entry === undefined ? null : REDACTED;
continue;
}
output[key] = redact(entry, depth + 1);
}
return output;
}
/** Convenience wrapper for serializing a tool result. */
export function redactToJson(value: unknown): string {
return JSON.stringify(redact(value));
}