mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-28 08:22:03 +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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user