fix(semantic): delete indexes written before secrets stopped being indexed

Fixing the writer does not rewrite files already on disk, so a profile that
built an index before today still has one holding stored secrets in plaintext.
Reporting a version string in a status field and expecting the user to notice
is not a remedy for that.

Both implementations now check the manifest's model_id on every read — status,
search, and the API equivalents — and delete records + manifest when it
predates the fix. Reading a stale index would also mean serving results
derived from the secrets it should never have held, so search purges before
it reads rather than after.

Deleting is safe: the index is a derived cache, rebuilt from the vault in
milliseconds, and the user is told to rebuild.

A manifest that cannot be parsed is deliberately NOT treated as stale.
"Cannot tell" must not mean "delete it", or every corrupt-manifest read would
throw away a working index; that case is tested on both sides.

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 10:02:31 -04:00
1 parent fdbdb12596
commit 885cbe5665
8 files changed
+228 -23

No files matched your search

+29 -9
View File
@@ -8,7 +8,7 @@
* is the part nobody notices — which secret a given entry produces.
*/
import { readFile, mkdir } from "node:fs/promises";
import { readFile, mkdir, rm } from "node:fs/promises";
import { existsSync } from "node:fs";
import { basename, dirname, join, resolve as resolvePath } from "node:path";
import {
@@ -42,6 +42,7 @@ import {
searchSemanticRecords,
semanticManifest,
semanticStatus,
isStaleSemanticIndex,
type SemanticRecord,
totpCodeAt,
Bip85,
@@ -822,6 +823,30 @@ export function registerRoutes(server: ApiServer, ctx: ApiContext): void {
const semanticRecordsPath = (): string => join(semanticDir(), "records.json");
const semanticManifestPath = (): string => join(semanticDir(), "manifest.json");
async function readSemanticManifest(): Promise<Record<string, unknown>> {
if (!existsSync(semanticManifestPath())) return {};
try {
return JSON.parse(await readFile(semanticManifestPath(), "utf8")) as Record<string, unknown>;
} catch {
return {};
}
}
/**
* Delete an index written before secrets stopped being indexed.
*
* Such a file holds stored secrets in the clear, and fixing the writer does
* not rewrite what is already on disk. Deleting is safe — it is a derived
* cache — and serving results from it would mean serving results derived
* from secrets it should never have held.
*/
async function purgeStaleSemantic(): Promise<void> {
if (!isStaleSemanticIndex(await readSemanticManifest())) return;
for (const path of [semanticRecordsPath(), semanticManifestPath()]) {
if (existsSync(path)) await rm(path, { force: true });
}
}
async function readSemanticRecords(): Promise<SemanticRecord[]> {
if (!existsSync(semanticRecordsPath())) return [];
try {
@@ -862,14 +887,8 @@ export function registerRoutes(server: ApiServer, ctx: ApiContext): void {
}
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.
}
}
await purgeStaleSemantic();
const manifest = await readSemanticManifest();
return { json: semanticStatus(manifest, (await readSemanticRecords()).length) };
});
@@ -883,6 +902,7 @@ export function registerRoutes(server: ApiServer, ctx: ApiContext): void {
server.route("POST", "/api/v1/semantic/search", async (req) => {
const body = bodyObject(req);
await purgeStaleSemantic();
const records = await readSemanticRecords();
if (records.length === 0) {
throw new HttpError(409, "no semantic index; POST /api/v1/semantic/build first");