Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Each release of Research-Kit, newest first. The version is `KIT_VERSION` in
`research-kit/lib/core.mjs`; a release is tagged `v<version>` (ADR-0116).

## Unreleased

- Evidence context refuses Raw cells outside research/raw and names directories before reading; the ADR-0109 trigger also moves corpus and artifact recorded-path readers to projectFile (#351).
- Fixed `AGENTS.md` corpus description to use evergreen relative references rather than stale hardcoded entry counts (#299).

- The suite's "every CLI refuses a flag it does not know" table now covers `measure`,
Expand Down
6 changes: 3 additions & 3 deletions docs/ARCHITECTURE.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ its own tests. Moving them is a refactor of working, tested code with no defect
**Trigger that ends the deferral:** a new reader of recorded paths, or a defect in any of
those three, is written against `projectFile`, and the other two move with it.

Implementation (2026-10-13, #351): the evidence-context private-file defect fired the
deferral's trigger. Its Raw reader, corpus capture/Raw checks, and the artifact Raw/ledger
readers now use `projectFile`. Capture reads also stay inside the capture folder by real
path, and missing or non-file inputs are named before any content read.

## Rejected alternatives

- **Move every reader now.** Three working readers rewritten in one change, with no defect
Expand Down
2 changes: 1 addition & 1 deletion research-kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,7 @@ node research-kit/bin/selftest.mjs # all of it
node research-kit/bin/selftest.mjs gate hook # just these files
```

1789 tests, offline, no key and no network. The runner **awaits** every test, so `ok` means
1793 tests, offline, no key and no network. The runner **awaits** every test, so `ok` means
the assertions settled (ADR-0021), and each test is raced against a watchdog
(`RESEARCH_KIT_TEST_TIMEOUT`, default 60s).

Expand Down
21 changes: 15 additions & 6 deletions research-kit/lib/artifact.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
PATHS, resolve, exists, isDirectory, isRegularFile, readText, sha256, canonicalJson, nowIso, listFiles, homeCommand, realInside, writeBytes,
PATHS, resolve, relative, exists, isDirectory, isRegularFile, readText, sha256, canonicalJson, nowIso, listFiles, homeCommand, realInside, writeBytes, projectFile,
} from './core.mjs';
import { buildZip } from './archive.mjs';
import { checkEntryName } from './artifact-zip.mjs';
Expand Down Expand Up @@ -308,8 +308,11 @@ export function findingsReviewState(root, corpus) {
const unrewritten = [];
for (const row of rows) {
if (!row.raw) continue;
if (!realInside(root, resolve(root, row.raw))) continue; // never read from outside (ADR-0076)
const text = readText(resolve(root, row.raw));
const named = projectFile(root, row.raw);
const rawRoot = resolve(root, PATHS.raw);
const raw = projectFile(rawRoot, relative(rawRoot, named.abs));
if (named.problem || raw.problem) continue;
const text = readText(raw.abs);
if (text === null) continue;
// The collector's own call, over what it extracted from: the page body after the
// front-matter, falling back to the page's title or URL (collect.mjs). Found 2026-09-27
Expand Down Expand Up @@ -556,13 +559,19 @@ export function createArtifact({
add(`project/${rel}`, fs.readFileSync(resolve(root, rel)));
}

const ledgerAbs = resolve(root, PATHS.ledger);
if (exists(ledgerAbs) && !realInside(root, ledgerAbs)) {
const ledger = projectFile(root, PATHS.ledger);
const ledgerAbs = ledger.abs;
if (ledger.problem === 'outside') {
const err = new Error(`${PATHS.ledger} is a link to somewhere outside the project; packaging would copy what is there into the archive.`);
err.code = 'OUTSIDE_PROJECT';
throw err;
}
if (exists(ledgerAbs) && !entries.some((e) => e.name === `project/${PATHS.ledger}`)) {
if (ledger.problem === 'not-file') {
const err = new Error(`${PATHS.ledger} is not a regular file and cannot travel as a provenance ledger.`);
err.code = 'UNPACKAGEABLE_FILE';
throw err;
}
if (!ledger.problem && !entries.some((e) => e.name === `project/${PATHS.ledger}`)) {
// The ledger is a dotfile inside research/raw/ and every hand-rolled copy in this
// project's history has lost exactly this file. Added explicitly rather than trusted
// to the walk.
Expand Down
17 changes: 12 additions & 5 deletions research-kit/lib/corpus.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import fs from 'node:fs';
import path from 'node:path';
import {
PATHS, HEADERS, resolve, relative, exists, isDirectory, readText, readJson,
writeText, appendLine, listFiles, sha256File, ageInDays, hostOf, parseJson, realInside, urlKey,
writeText, appendLine, listFiles, sha256File, ageInDays, hostOf, parseJson, urlKey, projectFile,
} from './core.mjs';
import { sketch } from './similarity.mjs';
import { parseJsonNoDuplicates } from './release/json.mjs';
Expand Down Expand Up @@ -315,11 +315,17 @@ export function readCaptures(root, { known = null, sketches: wantSketches = true
if (known?.has(rel)) continue;
// A link that lands outside the project is not read (ADR-0076): git stores symlinks,
// and research/raw/x.md -> ~/.ssh/id_rsa would otherwise be read as a capture.
if (!realInside(root, abs)) {
problems.push({ kind: 'capture-outside', file: rel, detail: `${rel} is a link to somewhere outside the project - not read` });
const named = projectFile(root, rel);
const raw = projectFile(dir, name);
if (named.problem === 'outside' || raw.problem === 'outside') {
problems.push({ kind: 'capture-outside', file: rel, detail: `${rel} is a link outside the project or its capture folder - not read` });
continue;
}
if (isDirectory(abs)) continue;
if (named.problem || raw.problem) {
problems.push({ kind: 'capture-unreadable', file: rel, detail: `${rel} is missing or not a regular file - not read` });
continue;
}
// Checked BEFORE reading: every file here is either read or named, never dropped.
let size = 0;
try { size = fs.statSync(abs).size; } catch { /* unreadable - named below */ }
Expand Down Expand Up @@ -643,14 +649,15 @@ export function readCorpus(root) {
// A cited capture that is not on disk is a corpus problem, not a check's discovery.
for (const row of evidence) {
if (!row.raw) continue;
if (exists(at(row.raw)) && !realInside(root, at(row.raw))) {
const named = projectFile(root, row.raw);
if (named.problem === 'outside') {
// A Raw cell that lands outside the project (a "../" spelling, or a link) is never
// read: evidence-context printed such a file into an agent's context (2026-09-28).
problems.push({ kind: 'raw-outside', artifact: PATHS.evidence, row: row.id, file: row.raw, line: row.line,
detail: `${row.raw} (row ${row.id}) is outside the project - a Raw cell names a capture in ${PATHS.raw}/` });
continue;
}
if (!exists(at(row.raw))) {
if (named.problem === 'missing') {
problems.push({ kind: 'raw-dangling', artifact: PATHS.evidence, row: row.id, file: row.raw, line: row.line });
}
}
Expand Down
20 changes: 15 additions & 5 deletions research-kit/lib/evidence-context.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
// evidence closes the unknown stays a person's call — that is the whole protocol, and a
// tool that quietly rendered a verdict here would be the most dangerous thing in the kit.

import { PATHS, resolve, readText, realInside } from './core.mjs';
import { PATHS, resolve, relative, readText, projectFile, isDirectory } from './core.mjs';
import { captureOf, traceOf, parseCapture } from './corpus.mjs';
import { supersededRows } from './checks.mjs';
import { findingWithContext } from './finding.mjs';
Expand Down Expand Up @@ -106,14 +106,24 @@ export function evidenceContext(corpus, unknownId, { limit = 420 } = {}) {
let excerpt = '';
let excerptProblem = '';
let extractor = null;
const rawOutside = row.raw && !realInside(corpus.root, resolve(corpus.root, row.raw));
if (rawOutside && readText(resolve(corpus.root, row.raw)) !== null) {
const named = row.raw ? projectFile(corpus.root, row.raw) : null;
const rawRoot = resolve(corpus.root, PATHS.raw);
const raw = named ? projectFile(rawRoot, relative(rawRoot, named.abs)) : null;
if (named?.problem === 'outside') {
// Never read: this output lands in an agent's context (ADR-0076, 2026-09-28).
excerptProblem = `${row.raw} is outside the project, so it is not read`;
} else if (raw?.problem === 'outside') {
excerptProblem = `${row.raw} names a file outside research/raw, so it is not read`;
} else if (raw?.problem === 'missing') {
excerptProblem = `${row.raw} is named by the row and is not on disk`;
} else if (raw?.problem === 'not-file') {
excerptProblem = isDirectory(raw.abs)
? `${row.raw} is a directory, not a capture`
: `${row.raw} is not a regular file, not a capture`;
} else if (row.raw) {
const text = readText(resolve(corpus.root, row.raw));
const text = readText(raw.abs);
if (text === null) {
excerptProblem = `${row.raw} is named by the row and is not on disk`;
excerptProblem = `${row.raw} could not be read - check its permissions`;
} else {
const body = parseCapture(text).body;
excerpt = excerptFor(body, row.finding, { limit });
Expand Down
8 changes: 8 additions & 0 deletions research-kit/test/artifact-producer.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ function build(root, overrides = {}) {
return createArtifact({ root, ...IDENTITY, ...overrides });
}

test('a directory at the explicit ledger path is refused as an unpackageable file', () => {
const root = collectedProject();
const ledger = path.join(root, PATHS.ledger);
fs.unlinkSync(ledger);
fs.mkdirSync(ledger);
assert.throws(() => build(root), (error) => error.code === 'UNPACKAGEABLE_FILE' && /ledger|fetches/.test(error.message));
});

test('package handoff prose retains gate warnings without changing build authorization', () => {
for (const [label, root, authorized] of [['collected', collectedProject(), false], ['approved', approvedProject(), true]]) {
const built = build(root);
Expand Down
63 changes: 62 additions & 1 deletion research-kit/test/evidence-context.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,75 @@
// concludes nothing.

import { spawnSync } from 'node:child_process';
import { test, describe, assert, assertEqual, fs, path, os, KIT_ROOT, tempDir, cleanup, makePassingProject } from './harness.mjs';
import { test, describe, assert, assertEqual, fs, path, os, KIT_ROOT, tempDir, cleanup, makePassingProject, requireSymlink } from './harness.mjs';
import { findingsReviewState } from '../lib/artifact.mjs';
import { resolve, PATHS } from '../lib/core.mjs';
import { runPreflight } from '../lib/preflight.mjs';
import { readCorpus } from '../lib/corpus.mjs';
import { evidenceContext, renderContext, excerptFor } from '../lib/evidence-context.mjs';

describe('evidence-context');

test('Raw cells cannot read or print a private project file directly or by traversal', () => {
const root = fixture();
const privateFile = path.join(root, '.env');
const sentinel = 'PRIVATE_SENTINEL_FROM_ENV';
fs.writeFileSync(privateFile, sentinel);
const evidenceFile = path.join(root, PATHS.evidence);
const original = fs.readFileSync(evidenceFile, 'utf8');
for (const raw of ['.env', 'research/raw/../../.env']) {
fs.writeFileSync(evidenceFile, original.replace('research/raw/page.md', raw));
const corpus = readCorpus(root);
const real = fs.readFileSync;
let privateReads = 0;
fs.readFileSync = (file, ...args) => { if (String(file) === privateFile) privateReads++; return real(file, ...args); };
let context;
try { context = evidenceContext(corpus, 'U-1'); findingsReviewState(root, corpus); }
finally { fs.readFileSync = real; }
assert.equal(privateReads, 0);
assert.equal(context.cited[0].excerpt, '');
assert.equal(context.cited[0].extractor, null);
assert.match(context.cited[0].excerptProblem, /outside research\/raw/);
for (const args of [[], ['--json']]) {
const result = spawnSync(process.execPath, [CLI, 'U-1', ...args], { cwd: root, encoding: 'utf8', windowsHide: true });
assert.equal(result.status, 0, result.stderr);
assert.ok(!result.stdout.includes(sentinel));
if (args.length) JSON.parse(result.stdout);
}
}
});

test('a directory Raw cell is named as a directory instead of a missing capture', () => {
const root = fixture();
fs.mkdirSync(path.join(root, PATHS.raw, 'folder'));
const corpus = readCorpus(root);
corpus.evidence[0].raw = `${PATHS.raw}/folder`;
const row = evidenceContext(corpus, 'U-1').cited[0];
assert.equal(row.excerpt, '');
assert.match(row.excerptProblem, /directory.*not a capture/);
assert.doesNotMatch(row.excerptProblem, /not on disk/);
});

test('a capture link to a private file inside the project is unread by all capture consumers', () => {
const root = fixture();
const privateFile = path.join(root, '.env');
fs.writeFileSync(privateFile, 'PRIVATE_SENTINEL_FROM_LINK');
const link = path.join(root, PATHS.raw, 'link.md');
requireSymlink(privateFile, link, 'a capture link to a private project file');
const real = fs.readFileSync;
let privateReads = 0;
fs.readFileSync = (file, ...args) => { if ([link, privateFile].includes(String(file))) privateReads++; return real(file, ...args); };
try {
const corpus = readCorpus(root);
corpus.evidence[0].raw = `${PATHS.raw}/link.md`;
const context = evidenceContext(corpus, 'U-1');
findingsReviewState(root, corpus);
assert.equal(context.cited[0].excerpt, '');
assert.match(context.cited[0].excerptProblem, /outside research\/raw/);
} finally { fs.readFileSync = real; }
assert.equal(privateReads, 0);
});

const REPO = path.resolve(KIT_ROOT, '..');
const CLI = path.join(KIT_ROOT, 'bin', 'evidence-context.mjs');

Expand Down
Loading