mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-29 00:42:08 +00:00
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:
1 parent
51f1358da4
commit
39c4e45376
13 files changed
+1189
-11
No files matched your search
@@ -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" } };
|
||||
|
||||
Reference in new issue
Block a user