fix(coding-agent): resolve the secret placeholder key lazily for builtin credential entries
Appending builtinCredentialSecretEntries() unconditionally made every secrets.enabled session carry a regex obfuscate entry, so secretEntriesNeedPlaceholderKey was always true and startup always created secret-placeholder.key — nullifying the replace-only/no-secret key-avoidance path and failing headless runs on an unwritable config root for a feature they never use. Only configured entries now force startup key creation. The built-in credential pattern matches dynamically, so SecretObfuscator accepts a key provider resolved once on the first actual credential match, via the new getSecretPlaceholderKeySync (never throws: degrades to a process-ephemeral key with a warning when the key file is unwritable). Also moves both CHANGELOG entries from the released 17.1.7 sections to [Unreleased].
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import * as crypto from "node:crypto";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { SENSITIVE_TOKEN_RE } from "@oh-my-pi/pi-ai/providers/transform-messages";
|
||||
import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
|
||||
@@ -32,9 +32,9 @@ export async function getSecretPlaceholderKey(keyDir: string = getAgentDir()): P
|
||||
}
|
||||
|
||||
const generated = crypto.randomBytes(32).toString("base64url");
|
||||
await fs.mkdir(keyDir, { recursive: true });
|
||||
await fs.promises.mkdir(keyDir, { recursive: true });
|
||||
try {
|
||||
await fs.writeFile(keyPath, generated, { flag: "wx", mode: 0o600 });
|
||||
await fs.promises.writeFile(keyPath, generated, { flag: "wx", mode: 0o600 });
|
||||
cachedPlaceholderKeys.set(keyPath, generated);
|
||||
return generated;
|
||||
} catch (err) {
|
||||
@@ -72,6 +72,60 @@ export async function getExistingSecretPlaceholderKey(keyDir: string = getAgentD
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Process-stable fallback for the sync lazy path when the key file cannot be
|
||||
// persisted (e.g. unwritable config root on a headless run). Memoized so
|
||||
// re-obfuscation within the process stays idempotent; placeholders simply lose
|
||||
// cross-session stability, matching `defaultPlaceholderKey()` in obfuscator.ts.
|
||||
let ephemeralSyncPlaceholderKey: string | undefined;
|
||||
|
||||
/**
|
||||
* Synchronous variant of `getSecretPlaceholderKey` for the lazy key provider
|
||||
* `SecretObfuscator` invokes inside its synchronous `obfuscate()` path when a
|
||||
* built-in credential-pattern entry first matches session content. Never
|
||||
* throws: an unreadable or unwritable key file degrades to a process-ephemeral
|
||||
* key (with a warning) instead of breaking the session.
|
||||
*/
|
||||
export function getSecretPlaceholderKeySync(keyDir: string = getAgentDir()): string {
|
||||
const keyPath = path.join(keyDir, "secret-placeholder.key");
|
||||
const cached = cachedPlaceholderKeys.get(keyPath);
|
||||
if (cached !== undefined) return cached;
|
||||
try {
|
||||
const existing = fs.readFileSync(keyPath, "utf8").trim();
|
||||
if (PLACEHOLDER_KEY_RE.test(existing)) {
|
||||
cachedPlaceholderKeys.set(keyPath, existing);
|
||||
return existing;
|
||||
}
|
||||
} catch {
|
||||
// Missing or unreadable — attempt creation below.
|
||||
}
|
||||
const generated = crypto.randomBytes(32).toString("base64url");
|
||||
try {
|
||||
fs.mkdirSync(keyDir, { recursive: true });
|
||||
fs.writeFileSync(keyPath, generated, { flag: "wx", mode: 0o600 });
|
||||
cachedPlaceholderKeys.set(keyPath, generated);
|
||||
return generated;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "EEXIST") {
|
||||
// Another process won the create race; accept its key if valid.
|
||||
try {
|
||||
const winner = fs.readFileSync(keyPath, "utf8").trim();
|
||||
if (PLACEHOLDER_KEY_RE.test(winner)) {
|
||||
cachedPlaceholderKeys.set(keyPath, winner);
|
||||
return winner;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the ephemeral key.
|
||||
}
|
||||
}
|
||||
logger.warn("Could not persist secret placeholder key, using a process-ephemeral key", {
|
||||
path: keyPath,
|
||||
error: String(err),
|
||||
});
|
||||
ephemeralSyncPlaceholderKey ??= crypto.randomBytes(32).toString("base64url");
|
||||
return ephemeralSyncPlaceholderKey;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read and validate the key file, optionally retrying briefly until a valid key lands. */
|
||||
async function readPlaceholderKeyFile(keyPath: string, retry: boolean): Promise<string | undefined> {
|
||||
const attempts = retry ? 50 : 1;
|
||||
|
||||
@@ -594,18 +594,22 @@ export class SecretObfuscator {
|
||||
/** Whether any secrets were configured */
|
||||
#hasAny: boolean;
|
||||
|
||||
/** Private per-install (or per-process) key for the keyed placeholder digest. */
|
||||
readonly #key: string;
|
||||
/**
|
||||
* Private per-install (or per-process) key for the keyed placeholder digest.
|
||||
* Resolved lazily when the constructor received a key PROVIDER: the first
|
||||
* placeholder mint or keyed fallback marker triggers resolution, so callers
|
||||
* can defer persisting `secret-placeholder.key` until a dynamic regex entry
|
||||
* actually matches session content.
|
||||
*/
|
||||
#key: string | undefined;
|
||||
#keyProvider: (() => string) | undefined;
|
||||
|
||||
constructor(entries: SecretEntry[], key: string = defaultPlaceholderKey()) {
|
||||
this.#key = key;
|
||||
// The keyed-hash key makes obfuscate-mode placeholder bases un-dictionaryable,
|
||||
// but it can be persisted in a user-readable file (`secret-placeholder.key`).
|
||||
// A prompt-injected tool read (read/bash) could otherwise surface it to the
|
||||
// provider verbatim and undo that protection, so redact the key itself from
|
||||
// obfuscated (provider-visible) output as a one-way secret.
|
||||
this.#replaceMappings.set(key, this.#generateSecretReplacement(key));
|
||||
this.#configuredSecretValues.add(key);
|
||||
constructor(entries: SecretEntry[], key: string | (() => string) = defaultPlaceholderKey()) {
|
||||
if (typeof key === "function") {
|
||||
this.#keyProvider = key;
|
||||
} else {
|
||||
this.#setPlaceholderKey(key);
|
||||
}
|
||||
// Collect every configured plain-secret literal AND compile every regex
|
||||
// entry BEFORE minting any placeholder below, so a placeholder's friendly
|
||||
// name (checked against both in `#createPlaceholder`) can never embed a
|
||||
@@ -671,6 +675,30 @@ export class SecretObfuscator {
|
||||
this.#hasAny = hasRealSec;
|
||||
}
|
||||
|
||||
/**
|
||||
* The keyed-hash key makes obfuscate-mode placeholder bases un-dictionaryable,
|
||||
* but it can be persisted in a user-readable file (`secret-placeholder.key`).
|
||||
* A prompt-injected tool read (read/bash) could otherwise surface it to the
|
||||
* provider verbatim and undo that protection, so redact the key itself from
|
||||
* obfuscated (provider-visible) output as a one-way secret.
|
||||
*/
|
||||
#setPlaceholderKey(key: string): void {
|
||||
this.#key = key;
|
||||
this.#replaceMappings.set(key, this.#generateSecretReplacement(key));
|
||||
this.#configuredSecretValues.add(key);
|
||||
}
|
||||
|
||||
/** Resolve the placeholder key once, minting its self-redaction on first use. */
|
||||
#getKey(): string {
|
||||
let key = this.#key;
|
||||
if (key === undefined) {
|
||||
key = this.#keyProvider?.() ?? defaultPlaceholderKey();
|
||||
this.#keyProvider = undefined;
|
||||
this.#setPlaceholderKey(key);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
hasSecrets(): boolean {
|
||||
return this.#hasAny;
|
||||
}
|
||||
@@ -937,7 +965,9 @@ export class SecretObfuscator {
|
||||
// remainder bytes that merely look sentinel-shaped (e.g. `ZZZZ`) cannot equal
|
||||
// the marker and are still redacted instead of passed through.
|
||||
const replacement =
|
||||
chunk.length <= 2 ? "Z".repeat(chunk.length) : `ZZ${buildKeyedReplacementRun(this.#key, chunk.length - 2)}`;
|
||||
chunk.length <= 2
|
||||
? "Z".repeat(chunk.length)
|
||||
: `ZZ${buildKeyedReplacementRun(this.#getKey(), chunk.length - 2)}`;
|
||||
this.#generatedReplaceChunks.add(replacement);
|
||||
return replacement;
|
||||
}
|
||||
@@ -1023,7 +1053,9 @@ export class SecretObfuscator {
|
||||
// the ordinary keyed-run fallback #generateReplacement already uses.
|
||||
replacement =
|
||||
stable ??
|
||||
(value.length <= 2 ? buildKeyedReplacementRun(this.#key, value.length) : this.#generateReplacement(value));
|
||||
(value.length <= 2
|
||||
? buildKeyedReplacementRun(this.#getKey(), value.length)
|
||||
: this.#generateReplacement(value));
|
||||
regex.lastIndex = 0;
|
||||
}
|
||||
this.#generatedReplaceChunks.add(replacement);
|
||||
@@ -1183,7 +1215,9 @@ export class SecretObfuscator {
|
||||
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const base =
|
||||
attempt === 0 ? buildHashBase(this.#key, baseKey) : buildHashBase(this.#key, `${baseKey}\0${attempt}`);
|
||||
attempt === 0
|
||||
? buildHashBase(this.#getKey(), baseKey)
|
||||
: buildHashBase(this.#getKey(), `${baseKey}\0${attempt}`);
|
||||
const owner = this.#placeholderBaseOwners.get(base);
|
||||
if (owner !== undefined && owner !== baseKey) continue;
|
||||
this.#placeholderBaseOwners.set(base, baseKey);
|
||||
@@ -1195,7 +1229,7 @@ export class SecretObfuscator {
|
||||
#reserveFallbackPlaceholderBase(baseKey: string, startAttempt: number): string {
|
||||
for (let attempt = startAttempt; ; attempt++) {
|
||||
const owner = `${baseKey}\0collision\0${attempt}`;
|
||||
const base = buildHashBase(this.#key, `${baseKey}\0collision\0${attempt}`);
|
||||
const base = buildHashBase(this.#getKey(), `${baseKey}\0collision\0${attempt}`);
|
||||
if (this.#placeholderBaseOwners.has(base)) continue;
|
||||
this.#placeholderBaseOwners.set(base, owner);
|
||||
return base;
|
||||
|
||||
Reference in New Issue
Block a user