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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ 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

- Machine config snapshots accept only non-array JSON objects (#368). Scalar or
array configs cannot create or replace a good snapshot; unreadable configs with
unusable backups fall back to fail-closed and role unknown.

- 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
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md

Large diffs are not rendered by default.

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
1791 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
6 changes: 4 additions & 2 deletions research-kit/lib/machine.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -199,11 +199,13 @@ export function readMachineConfig(env = process.env) {
try {
const raw = parseJson(text ?? '');
const { settings, retired, illTyped } = shape(raw && typeof raw === 'object' ? raw : {});
try { writeText(lastGoodPath(file), text); } catch { /* snapshot is best-effort */ }
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
try { writeText(lastGoodPath(file), text); } catch { /* snapshot is best-effort */ }
}
return { settings, state: 'readable', source: file, error: null, retiredKeys: retired, illTypedKeys: illTyped };
} catch (err) {
const snapshot = readJson(lastGoodPath(file), null);
if (snapshot) {
if (snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot)) {
const { settings, retired, illTyped } = shape(snapshot);
return { settings, state: 'unreadable', resolvedFrom: 'snapshot', source: lastGoodPath(file), error: err.message, retiredKeys: retired, illTypedKeys: illTyped };
}
Expand Down
27 changes: 27 additions & 0 deletions research-kit/test/machine.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,33 @@ test('unreadable with nothing to hold fails CLOSED, and tightens exactly two kno
assert.equal(posture(env).exitCode, 2, 'unreadable and resolved to blocking is its own code');
});

test('a non-object snapshot cannot restore a role or a permissive posture', () => {
for (const value of [[], 'x', [{ role: 'builder' }], null, 0, false, true]) {
const { env } = envWith('{"role": "buil', { snapshot: JSON.stringify(value) });
const read = readMachineConfig(env);
assert.equal(read.resolvedFrom, 'fallback-closed', JSON.stringify(value));
assert.equal(read.settings.role, 'unknown');
assert.equal(read.settings.failOpen, false);
assert.equal(collectionPolicy(env).mayCollect, false);
assert.equal(posture(env).exitCode, 2);
}
});

test('a parsed non-object config never creates or replaces the last-good snapshot', () => {
for (const value of [[], 'x', null, 0, false, true]) {
const contents = JSON.stringify(value);
const { env, file } = envWith(contents);
readMachineConfig(env);
assert.equal(fs.existsSync(`${file}.last-good`), false, `${contents} became a snapshot`);
const held = JSON.stringify({ role: 'builder', failOpen: false });
fs.writeFileSync(`${file}.last-good`, held);
readMachineConfig(env);
assert.equal(fs.readFileSync(`${file}.last-good`, 'utf8'), held);
fs.writeFileSync(file, '{"role": "buil');
assert.equal(readMachineConfig(env).settings.role, 'builder');
}
});

// ADR-0121 (2026-10-02, arena break-test #198): a posture key that is PRESENT and not of its
// type resolved to the laxer default - `{"failOpen":"false"}` to fail-open, `"STRICT"` to
// pluralist, `"HARD-BLOCK"` to ask - while `role` beside them resolves to the restrictive
Expand Down
Loading