@crewhaus/citation-tracker 0.1.0 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/citation-tracker",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Per-research-run citation persistence + URL dedup (Section 23 RES)",
6
6
  "main": "src/index.ts",
@@ -12,13 +12,13 @@
12
12
  "test": "bun test src"
13
13
  },
14
14
  "dependencies": {
15
- "@crewhaus/errors": "0.0.0"
15
+ "@crewhaus/errors": "0.1.2"
16
16
  },
17
17
  "license": "Apache-2.0",
18
18
  "author": {
19
19
  "name": "Max Meier",
20
- "email": "max@studiomax.io",
21
- "url": "https://studiomax.io"
20
+ "email": "max@crewhaus.ai",
21
+ "url": "https://crewhaus.ai"
22
22
  },
23
23
  "repository": {
24
24
  "type": "git",
@@ -30,12 +30,7 @@
30
30
  "url": "https://github.com/crewhaus/factory/issues"
31
31
  },
32
32
  "publishConfig": {
33
- "access": "restricted"
33
+ "access": "public"
34
34
  },
35
- "files": [
36
- "src",
37
- "README.md",
38
- "LICENSE",
39
- "NOTICE"
40
- ]
35
+ "files": ["src", "README.md", "LICENSE", "NOTICE"]
41
36
  }
package/src/index.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { mkdtempSync, rmSync } from "node:fs";
2
+ import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { CitationTrackerError, createCitationTracker, newRunId } from "./index.js";
@@ -136,4 +136,155 @@ describe("createCitationTracker", () => {
136
136
  rmSync(root, { recursive: true, force: true });
137
137
  }
138
138
  });
