knodin 0.10.7 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/cli.js CHANGED
@@ -456,10 +456,17 @@ function formatStatusHuman(result) {
456
456
  ? result.missing.records[0]
457
457
  : (result.missing.files[0] ?? result.missing.records[0]);
458
458
  const detail = firstIssue ? ` First issue: ${firstIssue}.` : "";
459
- const repairCommand = result.lifecycle?.status === "degraded" &&
460
- result.missing.records.every((record) => result.lifecycle?.issues.includes(record))
461
- ? "Run `knodin init`."
462
- : "Run `knodin repair`.";
459
+ // A linked worktree with no database is the one case where `repair` is both
460
+ // the wrong first step and the expensive one: `init` seeds from an indexed
461
+ // sibling and reconciles only what differs, while `repair` builds from
462
+ // scratch. The engine has already worked out that this is that case, so
463
+ // defer to the step it wrote rather than recomputing the judgement here.
464
+ const worktreeStep = result.repairSteps?.find((step) => step.includes("per-worktree"));
465
+ const repairCommand = worktreeStep ??
466
+ (result.lifecycle?.status === "degraded" &&
467
+ result.missing.records.every((record) => result.lifecycle?.issues.includes(record))
468
+ ? "Run `knodin init`."
469
+ : "Run `knodin repair`.");
463
470
  return `Graph or lifecycle needs repair: ${outstanding} issue(s) found (${coverage}).${detail} ${repairCommand}\n${lifecycleLine}${integrationLine}`;
464
471
  }
