feat(semantic): port the retrieval index — and stop it writing secrets to disk

The "semantic (vector search)" entry in not_yet_ported was a misnomer. It is
not vector search and needs no model: MODEL_ID is "seedpass-token-overlap-v1",
and it scores by Jaccard overlap over tokenized metadata. That is why it could
be ported at all — there is nothing to embed.

Porting it surfaced two defects in the Python implementation.

**Stored secrets were written to a plaintext, 0664 file.** `_extract_text`
appended a key_value entry's `value` — the stored secret itself — and
`build()` wrote it to semantic_index/records.json in the clear, along with a
tokenized copy that leaked it just as thoroughly. The file was created at the
process umask, so on a default install it was group- and world-readable, next
to a vault that is encrypted and 0600. Anything that could read the directory
— a backup, a sync client, another user on a shared machine — got the secret
without the master password.

Fixed in Python (index the key NAME, never the value; chmod 0600 on both
files) and never done in TypeScript. The rule is worth keeping simple: nobody
searches for a secret they do not already know, so indexing one is pure
downside. MODEL_ID is bumped to v2 so an index built by the old code is
distinguishable — a v1 index on disk still contains the secrets and should be
rebuilt.

**Entry 0 was silently unsearchable.** The check was
`int(entry.get("id", 0) or 0) <= 0`, conflating "no id" with "id 0" — and
entry ids start at 0, so the first entry any user ever created was absent from
every search result with nothing to indicate why. Found by writing a test that
queried for it. Fixed in both implementations: skip an id that is absent,
unparseable or negative, which is what the check was reaching for.

The TypeScript port is wired into the CLI (`semantic build/status/search`),
the API (the /api/v1/semantic routes are now real rather than 501), and
writes through atomicWrite for a genuine 0600 inode. Fixtures come from
scripts/generate_ts_port_fixtures.py, which asserts the leaked value is absent
from its own output, so the fixture-drift job proves provenance the way it
does for every other fixture.

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-19 09:54:53 -04:00
1 parent 51f1358da4
commit 39c4e45376
13 files changed
+1189 -11

No files matched your search

+114 -1
View File
@@ -9,7 +9,7 @@
import { Command } from "commander";
import process from "node:process";
import { entryMetadata, parseRef, resolveEntry } from "./refs.js";
import { entryMetadata, parseRef, resolveEntry, refFor } from "./refs.js";
import { materializeSecret } from "./secrets.js";
import { clipboardSink, execSink, parseCommandSpec, stdinSink } from "./sinks.js";
import { capabilities } from "./capabilities.js";
@@ -26,6 +26,11 @@ import {
parseBackupWrapper,
exportBackup,
findDerivationCollisions,
buildSemanticRecords,
searchSemanticRecords,
semanticManifest,
semanticStatus,
type SemanticRecord,
generateFingerprint,
assertValidMnemonic,
generateMnemonic,
@@ -1669,6 +1674,114 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
io.out(JSON.stringify({ status: "shutting down", host: o.host, port }));
});
const semantic = program
.command("semantic")
.description("local retrieval index over entry METADATA (never secrets)");
/**
* Records live in a plaintext file beside the vault, so the index holds
* only what is safe to leave unencrypted: labels, notes, tags, usernames,
* URLs, key names. Never a value. See buildSemanticRecords.
*/
async function semanticPaths(opts: GlobalOpts) {
const app = new AppDir(resolveAppDir(opts.appDir));
const fp = await currentFingerprint(app, opts);
const dir = join(app.profileDir(fp), "semantic_index");
return { dir, records: join(dir, "records.json"), manifest: join(dir, "manifest.json") };
}
async function readRecords(path: string): Promise<SemanticRecord[]> {
if (!existsSync(path)) return [];
try {
const data = JSON.parse(await readFile(path, "utf8"));
return Array.isArray(data) ? (data as SemanticRecord[]) : [];
} catch {
return [];
}
}
semantic
.command("build")
.description("(re)build the index from the current vault")
.action(async () => {
const opts = program.opts() as GlobalOpts;
const vault = await openFromOptions(opts);
const paths = await semanticPaths(opts);
const entries = Object.entries(vault.index.entries).map(([id, e]) => ({
...(e as object),
id: Number(id),
})) as Array<Record<string, unknown>>;
const records = buildSemanticRecords(entries);
await mkdir(paths.dir, { recursive: true });
// atomicWrite gives a fresh 0600 inode. Python created these at the
// process umask, which on a default install is 0664 — group and world
// readable, for a file sitting next to an encrypted vault.
await atomicWrite(
paths.records,
utf8(JSON.stringify(records, null, 2)),
);
await atomicWrite(
paths.manifest,
utf8(
JSON.stringify(
semanticManifest({
enabled: true,
built: true,
recordCount: records.length,
updatedAt: Date.now() / 1000,
}),
null,
2,
),
),
);
io.out(JSON.stringify({ built: records.length, index: paths.dir }));
});
semantic
.command("status")
.description("show whether the index exists and how current it is")
.action(async () => {
const opts = program.opts() as GlobalOpts;
const paths = await semanticPaths(opts);
let manifest: Record<string, unknown> = {};
if (existsSync(paths.manifest)) {
try {
manifest = JSON.parse(await readFile(paths.manifest, "utf8"));
} catch {
// A corrupt manifest reports as "not built" rather than failing:
// the fix is to rebuild, which the status is meant to prompt.
}
}
const records = await readRecords(paths.records);
io.out(JSON.stringify(semanticStatus(manifest, records.length), null, 2));
});
semantic
.command("search <query>")
.description("rank entries by metadata overlap (returns references, no secrets)")
.option("-k, --limit <n>", "maximum hits", "10")
.option("--kind <kind>", "restrict to one entry kind")
.action(async (query: string, o: { limit: string; kind?: string }) => {
const opts = program.opts() as GlobalOpts;
const paths = await semanticPaths(opts);
const records = await readRecords(paths.records);
if (records.length === 0) {
throw new Error("no semantic index; run 'semantic build' first");
}
const hits = searchSemanticRecords(records, query, {
k: parseIntOption(o.limit, "--limit", { min: 1 }),
...(o.kind !== undefined && { kind: o.kind }),
});
io.out(
JSON.stringify(
hits.map((h) => ({ ...h, ref: refFor(String(h.entry_id)) })),
null,
2,
),
);
});
const util = program.command("util").description("utility commands");
util