139
+
140
+ test("malformed jsonl in citations.jsonl surfaces as a CitationTrackerError", () => {
141
+ const root = newRoot();
142
+ const runId = newRunId();
143
+ const path = join(root, runId, "citations.jsonl");
144
+ try {
145
+ const t = createCitationTracker({ runId, rootDir: root });
146
+ t.recordCitation({ url: "u", snippet: "s" });
147
+ appendFileSync(path, "{not valid json\n");
148
+ expect(() => createCitationTracker({ runId, rootDir: root })).toThrow(CitationTrackerError);
149
+ } finally {
150
+ rmSync(root, { recursive: true, force: true });
151
+ }
152
+ });
153
+
154
+ test("getFetchRecord returns metadata for a known URL and undefined otherwise", () => {
155
+ const root = newRoot();
156
+ try {
157
+ const t = createCitationTracker({ rootDir: root });
158
+ expect(t.getFetchRecord("missing")).toBeUndefined();
159
+ const rec = t.recordFetch({ url: "u", content: "the body", branchId: "b7" });
160
+ const got = t.getFetchRecord("u");
161
+ expect(got).toEqual(rec);
162
+ expect(got?.sha256).toMatch(/^[0-9a-f]{64}$/);
163
+ expect(got?.contentBytes).toBe(Buffer.byteLength("the body", "utf8"));
164
+ expect(got?.branchId).toBe("b7");
165
+ } finally {
166
+ rmSync(root, { recursive: true, force: true });
167
+ }
168
+ });
169
+
170
+ test("getFetchedContent returns undefined for an unknown URL", () => {
171
+ const root = newRoot();
172
+ try {
173
+ const t = createCitationTracker({ rootDir: root });
174
+ expect(t.getFetchedContent("never-fetched")).toBeUndefined();
175
+ } finally {
176
+ rmSync(root, { recursive: true, force: true });
177
+ }
178
+ });
179
+
180
+ test("listFetches returns all fetch records in append order", () => {
181
+ const root = newRoot();
182
+ try {
183
+ const t = createCitationTracker({ rootDir: root });
184
+ expect(t.listFetches()).toEqual([]);
185
+ t.recordFetch({ url: "first", content: "1" });
186
+ t.recordFetch({ url: "second", content: "2" });
187
+ // Idempotent re-record must NOT add a second entry.
188
+ t.recordFetch({ url: "first", content: "1-again" });
189
+ const urls = t.listFetches().map((r) => r.url);
190
+ expect(urls).toEqual(["first", "second"]);
191
+ } finally {
192
+ rmSync(root, { recursive: true, force: true });
193
+ }
194
+ });
195
+
196
+ test("listFetches survives kill-and-resume in append order", () => {
197
+ const root = newRoot();
198
+ const runId = newRunId();
199
+ try {
200
+ const t1 = createCitationTracker({ runId, rootDir: root });
201
+ t1.recordFetch({ url: "a", content: "ka" });
202
+ t1.recordFetch({ url: "b", content: "kb" });
203
+ const t2 = createCitationTracker({ runId, rootDir: root });
204
+ expect(t2.listFetches().map((r) => r.url)).toEqual(["a", "b"]);
205
+ } finally {
206
+ rmSync(root, { recursive: true, force: true });
207
+ }
208
+ });
209
+
210
+ test("recordCitation carries branchId and supportingClaim through to disk", () => {
211
+ const root = newRoot();
212
+ const runId = newRunId();
213
+ try {
214
+ const t = createCitationTracker({ runId, rootDir: root });
215
+ const c = t.recordCitation({
216
+ url: "u",
217
+ snippet: "s",
218
+ branchId: "branch-9",
219
+ supportingClaim: "supports: target shapes list",
220
+ });
221
+ expect(c.branchId).toBe("branch-9");
222
+ expect(c.supportingClaim).toBe("supports: target shapes list");
223
+
224
+ // Round-trips verbatim through the JSONL on resume.
225
+ const reopened = createCitationTracker({ runId, rootDir: root });
226
+ const persisted = reopened.listCitationsOrdered()[0];
227
+ expect(persisted?.branchId).toBe("branch-9");
228
+ expect(persisted?.supportingClaim).toBe("supports: target shapes list");
229
+ } finally {
230
+ rmSync(root, { recursive: true, force: true });
231
+ }
232
+ });
233
+
234
+ test("now() injection makes retrievedAt deterministic", () => {
235
+ const root = newRoot();
236
+ try {
237
+ const fixed = new Date("2030-01-02T03:04:05.000Z");
238
+ const t = createCitationTracker({ rootDir: root, now: () => fixed });
239
+ const f = t.recordFetch({ url: "u", content: "x" });
240
+ expect(f.retrievedAt).toBe("2030-01-02T03:04:05.000Z");
241
+ // Citation with no prior fetch also stamps via now().
242
+ const c = t.recordCitation({ url: "other", snippet: "s" });
243
+ expect(c.retrievedAt).toBe("2030-01-02T03:04:05.000Z");
244
+ } finally {
245
+ rmSync(root, { recursive: true, force: true });
246
+ }
247
+ });
248
+
249
+ test("newRunId produces well-formed, accepted run ids", () => {
250
+ const root = newRoot();
251
+ try {
252
+ const id = newRunId();
253
+ expect(id).toMatch(/^run_[0-9a-f]{16}$/);
254
+ // A freshly-minted id must be accepted by the validator.
255
+ const t = createCitationTracker({ runId: id, rootDir: root });
256
+ expect(t.runId).toBe(id);
257
+ } finally {
258
+ rmSync(root, { recursive: true, force: true });
259
+ }
260
+ });
261
+
262
+ test("security: a tampered sha256 in fetches.jsonl cannot traverse out of the cache dir", () => {
263
+ const root = newRoot();
264
+ const runId = newRunId();
265
+ try {
266
+ // Boot once to create the run dir, then overwrite fetches.jsonl with a
267
+ // record whose sha256 is a path-traversal payload. A secret file lives
268
+ // two levels up (the runId dir) at a guessable name.
269
+ createCitationTracker({ runId, rootDir: root });
270
+ const secretPath = join(root, runId, "stolen.txt");
271
+ writeFileSync(secretPath, "TOP SECRET CONTENTS");
272
+ // cache/<sha256>.txt -> cache/../stolen.txt resolves to the secret file.
273
+ const evilRecord = {
274
+ version: 1,
275
+ url: "https://victim.example/x",
276
+ retrievedAt: "2030-01-01T00:00:00.000Z",
277
+ sha256: "../stolen",
278
+ contentBytes: 19,
279
+ };
280
+ writeFileSync(join(root, runId, "fetches.jsonl"), `${JSON.stringify(evilRecord)}\n`);
281
+
282
+ const t = createCitationTracker({ runId, rootDir: root });
283
+ expect(t.hasFetched("https://victim.example/x")).toBe(true);
284
+ // The guard must refuse the malformed sha256 rather than read the secret.
285
+ expect(t.getFetchedContent("https://victim.example/x")).toBeUndefined();
286
+ } finally {
287
+ rmSync(root, { recursive: true, force: true });
288
+ }
289
+ });
139
290
  });
package/src/index.ts CHANGED
@@ -35,6 +35,14 @@ import { CrewhausError, RuntimeError } from "@crewhaus/errors";
35
35
 
36
36
  export const DEFAULT_ROOT_DIR = ".crewhaus/research";
37
37
  const RUN_ID_RE = /^run_[0-9a-f]{16}$/;
38
+ /**
39
+ * A sha256 digest is always exactly 64 lowercase hex chars. We re-validate it
40
+ * before interpolating into the cache file path because the value can originate
41
+ * from a JSONL line read off disk (boot-time replay), and a corrupted or
42
+ * tampered `fetches.jsonl` must not be able to steer a read outside `cache/`
43
+ * (e.g. a `sha256` of "../../../etc/passwd"). Legitimate records always match.
44
+ */
45
+ const SHA256_RE = /^[0-9a-f]{64}$/;
38
46
 
39
47
  export type RunId = string;
40
48
 
@@ -170,6 +178,9 @@ export function createCitationTracker(opts: CreateCitationTrackerOptions = {}):
170
178
  }
171
179
 
172
180
  function tryReadCache(rec: FetchRecord): string | undefined {
181
+ // Defend the cache read against a tampered fetches.jsonl: a sha256 that is
182
+ // not a clean 64-hex digest could otherwise traverse out of cache/.
183
+ if (typeof rec.sha256 !== "string" || !SHA256_RE.test(rec.sha256)) return undefined;
173
184
  const p = cachePathFor(rec.sha256);
174
185
  if (!existsSync(p)) return undefined;
175
186
  return readFileSync(p, "utf8");