fix(derive): the profile config's password policy is the base again (M-2)

Python builds its PasswordGenerator with
`policy=self.config_manager.get_password_policy()` and merges an entry's own
`policy` block over that base per derivation. The port merged the entry block
over hardcoded defaults and never read the profile config at all, so every
entry in a profile with a non-default policy derived a *different password*
than Python. Nothing failed anywhere; it reached the user as a wrong password,
which is indistinguishable from data loss at the point of use.

materializeSecret now takes a `basePolicy` and merges the entry block over it.
All three callers supply the profile config's policy:

  - program.ts via basePolicyForOptions, resolved lazily so `entry list` and
    friends don't pay a second BIP-39 seed derivation for a policy they never
    use. `--vault` names an index file rather than a profile, so the config is
    looked for beside it and falls back to defaults when absent.
  - agent.ts from join(appDir, fingerprint), so a token-scoped reveal returns
    what the owner sees.
  - the TUI through a new sessionSecret helper that every menu path goes
    through, so a future call site cannot silently omit the base.

`util generate-password` is deliberately unchanged: it has no entry and no
profile, so its flags are the whole policy. It now says so in its own help
text rather than leaving the reader to infer it.

Evidence: js/packages/cli/test/passwordPolicy.test.ts covers config-only v2,
an entry overriding exactly one field, config-only v1 and exclude_ambiguous,
against values computed by the Python implementation — each also asserted
unequal to what the pre-fix code produced, so a regression fails loudly rather
than needing someone to notice a changed constant. Mutation-verified twice:
dropping basePolicy from the merge in secrets.ts, and making
basePolicyForOptions return {}, each turn all four red.

Found by the 2026-08-18 independent audit (js/SECURITY_AUDIT.md). The parity
fixture suite could not have caught this: those fixtures call generatePassword
with one already-resolved policy, so they prove the algorithm and say nothing
about where the policy came from.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
This commit is contained in:
thePR0M3TH3ANandClaude Opus 5 committed 2026-08-18 21:38:19 -04:00
1 parent f3c3d73c3d
commit 063fae191f
8 files changed
+376 -19

No files matched your search