465
472
  function humanLabel(key) {
@@ -12,12 +12,17 @@ export function compactIdentity(identity) {
12
12
  export function expandCompactIdentity(identity) {
13
13
  return identity?.startsWith("~") ? `${IDENTITY_PREFIX}${identity.slice(1)}*` : identity;
14
14
  }
15
+ // Missing evidence is "unknown", never "fresh" — the same default the engine's
16
+ // own `stalenessFor()` uses. Search stamps staleness per row and `SearchPage`
17
+ // carries no page-level field, so a zero-row page has no row to read it from:
18
+ // defaulting to "fresh" rendered an empty result on a stale or unprobed graph as
19
+ // `fresh 0/0`, an authoritative-looking negative (KNODIN-22).
15
20
  function freshness(value) {
16
21
  if (value === "reconciled")
17
22
  return "reconciled";
18
- if (value === "unknown")
19
- return "unknown";
20
- return "fresh";
23
+ if (value === "fresh")
24
+ return "fresh";
25
+ return "unknown";
21
26
  }
22
27
  function terseSignature(row) {
23
28
  const signature = row.signature?.replace(/\s+/g, " ").trim() ?? "";
@@ -25,8 +25,21 @@ export function credentialPatterns() {
25
25
  // consumed BEFORE the value, because `authorization: Bearer <token>`
26
26
  // otherwise matches only the word "Bearer" — redacting the label and
27
27
  // preserving the credential, exactly inverted (KNODIN-10).
28
+ //
29
+ // The keyword is surrounded by `[\w-]*` rather than anchored with `\b`,
30
+ // because `\b` does not exist between `_` and a letter: a leading `\b`
31
+ // could never match inside `AWS_SECRET_ACCESS_KEY`, and a trailing one
32
+ // stopped the name short of the `=`. That excluded the dominant spelling
33
+ // of every real secret — `AWS_SECRET_ACCESS_KEY=`, `DB_PASSWORD=`,
34
+ // `NPM_TOKEN=`, any `*_SECRET=` — while `password=` alone was redacted
35
+ // (KNODIN-19).
36
+ //
37
+ // The trailing half is `[_-]`-led rather than a bare `[\w-]*` so the
38
+ // keyword has to be a whole segment of the name. knodin echoes source
39
+ // into diagnostics and compressed output, and an unanchored tail
40
+ // redacted the value of a plain `const tokenCount = 5`.
28
41
  label: "credential",
29
- pattern: /\b(authorization|password|passwd|secret|token|api[_-]?key)(\s*[:=]\s*)(?:(?:bearer|basic|digest|token)\s+)?[^\s,;]+/gi,
42
+ pattern: /(?<![\w-])([\w-]*(?:authorization|password|passwd|secret|token|api[_-]?key)(?:[_-][\w-]*)?)(\s*[:=]\s*)(?:(?:bearer|basic|digest|token)\s+)?[^\s,;]+/gi,
30
43
  preservedGroups: 2,
31
44
  },
32
45
  {
@@ -0,0 +1,53 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * Git checkout-layout questions, kept out of the engine on purpose.
5
+ *
6
+ * `engine/index.ts` carries a pinned budget on direct `fs` reads
7
+ * (`sealed-reader-inventory.spec.ts`) because a sealed artifact answers with no
8
+ * working tree: a reader that touches the filesystem there gets ENOENT, which
9
+ * the nearest guard turns into `""` or `null`, and that reads downstream as
10
+ * "this symbol has no body" rather than "this was not covered".
11
+ *
12
+ * Git *metadata* is categorically outside that concern — a sealed artifact has
13
+ * no `.git` at all, and these functions are never on a source-content path — so
14
+ * routing them through the sealed resolver would add a branch that can never
15
+ * execute. Keeping them here says that in the layout rather than by spending
16
+ * budget the engine reserves for source reads, and it makes them directly
17
+ * unit-testable, which they are not as engine-private helpers.
18
+ */
19
+ /**
20
+ * True only for a `git worktree add` checkout.
21
+ *
22
+ * A linked worktree's `.git` is a FILE pointing at an administrative directory
23
+ * under the main checkout, and that directory contains `commondir` naming the
24
+ * shared repository.
25
+ *
26
+ * The `.git` file alone is NOT sufficient and treating it as sufficient is a
27
+ * live bug: submodules and `--separate-git-dir` clones use one too, and neither
28
+ * has a main checkout whose graph could cover it, so per-worktree guidance would
29
+ * be actively wrong for them. `commondir` is the marker that actually
30
+ * distinguishes the case.
31
+ */
32
+ export function isLinkedWorktree(repo) {
33
+ const marker = path.join(repo, ".git");
34
+ try {
35
+ // An ordinary checkout keeps a `.git` directory; `statSync` throws when
36
+ // there is no `.git` at all, which is an ordinary case — knodin indexes
37
+ // plain directories too.
38
+ if (!fs.statSync(marker).isFile())
39
+ return false;
40
+ const pointer = fs.readFileSync(marker, "utf8").trim();
41
+ const gitDir = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim();
42
+ if (!gitDir)
43
+ return false;
44
+ return fs.existsSync(path.join(path.resolve(repo, gitDir), "commondir"));
45
+ }
46
+ catch {
47
+ // Every read is inside the guard on purpose. This is best-effort detection
48
+ // reached from the missing-database remediation path, and that path has to
49
+ // answer: an unreadable `.git`, or one that disappears between the stat and
50
+ // the read, must degrade to "not a worktree" rather than crash `status`.
51
+ return false;
52
+ }
53
+ }
@@ -32,6 +32,7 @@ import { allocateCandidate, assertCandidate, discardCandidateFiles, listCandidat
32
32
  import { computeSimilarity, generateEmbedding, generateEmbeddings, } from "./embeddings.js";
33
33
  import { walkRepoFiles } from "./file-walker.js";
34
34
  import { clearGitHistorySignalCache, collectGitHistorySignals, } from "./git-history.js";
35
+ import { isLinkedWorktree } from "./git-layout.js";
35
36
  // Runtime import, but not a cycle: parse-pool imports only TYPES from here,
36
37
  // which erase at compile time. parse-worker's runtime import of this module
37
38
  // resolves inside the worker thread, never in this one.
@@ -13083,6 +13084,11 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
13083
13084
  verification: { mode: "deep-audit", verifiedAt },
13084
13085
  freshnessMechanism: freshnessMechanismFor(resolved, openPolicy),
13085
13086
  repairSteps: [
13087
+ ...(isLinkedWorktree(resolved)
13088
+ ? [
13089
+ "Run `knodin init` here: graphs are per-worktree and this one has none of its own, so the main checkout's graph does not cover it. `init` seeds from an indexed sibling worktree where one exists rather than rebuilding from scratch.",
13090
+ ]
13091
+ : []),
13086
13092
  "Run `knodin repair` to create the local index.",
13087
13093
  "Run `knodin status` again to verify health.",
13088
13094
  ],
@@ -147,10 +147,27 @@ class PoolRun {
147
147
  return this.buckets.get(bestKey)?.pop() ?? null;
148
148
  }
149
149
  attach(slot) {
150
- slot.handle.onMessage((response) => this.onResponse(slot, response));
151
- slot.handle.onError((error) => this.onSlotFailure(slot, error.message));
152
- slot.handle.onExit((code) => {
153
- if (slot.inFlight)
150
+ // Bind these listeners to the handle they were registered on, not just to
151
+ // the slot. `PoolWorkerHandle` offers no listener removal, so a replaced
152
+ // worker's listeners stay live and still close over the slot whose
153
+ // `.handle` is by then the replacement. A crashed worker's inevitable late
154
+ // `exit` therefore re-entered `onSlotFailure` and terminated its OWN
155
+ // replacement, cancelling the one documented retry and leaving the lane
156
+ // dead; on a one-worker pool that dropped the whole run to inline parsing
157
+ // (KNODIN-20). Mirrors the `pending.child !== child` guard the MCP
158
+ // supervisor already carries.
159
+ const handle = slot.handle;
160
+ const current = () => slot.handle === handle;
161
+ handle.onMessage((response) => {
162
+ if (current())
163
+ this.onResponse(slot, response);
164
+ });
165
+ handle.onError((error) => {
166
+ if (current())
167
+ this.onSlotFailure(slot, error.message);
168
+ });
169
+ handle.onExit((code) => {
170
+ if (current() && slot.inFlight)
154
171
  this.onSlotFailure(slot, `worker exited with code ${code}`);
155
172
  });
156
173
  }
@@ -22,16 +22,114 @@ const SEAL_SCHEMA = [
22
22
  function countLines(buf) {
23
23
  return buf.toString("utf8").split("\n").length;
24
24
  }
25
+ /**
26
+ * A database claims to be an artifact but carries an attestation the reader
27
+ * cannot trust.
28
+ *
29
+ * Distinct from "no attestation at all", which is an ordinary index and a
30
+ * routine answer. Every field here is consumed as a decision downstream — the
31
+ * schema gate, the version gate, and the repository path the artifact is
32
+ * allowed to serve source for — so a half-populated object must stop at the
33
+ * reader rather than reach them.
34
+ */
35
+ export class SealAttestationError extends Error {
36
+ code = "malformed-attestation";
37
+ constructor(reason) {
38
+ super(reason);
39
+ this.name = "SealAttestationError";
40
+ }
41
+ }
42
+ function isRecord(value) {
43
+ return typeof value === "object" && value !== null && !Array.isArray(value);
44
+ }
45
+ /**
46
+ * Validates the WHOLE shape, not just the fields the immediate caller reads.
47
+ * A sealed artifact is transported between machines, so it is untrusted input
48
+ * rather than something this process wrote (KNODIN-21), and an unchecked cast
49
+ * would let `undefined` stand where a version or a path belongs.
50
+ */
51
+ function validateAttestation(value) {
52
+ const bad = (reason) => {
53
+ throw new SealAttestationError(`seal attestation is malformed: ${reason}`);
54
+ };
55
+ if (!isRecord(value))
56
+ return bad("not an object");
57
+ const str = (key, on) => typeof on[key] === "string" ? on[key] : bad(`${key} is not a string`);
58
+ const num = (key, on) => typeof on[key] === "number" && Number.isFinite(on[key])
59
+ ? on[key]
60
+ : bad(`${key} is not a finite number`);
61
+ const nullableStr = (key, on) => on[key] === null || typeof on[key] === "string"
62
+ ? on[key]
63
+ : bad(`${key} is not a string or null`);
64
+ const obj = (key) => isRecord(value[key]) ? value[key] : bad(`${key} is not an object`);
65
+ if (value.sealVersion !== 1)
66
+ bad(`unsupported sealVersion ${String(value.sealVersion)}`);
67
+ const repository = obj("repository");
68
+ const health = obj("health");
69
+ const source = obj("source");
70
+ const embeddings = obj("embeddings");
71
+ const policy = source.policy;
72
+ if (policy !== "all-indexed" && policy !== "excluded")
73
+ bad(`unsupported source policy ${String(policy)}`);
74
+ const stripped = embeddings.stripped;
75
+ if (typeof stripped !== "boolean")
76
+ bad("embeddings.stripped is not a boolean");
77
+ return {
78
+ sealVersion: 1,
79
+ knodinVersion: str("knodinVersion", value),
80
+ schemaVersion: num("schemaVersion", value),
81
+ repository: {
82
+ identity: str("identity", repository),
83
+ path: str("path", repository),
84
+ remoteUrl: nullableStr("remoteUrl", repository),
85
+ },
86
+ sealedCommit: str("sealedCommit", value),
87
+ sealedRef: nullableStr("sealedRef", value),
88
+ sealedAt: str("sealedAt", value),
89
+ health: {
90
+ status: str("status", health),
91
+ pendingPaths: num("pendingPaths", health),
92
+ freshness: str("freshness", health),
93
+ },
94
+ source: {
95
+ policy: policy,
96
+ files: num("files", source),
97
+ uniqueBlobs: num("uniqueBlobs", source),
98
+ rawBytes: num("rawBytes", source),
99
+ storedBytes: num("storedBytes", source),
100
+ },
101
+ embeddings: { stripped: stripped, removed: num("removed", embeddings) },
102
+ };
103
+ }
104
+ /**
105
+ * `null` when the database carries no attestation; THROWS
106
+ * `SealAttestationError` when it carries one that does not validate. The split
107
+ * has teeth: "ordinary index" is something a caller falls back from, while a
108
+ * database misrepresenting itself must not be quietly downgraded to the same
109
+ * answer.
110
+ */
25
111
  export function readSealAttestation(db) {
112
+ let raw;
26
113
  try {
27
114
  const row = db
28
115
  .query("SELECT value FROM meta WHERE key = 'sealAttestation'")
29
116
  .get();
30
- return row ? JSON.parse(row.value) : null;
117
+ if (!row)
118
+ return null;
119
+ raw = row.value;
31
120
  }
32
121
  catch {
122
+ // No `meta` table, or an unreadable one: not a sealed artifact.
33
123
  return null;
34
124
  }
125
+ let parsed;
126
+ try {
127
+ parsed = JSON.parse(raw);
128
+ }
129
+ catch (error) {
130
+ throw new SealAttestationError(`seal attestation is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
131
+ }
132
+ return validateAttestation(parsed);
35
133
  }
36
134
  /**
37
135
  * Reads embedded source out of a sealed artifact.
@@ -64,9 +162,30 @@ export function listSealedFiles(db) {
64
162
  .all()
65
163
  .map((row) => row.filePath);
66
164
  }
67
- /** True when this database carries embedded source, i.e. can answer with no checkout. */
165
+ /**
166
+ * Content hash of every embedded file, as recorded at seal time.
167
+ *
168
+ * The hash is over the RAW bytes, so a caller can compare against a file on
169
+ * disk without decompressing the blob.
170
+ */
171
+ export function listSealedFileHashes(db) {
172
+ return db
173
+ .query("SELECT filePath, sha256 FROM sealed_file ORDER BY filePath")
174
+ .all();
175
+ }
176
+ /**
177
+ * True when this database carries embedded source, i.e. can answer with no
178
+ * checkout. A malformed attestation answers false: it is not an artifact any
179
+ * reader here can serve from, and this predicate's callers route on it rather
180
+ * than handle it.
181
+ */
68
182
  export function isSealedDatabase(db) {
69
- return readSealAttestation(db) !== null;
183
+ try {
184
+ return readSealAttestation(db) !== null;
185
+ }
186
+ catch {
187
+ return false;
188
+ }
70
189
  }
71
190
  /**
72
191
  * Produce a sealed artifact, or refuse with a machine-readable code.
@@ -1,9 +1,10 @@
1
+ import crypto from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { KNODIN_VERSION as RUNTIME_VERSION } from "../version.js";
5
6
  import { KNODIN_SCHEMA_VERSION, registerSealedRepository, unregisterSealedRepository, } from "./index.js";
6
- import { readSealAttestation } from "./seal.js";
7
+ import { listSealedFileHashes, readSealAttestation, SealAttestationError, } from "./seal.js";
7
8
  import { Database } from "./sqlite.js";
8
9
  export function openSealedArtifact(artifactPath, options = {}) {
9
10
  const resolved = path.resolve(artifactPath);
@@ -20,7 +21,18 @@ export function openSealedArtifact(artifactPath, options = {}) {
20
21
  reason: error instanceof Error ? error.message : String(error),
21
22
  };
22
23
  }
23
- const attestation = readSealAttestation(db);
24
+ let attestation;
25
+ try {
26
+ attestation = readSealAttestation(db);
27
+ }
28
+ catch (error) {
29
+ db.close();
30
+ return {
31
+ ok: false,
32
+ code: error instanceof SealAttestationError ? "malformed-attestation" : "unreadable",
33
+ reason: error instanceof Error ? error.message : String(error),
34
+ };
35
+ }
24
36
  if (!attestation) {
25
37
  db.close();
26
38
  return {
@@ -84,10 +96,31 @@ export function openSealedArtifact(artifactPath, options = {}) {
84
96
  // only the mount would leave those internal reads falling through to a
85
97
  // filesystem that does not have the source, which fails as "" rather than
86
98
  // loudly.
87
- registerSealedRepository(mountDir, db);
88
99
  const attestedPath = attestation.repository.path;
89
- if (attestedPath && path.resolve(attestedPath) !== path.resolve(mountDir))
90
- registerSealedRepository(attestedPath, db);
100
+ const registerAttested = attestedPath && path.resolve(attestedPath) !== path.resolve(mountDir);
101
+ // The attested path is chosen by the artifact, and the registry it lands in
102
+ // takes precedence over the filesystem for every source read in the
103
+ // process. An artifact naming a path that exists here would therefore serve
104
+ // its own bytes to live queries against that checkout (KNODIN-21). Sealing
105
+ // and opening on ONE machine is a real flow, so this refuses on
106
+ // DISAGREEMENT rather than on existence: where the embedded blobs match the
107
+ // files on disk the artifact describes that tree and shadowing it changes
108
+ // nothing, and where they do not, whichever side is wrong, no read of that
109
+ // path can be trusted.
110
+ if (registerAttested) {
111
+ const conflict = firstSourceDisagreement(db, path.resolve(attestedPath));
112
+ if (conflict) {
113
+ db.close();
114
+ return {
115
+ ok: false,
116
+ code: "path-conflict",
117
+ reason: `artifact attests ${path.resolve(attestedPath)}, which exists here with different source (${conflict}); re-seal from this checkout, or open the artifact where that path is absent`,
118
+ };
119
+ }
120
+ }
121
+ acquireSealedRepository(mountDir, db);
122
+ if (registerAttested)
123
+ acquireSealedRepository(attestedPath, db);
91
124
  const sealedAt = Date.parse(attestation.sealedAt);
92
125
  const ageDays = Number.isNaN(sealedAt)
93
126
  ? 0
@@ -100,13 +133,97 @@ export function openSealedArtifact(artifactPath, options = {}) {
100
133
  degraded,
101
134
  ageDays,
102
135
  close() {
103
- unregisterSealedRepository(mountDir);
104
- if (attestedPath)
105
- unregisterSealedRepository(attestedPath);
136
+ releaseSealedRepository(mountDir, db);
137
+ if (registerAttested)
138
+ releaseSealedRepository(attestedPath, db);
106
139
  db.close();
107
140
  },
108
141
  };
109
142
  }
143
+ /**
144
+ * Open handles per registered path, most recent last.
145
+ *
146
+ * The engine's registry is a bare path→database map, so two artifacts
147
+ * attesting the same repository would have the first `close()` unregister the
148
+ * other's source and leave it reading `""` (KNODIN-21). Held here rather than
149
+ * widened there because the map is the engine's chokepoint for a single
150
+ * answer to "where do bytes for this path come from"; ownership of the
151
+ * handles that claim it belongs to whoever opened them.
152
+ */
153
+ const sealedRegistrations = new Map();
154
+ function acquireSealedRepository(repoPath, db) {
155
+ const key = path.resolve(repoPath);
156
+ const held = sealedRegistrations.get(key) ?? [];
157
+ held.push(db);
158
+ sealedRegistrations.set(key, held);
159
+ registerSealedRepository(key, db);
160
+ }
161
+ function releaseSealedRepository(repoPath, db) {
162
+ const key = path.resolve(repoPath);
163
+ const held = sealedRegistrations.get(key);
164
+ if (!held)
165
+ return;
166
+ const last = held.lastIndexOf(db);
167
+ if (last === -1)
168
+ return;
169
+ held.splice(last, 1);
170
+ // Re-point at a surviving handle rather than leaving the closed one
171
+ // registered: an unregister here would fall through to a filesystem that
172
+ // has no source, and a closed database would throw on the next read.
173
+ const survivor = held.at(-1);
174
+ if (survivor)
175
+ registerSealedRepository(key, survivor);
176
+ else {
177
+ sealedRegistrations.delete(key);
178
+ unregisterSealedRepository(key);
179
+ }
180
+ }
181
+ /**
182
+ * First embedded file whose bytes differ from the file at the same relative
183
+ * path under `dir`, or `null` when every one that exists there agrees.
184
+ *
185
+ * Files absent from `dir` are not disagreement — a partially or wholly removed
186
+ * checkout is precisely what an artifact is for. Compared by hash against the
187
+ * one recorded at seal time, which is over raw bytes, so nothing is
188
+ * decompressed to answer this.
189
+ *
190
+ * `filePath` comes out of the artifact and is therefore attacker-controlled, so
191
+ * every candidate is resolved and required to stay under `dir` before it is
192
+ * opened. A `..` segment would otherwise have this check read and hash files
193
+ * outside the attested checkout — reachable during `openSealedArtifact`, and
194
+ * pure attack surface, since a repository-relative path never needs to escape.
195
+ * An entry that does is reported as disagreement rather than skipped: it is not
196
+ * a path this artifact can honestly claim, and failing closed keeps a traversal
197
+ * from being a way to hide an entry from this comparison.
198
+ */
199
+ function firstSourceDisagreement(db, dir) {
200
+ let stat;
201
+ try {
202
+ stat = fs.statSync(dir);
203
+ }
204
+ catch {
205
+ return null;
206
+ }
207
+ if (!stat.isDirectory())
208
+ return null;
209
+ const root = path.resolve(dir);
210
+ for (const entry of listSealedFileHashes(db)) {
211
+ const candidate = path.resolve(root, entry.filePath);
212
+ if (candidate !== root && !candidate.startsWith(root + path.sep))
213
+ return entry.filePath;
214
+ let onDisk;
215
+ try {
216
+ onDisk = fs.readFileSync(candidate);
217
+ }
218
+ catch {
219
+ continue;
220
+ }
221
+ const hash = crypto.createHash("sha256").update(onDisk).digest("hex");
222
+ if (hash !== entry.sha256)
223
+ return entry.filePath;
224
+ }
225
+ return null;
226
+ }
110
227
  /**
111
228
  * Read at call time rather than imported as a constant so that a test can
112
229
  * exercise the soft gate without rebuilding the package version.