From e41fcaddb1eeeb787e2b153f66c23c7d6396f469 Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Tue, 13 Oct 2026 10:29:01 +0300 Subject: [PATCH] fix(machine): reject non-object config snapshots what changed: Only non-array JSON objects may create or restore a last-good machine config snapshot. Added regressions for arrays, strings, numbers, booleans and null, plus preservation of an existing good backup. why: A JSON scalar or array holds no machine settings; restoring it after a parse failure silently enabled collector and fail-open defaults. Apply the existing ADR-0020/0023 fallback instead. No alternative design was set aside. what it touched: machine.mjs, machine tests, architecture map, changelog and README test inventory. No new config key, role or public interface. what you verified: verified - red-first reproduction (23 passed, two expected failures), then machine tests (25 passed, zero failures); handoff OK; root preflight PASS with its existing unstamped-brief warning; doctor READY on the collector with firecrawl-cli. The required full suite runs in the commit gate before acceptance. what you got wrong and fixed: nothing to report. --- CHANGELOG.md | 5 +++++ docs/ARCHITECTURE.md | 2 +- research-kit/README.md | 2 +- research-kit/lib/machine.mjs | 6 ++++-- research-kit/test/machine.test.mjs | 27 +++++++++++++++++++++++++++ 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7affd40..153d040f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` (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`, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ca0813fa..f3d995b5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -228,7 +228,7 @@ is an open decision (2026-09-30): | `panel.mjs` | **The desktop panel** (ADR-0147, 2026-10-07): `createPanel({ project, env, kitRoot, kitHome })` serves `research-kit/panel/` (one page, a script, a stylesheet) and a small JSON API on **127.0.0.1 only**, a thin shell over commands that already exist. It owns no verdict: doctor, handoff, preflight and install run as children with the fixed argument lists in `PANEL_COMMANDS`, in the chosen project folder, and their output is shown with the search key redacted. **No command from the page collects** - `research.mjs` and `decompose.mjs` are absent from `PANEL_COMMANDS`; the one way pages are fetched through the panel is auto-collect (`lib/auto-collect.mjs`, ADR-0148), for a builder's validated request, on a collector, within the operator's caps. Its routes are `GET /api/requests` (the queue and the meter), `POST /api/autocollect` (the settings), `/api/requests/check` and `/api/requests/resume`. It writes two things: the search provider's key into the machine config through `transport.mjs`'s `SEARCH_KEY` (so the vendor's name stays in the adapter and the registry, NFR-3), refused when the config resolves inside a git repository (Rule 6); and which project it shows, a full path to an existing folder, never searched for. The topic and `BRIEF.md` are read, never written (ADR-0056). `builderInstructions` is the hand-off text: the collector's push, ledger included, then the builder's own steps in `$HOME` spelling (ADR-0050) - declare the role, handoff, preflight, inspect an authored complete brief and its current inputs, never collect. Its readiness checklist requires classified map rows and rewritten Findings; a file-presence observation grants no build authorization. Commit-report placeholders require actual observed results. A collected Git-request result waits for the collector to record review/closure before dependent building. Every request passes four checks: the bound address, a Host header naming it (DNS rebinding), an Origin, when sent, that is its own, and for `/api/` the per-launch token, initialized through the launch URL fragment and required in x-panel-token. The launcher prints that capability URL; terminal-log and browser-history privacy are not guaranteed by this module. POSTs must be JSON, so a plain cross-site form is refused. Update is refused when the panel runs from the installed copy, which has nothing newer to install. Command runs, the project switch and auto-collect cycles share one lock (`running`, handed to `createAutoCollect` as `lock`): none runs beside another, so no preflight or update reads the corpus or the kit tree while a cycle writes it. | ADR-0147, ADR-0148 | | `requests.mjs` | **A builder's fact request as a file** (ADR-0148, 2026-10-07): `research/requests/.json`, the `fact-request` skill made machine-readable. `requestProblems(request, { perRequestPages })` validates it as untrusted input: the fixed fields (`fact`, `blocks`, `urls`, `queries`, `prefer`, `unknown`, `maxPages`, `topic`), bounded sizes, and `urlProblem` refuses a non-http(s) URL, one with a user name or password, and one naming this machine or an internal address (`isInternal` from `http-transport.mjs`, ADR-0110). A request over the per-request cap is refused, not trimmed. `listRequests` reads the folder, skipping the collector's own `.result.json` and `.plan.json` and ignoring a name that is not an id, and a request whose file, `.plan.json` or `.result.json` is a symbolic link (a builder's link would make the collector read or overwrite a file of its own machine; `writeText` follows links). `applyRequest` and `writeResult` refuse to write through a link as well. `applyRequest` writes the contract row (a new OPEN `U-n`, or an existing non-CLOSED row; it refuses requests naming a closed unknown) and the `plan.json` entries, and the plan for that run alone, with `maxScrapes` set to the request's pages. `contractUnknownStatus` reads the status of a contract row for continuation checks. `writeResult` records the outcome beside the request. Terminal and partial results are trusted only when `recordTrustedResult` has recorded hashes of both request and result in the machine-local `research-kit.autocollect.json`; `isTrustedResult` checks those hashes so a builder cannot submit a result as if the collector wrote it. The auto-collect settings (`off`/`auto`, pages a request, pages a day, interval, the folder for new topics) and the daily meter live in `research-kit.autocollect.json` beside the machine config (`readAutoCollect`, `saveAutoCollect`, `recordSpend`, `setPaused`); no key is in them. | ADR-0148 | | `auto-collect.mjs` | **The collector listening** (ADR-0148, 2026-10-07): `createAutoCollect({ project, env, kitRoot, exec, lock })` returns `cycle`, `status`, `schedule`, `resume`, `stop`. A cycle resolves the project folder to its final path (`fs.realpathSync.native`) before comparing it with git's top level, so a Windows 8.3 short name or a symlinked folder cannot turn a path git stages into one outside the repository. `status()` uses that same canonical project path so its trust lookup matches collector records across alternate Windows path spellings. A cycle refuses unless `collectionPolicy` says the machine may collect, and skips while another run holds the shared `lock` (the panel's; its own when none is given). Otherwise it runs `git pull --ff-only`; a failed pull is surfaced and blocks the cycle before requests are read or processed. On success, it goes through each request. Results received over git are ignored unless their request and result hashes match collector-local trust records, including in `status()`; trusted terminal results retry pending commit/push delivery without collecting again, and trusted `partial` results carry continuation state and resume only while their contract unknown is not CLOSED. An invalid one gets a refused result, committed and pushed. A valid one, within the per-request and daily caps and with `research/` clean, is applied (`requests.mjs`), then its target-specific `research/requests/.plan.json` runs, followed by `preflight.mjs`, whose verdict is recorded, not acted on. It then stages `research/` and the ledger by name (`git add -f`), commits, and pushes when there is an upstream. Accounting travels on the fixed Node research child's private IPC descriptor (ADR-0149), never its human output: exactly one bounded, typed report with measured pages within the admitted allowance and the exhaustion provider. Missing, invalid, duplicate or over-allowance reporting reserves the remaining admitted allowance against the daily meter, labels actual spend unknown, records a terminal failed result and pauses. Earlier measured continuation attempts stay counted once. A valid received report survives failed exit or later interruption; interruption also pauses. Diagnostic output keeps a bounded redacted tail with a truncation notice, and failure detail is bounded separately. The legacy `spentPages`/`creditsStopped` exports remain diagnostic compatibility helpers. It never passes `--fallback`: received exhaustion accounting makes the loop commit the partial corpus and continuation state, pause until the operator resumes, then reuse the saved target, unknown and remaining page allowance (ADR-0129). A `topic` request gets `new-project.mjs` and `decompose.mjs --max-scrapes 0` in a dated folder under the folder the operator approved, never over an existing project (ADR-0056). It is refused when no folder is approved. Diagnostic details, refusal reasons and preflight verdicts are scrubbed with the existing redactor before result files or commit reports are written (2026-10-08). Status also scrubs inherited trusted result details, delivery diagnostics and pause text without rewriting those files or changing their trust hashes; the configured redactor defines which secret is recognized. Every child gets a fixed argument list; `exec` is injectable for tests. The panel labels its unchanged numeric meter as pages used or reserved. **Allowance regression coverage** (2026-10-09): the four-cycle credit exhaustion and resume fixture runs real synthetic Node scripts against persisted request, plan, result and meter files, with a finite injected Git executor. Native round-trip, partial-delivery and topic-resume fixtures retain real Git and corpus-transfer assertions. The test watchdog remains 60 seconds. **Complete Git answers** (ADR-0151): the bounded child byte buffer reports a private `truncated` flag. Repository and Git metadata paths, upstream, ahead count, commit subject and changed paths, dirty state, and pending delivery status/diff all refuse truncated output before it can authorize work; a malformed ahead count or missing quiet-diff exit also blocks delivery. A newly written partial result is trusted before fingerprinting, so refused inspection stages nothing and cannot make a restart collect it again. Exit-only operations retain bounded diagnostic logs. **Admission survives collector termination** (ADR-0152): `reserveCollection` saves the newly admitted allowance and an owned in-flight pause atomically before the fixed research child starts; a failed meter write prevents spawn. `settleCollection` refunds unused allowance once on a valid receipt, preserves earlier spend and distinct pauses, and retains the full charge for unknown/spawn-failed attempts. Whole collector termination leaves its charge/pause across restart or project switch. Continuation reserves only its remaining newly admitted pages. Day-aware settlement never refunds yesterday against today; cross-day completion charging is conservative, and an over-cap completion remains paused. The existing file writer is atomic, while the mutex remains per collector instance; racing independent instances and power-loss durability are not certified by this correction. Successful result.next says captures were delivered: the builder reads/judges, the collector records Git Findings, closures, MAP and the current authored BRIEF; another fetch is only for a missing external fact. ADR-0052 local received-package review remains available, and derived approval stays role-agnostic (2026-10-08, ADR-0150). | ADR-0148, ADR-0149, ADR-0150, ADR-0151, ADR-0152 | -| `machine.mjs` | The config carries `searxngUrl` (2026-09-30, ADR-0104): a URL, not a secret. `shape()` keeps only the non-empty strings of `skillRoots`: every other key it reads is type-checked, and this one was checked as an array and passed its contents to `path.join`, so `{"skillRoots":[42]}` killed `doctor` with a raw ERR_INVALID_ARG_TYPE stack (break-test 2026-10-01). A dropped entry is silent; naming it would be a new finding, which the freeze does not allow. `editGate.settingsPath` is taken only as a non-empty string, else the anchor (2026-10-02, arena #198): it was the one key `shape()` passed through raw, and a numeric value reached `path.dirname` in `installEditGate` and killed `install-hooks` with a raw stack - the sibling of the `skillRoots` check, found a day later because the first fix was never swept to it. **An ill-typed posture key resolves to its restrictive state** (ADR-0121, 2026-10-02): `shape()` resolves a `failOpen`, `evidencePolicy` or `editGate.mode` that is present and not of its type to `false`, `strict` and `hard-block` through one `postureKey` helper, lists it in `illTyped`, and `readMachineConfig`/`posture` carry `illTypedKeys` beside `retiredKeys` on every path, the snapshot one included. Absent keys keep their defaults. Until then a typo fell to the lax default, the only place a typo relaxed a gate in silence. `browserPath` is read and kept (2026-10-11): it was missing from `shape()`, so the key the browser transport's errors name was never read, and every `saveConfig` - `install-hooks --role` among them - erased it. | **Everything outside the project**: machine config (fail-open posture, `editGate` mode and settings path, `evidencePolicy`, `role`), read in **three states** — `absent` (nothing configured; the fail-open default is the answer), `readable`, and **`unreadable`** (the file exists and does not parse), which holds the last config that parsed from a snapshot beside it and, with nothing to hold, **fails closed**. `readMachineConfig()` returns the settings *and* `state`/`source`/`error`, because the settings alone cannot say whether fail-closed was configured or is the fallback this machine adopted (ADR-0020). Install state, git `core.hooksPath` (scope-aware readers), and the **runtime anchors** — `RUNTIME_ANCHORS`, the one place a runtime's paths are named (settings file, personal skill roots, project-relative skill dir), each a default that `runtimePaths()` / `skillLocations()` let the machine config override (ADR-0012); Git readers/writers accept an explicit child environment, preserving production defaults while allowing R1's disposable machine/Git fixture to isolate global/system origins; reads a retired config key's value into the current shape rather than Its six home-based paths (settings, skills, `agentsHome`, `kitHome`, the config, the install state) use `homeDir()`, so `HOME=""` no longer makes them relative (2026-09-28). dropping a setting the operator had tightened, and reports a retired ENVIRONMENT variable (`CLAUDE_SETTINGS_PATH`) without ever honouring it. Owns the machine role and the collection policy that follows from it (`machineRole`, `collectionPolicy`, `collectionRefusal`) — the single answer to "may this machine collect?". The only module that reaches past the project root. **`localHooksPathOverride` asks git from `cwd`** (2026-10-03, outside audit): git finds the repository by walking up, so a subdirectory - a nested decision project, the folder install-hooks is run from - is governed by the same local `core.hooksPath` as the root; the probe looked for `.git` beside `cwd` and said `null` below it, and `doctor`'s `gateHealth` goes through the same probe now. `previousHooksPath(opts)` reads the folder the install replaced (2026-10-03), the reader beside `setPreviousHooksPath`, with `--type=path` as `hand-on.sh` reads it - the raw value made doctor call a working `~/custom-hooks` missing (second audit, 2026-10-03). `repoTopLevel(opts)` is `git rev-parse --show-toplevel` from a cwd, or null outside a repository (2026-10-03). `skillRoots(env)` is the personal skill roots - the config's `skillRoots`, else the anchor's - which `skillLocations()` and the skill set's deploy both read (2026-10-06, ADR-0146). | ADR-0002, ADR-0010, ADR-0012 | +| `machine.mjs` | The config carries `searxngUrl` (2026-09-30, ADR-0104): a URL, not a secret. `shape()` keeps only the non-empty strings of `skillRoots`: every other key it reads is type-checked, and this one was checked as an array and passed its contents to `path.join`, so `{"skillRoots":[42]}` killed `doctor` with a raw ERR_INVALID_ARG_TYPE stack (break-test 2026-10-01). A dropped entry is silent; naming it would be a new finding, which the freeze does not allow. `editGate.settingsPath` is taken only as a non-empty string, else the anchor (2026-10-02, arena #198): it was the one key `shape()` passed through raw, and a numeric value reached `path.dirname` in `installEditGate` and killed `install-hooks` with a raw stack - the sibling of the `skillRoots` check, found a day later because the first fix was never swept to it. **An ill-typed posture key resolves to its restrictive state** (ADR-0121, 2026-10-02): `shape()` resolves a `failOpen`, `evidencePolicy` or `editGate.mode` that is present and not of its type to `false`, `strict` and `hard-block` through one `postureKey` helper, lists it in `illTyped`, and `readMachineConfig`/`posture` carry `illTypedKeys` beside `retiredKeys` on every path, the snapshot one included. Absent keys keep their defaults. Until then a typo fell to the lax default, the only place a typo relaxed a gate in silence. `browserPath` is read and kept (2026-10-11): it was missing from `shape()`, so the key the browser transport's errors name was never read, and every `saveConfig` - `install-hooks --role` among them - erased it. Only non-array JSON objects may create or restore a last-good snapshot (#368, 2026-10-13). A parsed scalar/array leaves an existing good snapshot intact; an unusable backup falls back to fail-closed with role unknown. | **Everything outside the project**: machine config (fail-open posture, `editGate` mode and settings path, `evidencePolicy`, `role`), read in **three states** — `absent` (nothing configured; the fail-open default is the answer), `readable`, and **`unreadable`** (the file exists and does not parse), which holds the last config that parsed from a snapshot beside it and, with nothing to hold, **fails closed**. `readMachineConfig()` returns the settings *and* `state`/`source`/`error`, because the settings alone cannot say whether fail-closed was configured or is the fallback this machine adopted (ADR-0020). Install state, git `core.hooksPath` (scope-aware readers), and the **runtime anchors** — `RUNTIME_ANCHORS`, the one place a runtime's paths are named (settings file, personal skill roots, project-relative skill dir), each a default that `runtimePaths()` / `skillLocations()` let the machine config override (ADR-0012); Git readers/writers accept an explicit child environment, preserving production defaults while allowing R1's disposable machine/Git fixture to isolate global/system origins; reads a retired config key's value into the current shape rather than Its six home-based paths (settings, skills, `agentsHome`, `kitHome`, the config, the install state) use `homeDir()`, so `HOME=""` no longer makes them relative (2026-09-28). dropping a setting the operator had tightened, and reports a retired ENVIRONMENT variable (`CLAUDE_SETTINGS_PATH`) without ever honouring it. Owns the machine role and the collection policy that follows from it (`machineRole`, `collectionPolicy`, `collectionRefusal`) — the single answer to "may this machine collect?". The only module that reaches past the project root. **`localHooksPathOverride` asks git from `cwd`** (2026-10-03, outside audit): git finds the repository by walking up, so a subdirectory - a nested decision project, the folder install-hooks is run from - is governed by the same local `core.hooksPath` as the root; the probe looked for `.git` beside `cwd` and said `null` below it, and `doctor`'s `gateHealth` goes through the same probe now. `previousHooksPath(opts)` reads the folder the install replaced (2026-10-03), the reader beside `setPreviousHooksPath`, with `--type=path` as `hand-on.sh` reads it - the raw value made doctor call a working `~/custom-hooks` missing (second audit, 2026-10-03). `repoTopLevel(opts)` is `git rev-parse --show-toplevel` from a cwd, or null outside a repository (2026-10-03). `skillRoots(env)` is the personal skill roots - the config's `skillRoots`, else the anchor's - which `skillLocations()` and the skill set's deploy both read (2026-10-06, ADR-0146). | ADR-0002, ADR-0010, ADR-0012 | | `handoff.mjs` | The **arrival question**: `verifyHandoff(root)` reports a ledger that is absent or empty, an `EVIDENCE.md` that is absent or holds no evidence table (`handoff-evidence-missing`, `handoff-evidence-unparsed`; the report carries `rows`, and the CLI and doctor print the count - 2026-10-02: with no rows there were no citations, so "every cited capture on disk" held vacuously and handoff said OK over a corpus whose table a sync tool had dropped), every capture an evidence row names that is not on disk, and a chain that does not verify — each named. Read-only by design: the machine that asks cannot collect the missing bytes. The report also carries `lineEndings`, the captures whose body hash fails *only* because git rewrote CRLF into them on checkout, and **`handoffRemedy(report)` picks the remedy from the cause** rather than printing one text for every way a corpus can look broken (ADR-0020): `HANDOFF_REMEDY` when something did not travel (the collector must push `research/raw/`, dotfiles included), `lineEndingRemedy` when everything travelled and this machine rewrote it (pin with `.gitattributes`, re-check out, touch no collector and spend no credits), both when a report holds both. Consumed by `bin/handoff.mjs` and doctor (blocker on `role=builder`, silent on a collector; doctor passes the snapshot it already read, so the question is composed once for both). The report carries `nothingCollected` (no ledger entry, no evidence row, no capture) since 2026-09-27, so a caller can tell a project never collected from a corpus that failed to travel. The finding names are unchanged, so the doctor and the artifact validator read them as before. The report also carries `brief: { state, todo }` (`briefReview`, 2026-09-27, ADR-0061): the CLI's "handoff OK" is followed by a note when the brief is a draft (naming the judged sections still TODO) or has not been drafted. It is never a finding, and the exit stays 0. **The line-ending remedy is judged by running it** (2026-09-27, ADR-0062): per affected capture it prints `git rm --cached` (the index entry only) then `git checkout HEAD --`, which rewrites the file as LF through the pin, and it prints the pin (`PIN_LINES`, to add in an editor) only when `.gitattributes` lacks it. Every printed command is a plain git command with no comment, so cmd and PowerShell run it as written (ADR-0070). `--renormalize` had fixed only the index, so the capture handoff reads stayed CRLF. A **third cause** (2026-09-28): a ledger present and empty while captures are on disk lost its entries where the captures are - a one-line ledger whose torn tail `doctor --fix-arity` dropped - so `ledgerLost` gets `ledgerLostRemedy()`: restore it from git, or re-collect with `--force`. It was given the push remedy, which on the collector pushes the same empty ledger. A ledger that is merely **short** is the same cause: a cited capture on disk that no entry records is `handoff-capture-unledgered` (2026-09-28) and gets the same remedy - handoff had said OK and exited 0 while preflight failed it as `fetch-entry-exists`. **`CORPUS_ADD`** (2026-09-28) is how the kit prints putting a corpus into git - `git add research/`, then `git add -f research/raw/.fetches.jsonl` - shared by the push remedy and the commit gate's fix: both said `git add -f` on the whole folder, which also committed the machine-local `.usage.jsonl`, `.failures.jsonl`, `.diagnostics.jsonl` and `.fetches.lock`. A **fourth cause** (2026-10-04, break-test pass 4, F5b): a capture whose body hash fails and NOT by line endings (`body-unmodified/modified`) was changed after its fetch, and the report carries it as `altered`; it no longer counts as "did not travel", and **`alteredRemedy(files, { isRepo })`** prints, per file, `git status --porcelain --` to tell a local change from a committed one, then `git checkout HEAD --` followed by handoff again (no folder-wide checkout, which would discard uncommitted work beside it), and for the committed case `git log --oneline --` to find the commit that held the fetched bytes and restore from it, here or on the collector; without git metadata it prints no command. Judged by running it (2026-10-04, external review): a dirty status says only that this checkout changed the file after the commit - a capture committed altered and edited again is dirty too - so the remedy no longer claims the committed copy is the fetched one; and `--force` is no longer offered, because a new fetch writes a new capture beside this one, or refuses to overwrite a differing source sibling, while `verifyLedger` still checks the entry that names this file. The push remedy it used to get sends the same bytes again when the change was committed, and sends an operator to another machine when the committed copy here was one checkout away. **`safePathspec(file)`** (2026-10-04, two external reviews) decides whether a capture path is printed inside a git command at all: letters, digits, `.`, `_`, `-` and `/`, the alphabet the collector's own names come from. Anything else is named but never printed as a command, because no one spelling is read literally by cmd, PowerShell and sh alike and by git: a space unquoted is two pathspecs, `$copy` inside double quotes is expanded to nothing by PowerShell, and `[12]` is a git glob whatever the shell did, so `git checkout HEAD --` restored a neighbour and discarded its uncommitted work. For those the remedy prints commands that type no name at all: the operator lists them, one per line, in `handoff-names.txt` (`NAMES_FILE`) and runs `git --literal-pathspecs checkout HEAD --pathspec-from-file=handoff-names.txt` (the line-ending remedy adds the `git rm --cached` step the same way) - "quote it for your shell and prefix `:(literal)`" was not enough, because cmd expands `%NAME%` inside double quotes and PowerShell `$name` (third external review, 2026-10-04, verified by running in cmd); `--pathspec-from-file` hands git the bytes of the file with no shell in between, and `--literal-pathspecs` switches pattern syntax off (both git 2.25+). Such a name reaches the ledger rarely but not only by hand: `captureName` embeds the URL's host as it is, so an IPv6 literal or an adapter-supplied host outside DNS's alphabet produces one (third review). The classification runs over EVERY affected file (`classify`), not the five the text names: an unsafe sixth file got no note and "handle the remaining files the same way, each by name" - an instruction to type the glob that restores a neighbour (third external review, 2026-10-04). **`insideRepository(root)`** (2026-10-04, two external reviews) decides whether the runnable remedies are printed, and it asks git: `git -C rev-parse --is-inside-work-tree`. It was `root/.git` alone, so a nested decision project (ADR-0030), which has none of its own, was told it had "no git metadata"; then a walk up for a `.git` entry, which walked past a bare repository the project sat inside and printed a checkout git refused with "this operation must be run in a work tree". The commands are git's, so git decides whether they run; without git on PATH the walk is the fallback: it walks the PHYSICAL path (realpath first - a project reached through a junction into a repository had read as outside it, and one linked out of a repository as inside; third review) and reads `GIT_CEILING_DIRECTORIES` as git does: absolute entries only, each through realpath until an empty entry, after which the rest are taken as written. Git's pathspecs are relative to the cwd, so the printed commands run unchanged from the project folder. The tests that want "no git metadata" from a scratch fixture set `GIT_CEILING_DIRECTORIES` above it and go through `handoffRemedy`, so a detection that always says "repository" fails them. **`repositoryState(root)`** is the full answer, `{ inside, refused }`: when git answered with something other than yes, "not a git repository" or "must be run in a work tree" - `fatal: detected dubious ownership` - `refused` carries its first line and both remedies say git refused to read the repository and to fix that first, where they said "no git metadata, re-copy the corpus" (third review, 2026-10-04); no command is printed either way. | ADR-0011, ADR-0020 | | `bundle.mjs` | The **archive this project arrived as**, and how far the tree has moved from it. `BUNDLE_INDEX.md` lists 80 SHA-256 digests taken at the 2026-09-17 handover; until ADR-0028 **nothing had ever read them**, which is how ADR-0022 could treat them as a constraint for three days without noticing the constraint was already violated. `verifyBundle(root)` sorts every entry into unchanged / drifted / unexpected / missing, where "drifted" means a file in `EXPECTED_TO_DRIFT` — the corpus, the map, the ADR index, the manifest itself, (from 2026-09-20) `CONTEXT.md` and the root `README.md`, which are documentation ABOUT the code and go stale the moment it moves, and (from 2026-09-21) `AGENTS.md`, which is the file the agent protocol is *written in* — caught drifting by this check when ADR-0030 gave decision research a committed home and the rule that sends an agent there had to be where agents read their rules — each of which moves because one of this project's own rules *requires* it to. The list is deliberately narrow: exempting a planning document would leave the report unable to say anything, and a test asserts none is exempted. Hashes **bytes**, not decoded text. Reports; never fails a build — a changed file is a fact about history, and the only thing that makes it a problem is nobody knowing. `bin/bundle.mjs --json` prints `verifyBundle`'s result as JSON (2026-09-28): the flag was accepted and never read, so a caller parsing it got the markdown report. `START_HERE.md` joined `EXPECTED_TO_DRIFT` on 2026-09-29: the archived copy held the author's machine paths, and the portable one is the template rendered with `$HOME`. | ADR-0028 | | `git-config.mjs`, `config.mjs` | Compatibility shims → `machine.mjs`. | ADR-0002 | diff --git a/research-kit/README.md b/research-kit/README.md index 4bc12369..5ac159b0 100644 --- a/research-kit/README.md +++ b/research-kit/README.md @@ -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). diff --git a/research-kit/lib/machine.mjs b/research-kit/lib/machine.mjs index 59d715ac..7ebc8aef 100644 --- a/research-kit/lib/machine.mjs +++ b/research-kit/lib/machine.mjs @@ -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 }; } diff --git a/research-kit/test/machine.test.mjs b/research-kit/test/machine.test.mjs index 251d7851..d5144f95 100644 --- a/research-kit/test/machine.test.mjs +++ b/research-kit/test/machine.test.mjs @@ -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