+45
View File
@@ -232,6 +232,51 @@ before and after each was confirmed. Ordered by severity.
non-existent ids exhausts a token. That ordering is deliberate
anti-enumeration; confirm it is the trade wanted and write it down.
### Findings from the 2026-08-18 independent audit
Full report: [`js/SECURITY_AUDIT.md`](js/SECURITY_AUDIT.md) (Fable 5, line-by-line read
of every non-test source file at `e9d8908`). Verdict: no criticals or highs. This is one
AI family; the GPT-family pass in the blocker above is still outstanding and is what makes
the review independent in the sense that matters.
- [x] **(fixed 2026-08-18) M-2 — the profile config's password policy was ignored.**
Python bases derivation on `config_manager.get_password_policy()` and merges the
entry's `policy` block over it; the port merged the entry block over hardcoded
defaults and never read the config. Any profile with a non-default policy therefore
derived **different passwords** in TS than in Python, for every entry — reaching the
user as a wrong password, i.e. as data loss, with nothing failing anywhere.
`materializeSecret` now takes a `basePolicy`; the CLI, the agent and the TUI all
supply the profile config's policy, the TUI through a `sessionSecret` helper so no
menu path can omit it. Evidence:
`js/packages/cli/test/passwordPolicy.test.ts`, four cases against Python-computed
values, each also asserted unequal to the pre-fix output; mutation-verified (drop
`basePolicy` from the merge and all four go red).
- [ ] **M-1 — restore can return a stale snapshot when two syncs land in the same second.**
Manifests sort by `created_at` (whole-second Nostr resolution) with the event id as
tie-break, which is arbitrary with respect to time, so the older of two same-second
snapshots wins roughly half the time and `nostr restore` silently drops the entries
created between them. Reproduces as the intermittent failure of "syncs the vault to
the relay and restores after local destruction" (~1 in 4 runs). **This one is a
protocol change, not a local fix:** the fix is a monotonic sequence number inside the
signed manifest, used as the secondary sort key, and it has to land in Python at the
same time or the two implementations will disagree about which snapshot is latest.
Decide the wire format before writing code.
- [ ] **L-1 — BIP-85 app-32 path is shared across password, SSH and PGP derivation.**
Protocol-level, both implementations, needs versioning. Same care as M-1.
- [ ] **L-2 / L-3 — merge-level data loss that is silent rather than wrong:** concurrent
same-id creation on two replicas discards one entry at merge; the 2048-entry
tombstone cap allows deletion replay. Both are arguably working as designed; the fix
for each is to surface the event rather than to change the merge.
- [ ] **L-4 — remaining inert settings.** `additional_backup_path` is stored and confirmed
by the TUI and used by nothing. Same class as the five fixed on 2026-08-17: either
wire it up or have the menu say it is unavailable.
- [ ] **L-5 / L-6 / L-7 — agent boundary hardening.** Anchor `label_regex` (unanchored
today, so a token's scope is substring-matched and wider than it reads); document
that an exec allowlist constrains the command word and not its arguments; validate
the wire-supplied `fingerprint` in the daemon's `put` before using it as a path
component. All owner-gated, none live holes.
- [ ] **L-8 — plaintext backup import is not bound to a profile.**
### Cutover
- [ ] Fold the CLI bundle's `.sha256` into the `release-integrity` signing
+14 -1
View File
@@ -49,7 +49,7 @@ failure is flaky and is the direct evidence for finding M-1.
fix must land on the Python side too, or the two implementations will disagree on which
snapshot is "latest."
### M-2 (Medium) — Config-level password policy is ignored; same entry derives different passwords in TS vs Python
### M-2 (Medium) — Config-level password policy is ignored; same entry derives different passwords in TS vs Python — **FIXED 2026-08-18**
- **Where:** `js/packages/core/src/derive/password.ts:53-64` (`resolvePolicy` hardcodes
defaults); `js/packages/cli/src/secrets.ts:43-49` (only the entry's own `policy` block is
@@ -69,6 +69,19 @@ failure is flaky and is the direct evidence for finding M-1.
`materializeSecret`/`generatePassword` as the base policy before applying the entry's
overrides, and add a cross-implementation fixture with a non-default config policy —
the current parity suite does not cover this case, which is why it passed.
- **Fix (2026-08-18):** `materializeSecret` takes a `basePolicy` option and merges the
entry's block over it. All three callers supply the profile config's policy:
`program.ts` via `basePolicyForOptions` (which reads the config beside the index when
`--vault` names one directly), `agent.ts` via `loadConfig(join(appDir, fingerprint))`,
and the TUI via a new `sessionSecret` helper that every menu path now goes through, so
a future call site cannot silently omit the base. `util generate-password` is
deliberately unchanged and now documents itself as profile-less: it has no entry and no
profile, so its flags are the whole policy.
- **Evidence:** `js/packages/cli/test/passwordPolicy.test.ts` — four cases against values
computed by the Python implementation (config-only v2, entry-overrides-one-field v2,
config-only v1, and exclude_ambiguous), each also asserted *unequal* to the value the
pre-fix code produced. Mutation-verified: dropping `basePolicy` from the merge in
`secrets.ts` turns all four red.
### L-1 (Low) — BIP-85 app-32 path is shared across password, SSH, and PGP derivation
+7
View File
@@ -41,6 +41,7 @@ import {
type SinkResult,
} from "./sinks.js";
import { AuditLog } from "./audit.js";
import { loadConfig, passwordPolicyFromConfig } from "./configFile.js";
import { INDEX_FILENAME } from "./appDir.js";
export const DEFAULT_TTL_SECONDS = 900;
@@ -366,8 +367,14 @@ export class AgentDaemon {
}
ts = requested;
}
// Password derivation bases the policy on the profile config and merges
// the entry's own block over it. The daemon reads the same config the TUI
// and CLI do, so a token-scoped `reveal` returns the same password the
// owner sees rather than one derived from the built-in defaults.
const config = await loadConfig(join(this.appDir, fingerprint), held.mnemonic);
const secret = materializeSecret(vault.index, id, entry, held.mnemonic, {
...(ts !== undefined && { timestamp: ts }),
basePolicy: passwordPolicyFromConfig(config),
});
return { entry, id, token: auth.token, secret };
}
+19
View File
@@ -11,7 +11,9 @@ import {
deriveIndexKeyBytes,
encryptV3,
parseEncryptedFile,
passwordPolicyFromRecord,
utf8,
type PasswordPolicy,
} from "@seedpass/core";
import { CONFIG_FILENAME } from "./appDir.js";
import { atomicWrite, withVaultLock } from "./vaultFile.js";
@@ -48,6 +50,23 @@ export function defaultConfig(): Record<string, unknown> {
};
}
/**
* The profile's password policy, as the BASE that entry `policy` blocks
* override.
*
* Parity with ConfigManager.get_password_policy: the config keys carry the
* same snake_case names as an entry's policy block, so the entry parser reads
* both. Keys absent from the config stay absent here rather than being
* materialized as defaults — that is what lets an entry override exactly the
* fields it names and inherit the rest, matching Python's
* `dataclasses.replace(base, **overrides)`.
*/
export function passwordPolicyFromConfig(
config: Record<string, unknown>,
): PasswordPolicy {
return passwordPolicyFromRecord(config);
}
export async function loadConfig(
profileDir: string,
mnemonic: string,
+45 -4
View File
@@ -151,7 +151,12 @@ function resolveHome(p: string): string {
return p.startsWith("~") ? join(homedir(), p.slice(1)) : p;
}
import { AppDir, resolveAppDir, INDEX_FILENAME, BACKUP_EXTENSION, defaultBackupFilename } from "./appDir.js";
import { loadConfig, saveConfig, mutateConfig } from "./configFile.js";
import {
loadConfig,
saveConfig,
mutateConfig,
passwordPolicyFromConfig,
} from "./configFile.js";
import { AgentClient, AgentDaemon, agentSocketPath, DEFAULT_TTL_SECONDS } from "./agent.js";
import { AuditLog } from "./audit.js";
import { runTui } from "./tui/app.js";
@@ -235,6 +240,29 @@ async function openFromOptions(opts: GlobalOpts): Promise<OpenedVault> {
return openVault(path, await resolveMnemonic(app, opts));
}
/**
* The profile config's password policy, which entry `policy` blocks override.
*
* Parity with Python, which constructs its PasswordGenerator with
* `policy=self.config_manager.get_password_policy()` and merges the entry's
* block over that base. Deriving from the built-in defaults instead produces
* a different password for every entry in a profile whose configured policy
* is not the default — indistinguishable from data loss at the point of use.
*
* `--vault` names an index file rather than a profile, so the config is
* looked for beside it; loadConfig returns defaults when none is there.
*/
async function basePolicyForOptions(
opts: GlobalOpts,
mnemonic: string,
): Promise<PasswordPolicy> {
const app = new AppDir(resolveAppDir(opts.appDir));
const profileDir = opts.vault
? dirname(resolveHome(opts.vault))
: app.profileDir(await currentFingerprint(app, opts));
return passwordPolicyFromConfig(await loadConfig(profileDir, mnemonic));
}
/**
* Run a mutation under the vault lock, holding it for the whole
* read-modify-write cycle.
@@ -351,11 +379,16 @@ async function openReadAccess(opts: GlobalOpts): Promise<ReadAccess> {
const vault = await openFromOptions(opts);
const localRows = async () =>
Object.entries(vault.index.entries).map(([id, e]) => entryMetadata(id, e));
const localSecret = (id: string, timestamp?: number) => {
// Resolved on first use, not up front: reading the config costs another
// BIP-39 seed derivation, and `entry list` and friends never need a policy.
let basePolicy: PasswordPolicy | undefined;
const localSecret = async (id: string, timestamp?: number) => {
const entry = vault.index.entries[id];
if (!entry) throw new Error(`no entry ${id}`);
basePolicy ??= await basePolicyForOptions(opts, vault.mnemonic);
return materializeSecret(vault.index, id, entry, vault.mnemonic, {
...(timestamp !== undefined && { timestamp }),
basePolicy,
});
};
return {
@@ -367,7 +400,7 @@ async function openReadAccess(opts: GlobalOpts): Promise<ReadAccess> {
},
reveal: async (id, timestamp) => localSecret(id, timestamp),
deliver: async (id, sink, command, timestamp) => {
const secret = localSecret(id, timestamp);
const secret = await localSecret(id, timestamp);
const result =
sink === "clipboard"
? await clipboardSink(secret.value)
@@ -1426,7 +1459,15 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
util
.command("generate-password")
.description("derive a password (Python parity: index 0, gen v1 by default)")
// Deliberately profile-less: this derives from SEEDPASS_MNEMONIC and the
// flags alone, never from a profile's configured policy. Unlike
// `entry reveal`, there is no entry and no profile to read a base policy
// from, so the flags ARE the policy — an unflagged run gives the built-in
// defaults, not whatever the active profile happens to be configured for.
.description(
"derive a password from the flags alone, ignoring any profile's " +
"configured policy (Python parity: index 0, gen v1 by default)",
)
.option("--length <n>", "password length", "24")
.option("--index <n>", "derivation index", "0")
.option("--gen-version <v>", "generation version (1 or 2)", "1")
+15 -5
View File
@@ -17,6 +17,7 @@ import {
deriveSshKeyPair,
derivePgpKey,
type Entry,
type PasswordPolicy,
type VaultIndex,
} from "@seedpass/core";
@@ -33,19 +34,28 @@ export function materializeSecret(
id: string,
entry: Entry,
mnemonic: string,
options: { timestamp?: number } = {},
options: { timestamp?: number; basePolicy?: PasswordPolicy } = {},
): MaterializedSecret {
switch (entry.kind) {
case "password": {
// The entry's own policy block must be honored — Python merges it over
// the base policy before deriving, so ignoring it yields a different
// password for any entry created with policy flags.
// Policy resolves in three layers, and all three must be present or the
// derived password is simply wrong. Python builds its PasswordGenerator
// with the profile config's policy as the base
// (manager.py: policy=self.config_manager.get_password_policy()) and
// merges the entry's own `policy` block over it per derivation
// (_generate_password_for_entry). `basePolicy` is that config base;
// omitting it silently falls back to the built-in defaults, which is
// what made any profile with a non-default config policy derive
// different passwords here than in Python.
const bip85 = Bip85.fromMnemonic(mnemonic);
const value = generatePassword(bip85, {
length: entry.length,
index: Number(id),
genVersion: entry.gen_version ?? 1,
policy: passwordPolicyFromRecord((entry as { policy?: unknown }).policy),
policy: {
...(options.basePolicy ?? {}),
...passwordPolicyFromRecord((entry as { policy?: unknown }).policy),
},
});
return { value, descriptor: `password for ${entry.label}` };
}
+34 -9
View File
@@ -44,9 +44,14 @@ import {
import { AppDir, INDEX_FILENAME, DEFAULT_PBKDF2_ITERATIONS, BACKUP_EXTENSION, defaultBackupFilename } from "../appDir.js";
import { atomicWrite, openVault, saveVaultHoldingLock, withVaultLock, type OpenedVault } from "../vaultFile.js";
import { entryMetadata, refFor } from "../refs.js";
import { materializeSecret } from "../secrets.js";
import { materializeSecret, type MaterializedSecret } from "../secrets.js";
import { clipboardSink } from "../sinks.js";
import { loadConfig, mutateConfig, DEFAULT_RELAYS } from "../configFile.js";
import {
loadConfig,
mutateConfig,
passwordPolicyFromConfig,
DEFAULT_RELAYS,
} from "../configFile.js";
import {
ansi,
confirm,
@@ -87,6 +92,28 @@ export interface Session {
// ---------------------------------------------------------------- utilities
/**
* materializeSecret bound to this session's profile config.
*
* Every TUI path that turns an entry into a secret must go through here.
* Password derivation takes the profile config's policy as its base and
* merges the entry's own block over it; a call site that reaches for
* materializeSecret directly loses the config base and silently derives a
* different password than Python for any profile whose policy is not the
* default.
*/
function sessionSecret(
s: Session,
id: string,
entry: Entry,
options: { timestamp?: number } = {},
): MaterializedSecret {
return materializeSecret(s.vault.index, id, entry, s.vault.mnemonic, {
...options,
basePolicy: passwordPolicyFromConfig(s.config),
});
}
function title(s: Session, breadcrumb: string): void {
header(s.ui, s.fingerprint, s.name, breadcrumb);
}
@@ -423,7 +450,7 @@ async function addPassword(s: Session): Promise<boolean> {
});
// Derived, not stored: show it once here the way Python does.
const entry = s.vault.index.entries[id] as Entry;
const secret = materializeSecret(s.vault.index, id, entry, s.vault.mnemonic);
const secret = sessionSecret(s, id, entry);
await reveal(s, secret.value, `Password for ${label}`);
await announce(s, id, "Password");
return true;
@@ -711,7 +738,7 @@ async function entryDetails(s: Session, id: string): Promise<void> {
switch (choice) {
case "s":
case "c": {
const secret = materializeSecret(s.vault.index, id, entry, s.vault.mnemonic);
const secret = sessionSecret(s, id, entry);
if (choice === "c") {
try {
const r = await copyToClipboard(s, secret.value);
@@ -864,7 +891,7 @@ async function showQr(s: Session, id: string, entry: Entry): Promise<void> {
// dependency in a process that holds unlocked seeds — say so plainly and
// offer the underlying value instead.
warn(s.ui, "QR rendering is not available in this build.");
const secret = materializeSecret(s.vault.index, id, entry, s.vault.mnemonic);
const secret = sessionSecret(s, id, entry);
if (await confirm(s.ui, "Show the value it would encode instead?")) {
await reveal(s, secret.value, secret.descriptor);
}
@@ -874,7 +901,7 @@ async function showQr(s: Session, id: string, entry: Entry): Promise<void> {
async function exportDocument(s: Session, id: string, entry: Entry): Promise<void> {
const out = await s.ui.ask("Output path: ");
if (!out) return;
const secret = materializeSecret(s.vault.index, id, entry, s.vault.mnemonic);
const secret = sessionSecret(s, id, entry);
try {
if (await writeSecretFile(s, out, secret.value)) {
ok(s.ui, `Document exported to: ${out}`);
@@ -914,9 +941,7 @@ async function totpCodes(s: Session): Promise<void> {
for (const { id, entry } of rows) {
const period = Number(entry.period ?? 30);
const remaining = period - (now % period);
const secret = materializeSecret(s.vault.index, id, entry, s.vault.mnemonic, {
timestamp: now,
});
const secret = sessionSecret(s, id, entry, { timestamp: now });
const code = secretModeOn(s) ? "".padEnd(6, "•") : secret.value;
s.ui.say(
` ${ansi.cyan}${String(entry.label)}${ansi.reset} ${ansi.bold}${code}${ansi.reset} ` +
+197
View File
@@ -0,0 +1,197 @@
/**
* Password policy layering: profile config is the BASE, the entry's own
* `policy` block overrides it per field.
*
* This is the case the parity fixture suite could not catch. Those fixtures
* call generatePassword with one already-resolved policy, so they prove the
* algorithm and say nothing about where the policy came from. The port read
* the entry's block over hardcoded defaults and never read the profile
* config at all, so every entry in a profile with a non-default policy
* derived a different password than Python — presenting to the user as a
* wrong password, i.e. as data loss, with nothing failing anywhere.
*
* The expected values below are ground truth computed by the Python
* implementation, not by this one:
*
* pg = PasswordGenerator(_SeedDeriver(), MNEMONIC, bip85, policy=<policy>)
* pg.generate_password(length=20, index=<i>, gen_version=<v>)
*
* with <policy> = PasswordPolicy(**CONFIG_POLICY) for an entry carrying no
* block, and dataclasses.replace(base, **entry_block) for one that does —
* mirroring manager.py's _generate_password_for_entry.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import process from "node:process";
import { mnemonics } from "@seedpass/test-vectors";
import {
deriveIndexKeyBytes,
encryptV3,
generateFingerprint,
utf8,
} from "@seedpass/core";
import { buildProgram, type ProgramIo } from "../src/index.js";
const MNEMONIC = mnemonics["abandon12"]!;
const FINGERPRINT = generateFingerprint(MNEMONIC);
const PASSWORD = "policy-test-password";
/** A deliberately non-default profile policy — every field differs. */
const CONFIG_POLICY = {
min_uppercase: 5,
min_lowercase: 1,
min_digits: 4,
min_special: 0,
exclude_ambiguous: true,
};
/** Python, policy = the config above, entry carries no policy block. */
const PY_CONFIG_ONLY_V2 = "27osg$|$$,W*}UFyPL85";
/** Same config policy, entry id 2, gen_version 1. */
const PY_CONFIG_ONLY_V1 = "WL2c'A&\"G62sm:Pg*47d";
/** Python, policy = config with min_digits overridden to 1 by the entry. */
const PY_ENTRY_OVERRIDE_V2 = "!@`\\8Tfn`]]H$SH2cx7V";
/**
* What the port produced before the fix: the same entries derived against
* built-in defaults instead of the profile config. Asserted as inequality so
* a regression that quietly drops the config base fails loudly rather than
* needing someone to notice a changed constant.
*/
const DEFAULTS_ONLY_V2 = "SXnmg8]83'O9?RExHE3V";
const DEFAULTS_PLUS_ENTRY_V2 = "3r/G3G\\[<5~/hK},cf2N";
const DEFAULTS_ONLY_V1 = "+y:2Acu06&?K[j9aCG8K";
let appDir: string;
interface RunResult {
stdout: string;
error?: unknown;
}
async function run(
env: Record<string, string | undefined>,
...argv: string[]
): Promise<RunResult> {
const saved: Record<string, string | undefined> = {};
for (const [k, v] of Object.entries(env)) {
saved[k] = process.env[k];
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
const out: string[] = [];
const io: ProgramIo = { out: (l: string) => out.push(l), err: () => {} };
let error: unknown;
try {
await buildProgram(io).parseAsync([
"node", "seedpass-js", "--app-dir", appDir, ...argv,
]);
} catch (e) {
error = e;
} finally {
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
}
return { stdout: out.join("\n"), error };
}
function passwordEntry(
label: string,
extra: Record<string, unknown> = {},
): Record<string, unknown> {
return {
kind: "password",
type: "password",
label,
length: 20,
username: "",
url: "",
notes: "",
tags: [],
links: [],
custom_fields: [],
archived: false,
date_added: "2023-11-14T22:13:20+00:00",
date_modified: "2023-11-14T22:13:20+00:00",
modified_ts: 1700000000,
gen_version: 2,
...extra,
};
}
beforeAll(async () => {
appDir = await mkdtemp(join(tmpdir(), "seedpass-policy-"));
// Real profile, created the way a user creates one.
await run(
{ SEEDPASS_MNEMONIC: MNEMONIC, SEEDPASS_PASSWORD: PASSWORD },
"fingerprint", "add", "--name", "policy",
);
// Set the policy through the real config command, so the test covers the
// path a user actually takes rather than a hand-written config file.
for (const [key, value] of Object.entries(CONFIG_POLICY)) {
await run({ SEEDPASS_MNEMONIC: MNEMONIC }, "config", "set", key, String(value));
}
const index = {
schema_version: 4,
_sync_meta: { next_index: 3 },
entries: {
// 0: no policy block at all -> derives purely from the config policy.
"0": passwordEntry("config-base.example"),
// 1: overrides exactly one field; the other four must still come from
// the config, which is what distinguishes layering from replacement.
"1": passwordEntry("entry-override.example", { policy: { min_digits: 1 } }),
// 2: same as 0 but frozen v1 -- the legacy algorithm reads the same
// resolved policy, so the config base must reach it too.
"2": passwordEntry("legacy-v1.example", { gen_version: 1 }),
},
};
const key = deriveIndexKeyBytes(MNEMONIC);
await writeFile(
join(appDir, FINGERPRINT, "seedpass_entries_db.json.enc"),
await encryptV3(key, utf8(JSON.stringify(index))),
);
});
afterAll(() => {
delete process.env["SEEDPASS_MNEMONIC"];
});
describe("profile config is the base password policy", () => {
it("derives an unblocked entry from the config policy, matching Python", async () => {
const r = await run({ SEEDPASS_MNEMONIC: MNEMONIC }, "entry", "reveal", "config-base.example");
expect(r.error).toBeUndefined();
expect(r.stdout).toBe(PY_CONFIG_ONLY_V2);
expect(r.stdout).not.toBe(DEFAULTS_ONLY_V2);
});
it("layers the entry's policy block over the config rather than replacing it", async () => {
const r = await run(
{ SEEDPASS_MNEMONIC: MNEMONIC }, "entry", "reveal", "entry-override.example",
);
expect(r.error).toBeUndefined();
expect(r.stdout).toBe(PY_ENTRY_OVERRIDE_V2);
// Would be the answer if the entry block replaced the config base instead
// of merging over it.
expect(r.stdout).not.toBe(DEFAULTS_PLUS_ENTRY_V2);
});
it("applies the config base to frozen v1 entries too", async () => {
const r = await run({ SEEDPASS_MNEMONIC: MNEMONIC }, "entry", "reveal", "legacy-v1.example");
expect(r.error).toBeUndefined();
expect(r.stdout).toBe(PY_CONFIG_ONLY_V1);
expect(r.stdout).not.toBe(DEFAULTS_ONLY_V1);
});
it("honours exclude_ambiguous from the config", async () => {
const r = await run({ SEEDPASS_MNEMONIC: MNEMONIC }, "entry", "reveal", "config-base.example");
expect(r.stdout).not.toMatch(/[O0Il1]/);
});
});