/** * SecretsManager, hierarchy-aware secret resolution and persistence. * * Resolution order: * 1. Environment variable (process.env[key]) * 2. Daemon stores (~/.goodvibes/daemon/secrets.enc, then secrets.json) * 3. Project/ancestor secure stores (.goodvibes//secrets.enc), nearest first * 4. Project/ancestor plaintext stores (.goodvibes/.secrets.json), nearest first * 5. User secure store (~/.goodvibes//secrets.enc) * 6. User plaintext store (~/.goodvibes/.secrets.json) * 7. If a resolved value is a SecretRef, resolve through the referenced provider * * The three scopes and the files behind them are documented in * secrets-store-paths.ts. The daemon tier leads because a credential the daemon * executes with has one home and a surface silo can only hold a stale copy of * it; the tier is empty until something writes to it, so a store with no daemon * secrets resolves exactly as it did before the tier existed. * * The active policy decides whether plaintext stores are eligible: * - plaintext_allowed → read/write plaintext or secure * - preferred_secure → prefer secure, allow plaintext fallback with warning * - require_secure → never read/write plaintext * * Encryption keys come from a random keyfile (~/.goodvibes/secrets.key, * 0600 in a 0700 directory), generated on first need, never from host * identity, so stores survive hostname/username changes and machine moves. * Stores written by older SDKs (host-identity key, no version field) are * migrated to the keyfile format on first successful read. A store that * exists but cannot be decrypted is a distinct, surfaced error state, it is * never treated as empty and never overwritten. * * Secret values are never logged. */ import type { ConfigManager } from './manager.js'; export { SecretStoreUnreadableError } from './secrets-keyfile.js'; export type SecretStorageMode = 'plaintext_allowed' | 'preferred_secure' | 'require_secure'; export type { SecretScope, SecretSource, SecretStorageMedium, SecretStorePath, } from './secrets-store-paths.js'; import type { SecretScope, SecretSource, SecretStorageMedium } from './secrets-store-paths.js'; export interface SecretRecord { readonly key: string; readonly source: SecretSource; readonly scope: SecretScope | 'env'; readonly secure: boolean; readonly path?: string | undefined; readonly overriddenByEnv: boolean; readonly refSource?: string | undefined; } export interface SecretWriteOptions { readonly scope?: SecretScope | undefined; readonly medium?: SecretStorageMedium | undefined; } export interface SecretDeleteOptions { readonly scope?: SecretScope | undefined; readonly medium?: SecretStorageMedium | undefined; } export interface SecretStorageReview { readonly policy: SecretStorageMode; readonly secureAvailable: boolean; readonly storedKeys: number; readonly envBackedKeys: number; readonly secureKeys: number; readonly plaintextKeys: number; readonly warnings: readonly string[]; readonly locations: readonly { readonly source: Exclude; readonly path: string; readonly exists: boolean; readonly readable: boolean; }[]; } /** * Identity used only to decrypt legacy stores (written before keyfile-derived * encryption existed). Overridable so tests can simulate stores written on a * machine with a different hostname/username. */ export interface LegacyStoreIdentity { readonly hostname: string; readonly username: string; } export interface SecretsManagerOptions { readonly projectRoot: string; readonly globalHome: string; readonly surfaceRoot: string; /** * The daemon's state root, holding the daemon-scoped stores. Defaults to * `/.goodvibes/daemon`; a caller that honors `--daemon-home` or * `GOODVIBES_DAEMON_HOME` resolves it first and passes it here, so the daemon * and its clients agree on one file. */ readonly daemonHome?: string | undefined; readonly configManager?: Pick | undefined; readonly policy?: SecretStorageMode | undefined; readonly secureProjectFilePath?: string | undefined; readonly secureUserFilePath?: string | undefined; readonly secureDaemonFilePath?: string | undefined; readonly plaintextProjectFilePath?: string | undefined; readonly plaintextUserFilePath?: string | undefined; readonly plaintextDaemonFilePath?: string | undefined; /** Override the keyfile location (defaults to /.goodvibes/secrets.key). */ readonly keyFilePath?: string | undefined; /** Override the host identity used to decrypt legacy stores (tests only). */ readonly legacyIdentity?: LegacyStoreIdentity | undefined; } /** * Where a write to `key` will actually go, given what the caller asked for. * * **Daemon ownership beats an explicit scope, deliberately.** It used to be the * other way round, an explicit `scope` won, and that made the routing above * defeatable by the ordinary path a person takes to store a credential: * `/secrets set` passes a scope on every call, so a daemon-owned password went * into a client silo the daemon never reads. The credential reported success * and did nothing, which is the exact failure the ownership rule exists to * prevent. * * The alternative, refusing the write and telling the caller to pick a * different scope, was rejected: it turns a storage bug into a wall in front * of the credentials people most need to set, and the caller's scope argument * is nearly always a default it never thought about rather than an intent. * * So the write is honoured and RELOCATED, never dropped, and the relocation is * disclosed rather than silent: `set()` logs it naming both scopes, and this * function is exported so a surface can tell the operator where a credential is * going BEFORE it asks for it. */ export declare function resolveSecretWriteScope(key: string, requested?: SecretScope | undefined): SecretScope; /** True when `requested` would have sent a daemon-owned credential somewhere the daemon cannot read. */ export declare function secretWriteScopeWasOverridden(key: string, requested?: SecretScope | undefined): boolean; /** Why `key` was filed where it was. Safe to display: names only, never values. */ export declare function describeSecretWriteScope(key: string): string; export declare class SecretsManager { /** Change listeners, fired after a successful set() or delete() so credential consumers (e.g. the provider registry) re-resolve LIVE, no restart. */ private readonly changeListeners; /** Subscribe to secret writes/deletes. Returns an unsubscribe function. */ onDidChange(listener: (key: string) => void): () => void; private notifyChanged; private encKey; private readonly keyFilePath; private readonly options; private readonly surfaceRoot; private readonly reportedUnreadableStores; private readonly layout; constructor(options: SecretsManagerOptions); /** Load the encryption key, generating a fresh one exclusively on first need, see secrets-keyfile.ts. */ private getEncryptionKey; getGlobalHome(): string; get(key: string): Promise; /** * Read `key` from ONE tier, ignoring the read order and the environment. * * `get()` answers "what value would be used", which is the right question * almost everywhere and the wrong one for migration: a surface copy read * through `get()` returns whatever the DAEMON tier holds, because the daemon * tier leads. Moving a credential needs to see each tier separately, read * the surface copy, write the daemon copy, read the daemon copy BACK and * compare, and a resolver that transparently prefers one tier makes that * comparison meaningless. * * Returns the value exactly as stored. A `goodvibes://` reference is NOT * followed: migration moves the stored bytes, and following a reference here * would copy the pointed-at value over the pointer. */ getFromScope(key: string, scope: SecretScope, storePath?: string): Promise; private getInternal; private resolveMaybeReferencedValue; set(key: string, value: string, options?: SecretWriteOptions): Promise; /** * Load a store's current contents ahead of a write. A missing file is a * legitimately empty store; a file that exists but cannot be read refuses * the write outright, overwriting it would destroy every secret it holds. */ private readStoreForWrite; list(): Promise; listDetailed(): Promise; inspect(): Promise; /** * REVOKE. Removes the credential everywhere it can be reached. * * For a daemon-needed key the caller's `scope` is deliberately DISCARDED and * every tier is swept, because a revoke narrowed to one tier reports success * while leaving a live copy behind, a credential the operator believes is * gone and is not. * * That makes this the wrong method for moving a credential between tiers, and * the difference is not visible at the call site: the migration's "remove the * surface copy now the daemon copy is verified" was spelled * `delete(key, { scope: source })`, the key was daemon-needed by definition, * so the sweep ran and destroyed the daemon copy it had just written and * read back. Both stores ended empty while the report said `migrated: 1, * failed: 0`. Use `deleteFromScope` to remove ONE physical copy. */ delete(key: string, options?: SecretDeleteOptions): Promise; /** * Remove ONE tier's copy, and never any other. The narrow counterpart to * `delete`: that one means "this credential is revoked" and sweeps every * tier; this means "this copy is redundant, the real one is elsewhere". * A separate method because the two were indistinguishable at the call site, * and the migration reached for the wrong one. Migration is the only caller. */ deleteFromScope(key: string, scope: SecretScope, storePath?: string): Promise; private getPolicy; private getReadOrder; private getAllCandidateStores; /** Every store a MIGRATION may look in, including other surfaces' silos. */ private getMigratableStores; /** Every credential a migration could move, across every surface's silo. */ listDetailedForMigration(): Promise; private resolveWriteTarget; private getDefaultWriteMedium; /** * Read an encrypted store with three distinct outcomes: `ok` (decrypted), * `missing` (no file, a legitimately empty store), and `unreadable` (a file * exists but cannot be decrypted or parsed). Unreadable is never collapsed * into empty: writes to an unreadable store are refused so its contents are * never destroyed. * * Legacy stores (no `version` field, host-identity key) are migrated in * place on first successful read: decrypted with the legacy key, then * re-encrypted under the keyfile. */ private readEncryptedStore; private migrateLegacyStore; /** * Lookup-flavored read: returns the secrets when readable, null otherwise. * An unreadable store logs one honest error per file per process; it is * never mistaken for an empty store on the write path (see set/delete). */ private readEncryptedFile; private reportUnreadableStore; private writeEncryptedFile; private readPlaintextStore; private readPlaintextFile; private writePlaintextFile; } //# sourceMappingURL=secrets.d.ts.map