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

+84 -2
View File
@@ -8,7 +8,7 @@
* is the part nobody notices — which secret a given entry produces.
*/
import { readFile, stat } from "node:fs/promises";
import { readFile, mkdir } from "node:fs/promises";
import { existsSync } from "node:fs";
import { basename, dirname, join, resolve as resolvePath } from "node:path";
import {
@@ -38,6 +38,11 @@ import {
passwordPolicyFromRecord,
removeLink,
restoreEntry,
buildSemanticRecords,
searchSemanticRecords,
semanticManifest,
semanticStatus,
type SemanticRecord,
totpCodeAt,
Bip85,
type Entry,
@@ -64,7 +69,6 @@ export const UNPORTED_PREFIXES: Array<{ prefix: string; feature: string }> = [
{ prefix: "/api/v1/high-risk", feature: "high-risk partitions" },
{ prefix: "/api/v1/agent/job-profiles", feature: "agent job profiles" },
{ prefix: "/api/v1/agent/recovery", feature: "agent recovery split" },
{ prefix: "/api/v1/semantic", feature: "the semantic (vector search) index" },
];
export interface ApiContext {
@@ -812,6 +816,84 @@ export function registerRoutes(server: ApiServer, ctx: ApiContext): void {
};
});
// --------------------------------------------------------------- semantic
const semanticDir = (): string => join(profileDir(ctx), "semantic_index");
const semanticRecordsPath = (): string => join(semanticDir(), "records.json");
const semanticManifestPath = (): string => join(semanticDir(), "manifest.json");
async function readSemanticRecords(): Promise<SemanticRecord[]> {
if (!existsSync(semanticRecordsPath())) return [];
try {
const data = JSON.parse(await readFile(semanticRecordsPath(), "utf8"));
return Array.isArray(data) ? (data as SemanticRecord[]) : [];
} catch {
return [];
}
}
async function buildSemantic(): Promise<number> {
const vault = await readVault(ctx);
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(semanticDir(), { recursive: true });
// The records file is plaintext, so it holds metadata only and is written
// through atomicWrite for a genuine 0600 inode.
await atomicWrite(semanticRecordsPath(), new TextEncoder().encode(JSON.stringify(records, null, 2)));
await atomicWrite(
semanticManifestPath(),
new TextEncoder().encode(
JSON.stringify(
semanticManifest({
enabled: true,
built: true,
recordCount: records.length,
updatedAt: ctx.now() / 1000,
}),
null,
2,
),
),
);
return records.length;
}
server.route("GET", "/api/v1/semantic/status", async () => {
let manifest: Record<string, unknown> = {};
if (existsSync(semanticManifestPath())) {
try {
manifest = JSON.parse(await readFile(semanticManifestPath(), "utf8"));
} catch {
// Corrupt manifest reports as not-built; the fix is a rebuild.
}
}
return { json: semanticStatus(manifest, (await readSemanticRecords()).length) };
});
for (const path of ["/api/v1/semantic/build", "/api/v1/semantic/rebuild"]) {
server.route("POST", path, async () => {
// Build and rebuild are the same operation here: the records file is
// rewritten wholesale either way, so there is no stale state to clear.
return { json: { status: "ok", records: await buildSemantic() } };
});
}
server.route("POST", "/api/v1/semantic/search", async (req) => {
const body = bodyObject(req);
const records = await readSemanticRecords();
if (records.length === 0) {
throw new HttpError(409, "no semantic index; POST /api/v1/semantic/build first");
}
const hits = searchSemanticRecords(records, str(body["query"], "query"), {
k: optInt(body["k"], "k") ?? 10,
...(typeof body["kind"] === "string" && { kind: body["kind"] }),
});
return { json: { results: hits.map((h) => ({ ...h, ref: refFor(String(h.entry_id)) })) } };
});
server.route("POST", "/api/v1/shutdown", async () => {
ctx.requestShutdown();
return { json: { status: "shutting down" } };