Repository navigation
host: a decision file name can point outside decisions/ and be read, and project_state follows a symlink out of the project #2037
Description
Implementation: FlyBot (ours) — PR follows within 24h. Do not pick up without a pickup-ok comment on this issue.
Written-by: FlyBot Lead
Claimed-by: FlyBot Lead
Claim-Id: f34184e67ec64a2bb6c6f9ea7ad0f112
Branch: dev/1-decision-name-guard
Base: 5528769
At: 2026-11-01T12:46:25Z
Implementation: FlyBot (ours) — PR follows within 24h. Do not pick up without a pickup-ok comment.
Written-by: FlyBot Lead
What the code does today
core/src/host/project-board.js builds the decision listing from readdir() on the project's decisions folder, and archivedOf accepts a name when it starts with decisions/ and ends with .md. A name that carries a .. segment passes both tests, so join(folder, 'decisions', name) resolves outside the project folder and the board draws a row whose path and title come from that outside file.
The same folder is served by readProjectState (the project_state tool), which resolves a symlinked file and returns its text.
Neither path needs the meeting tool: the decisions folder and the project state file are plain files a user or a script can write.
Expected
- A name that is not a plain file directly inside the decisions folder never becomes a board row, and never becomes a
pathon a meeting whose file fell past the 200-row cap. Four classes, each refused on its own: a..segment (single or multi), an absolute path, a symlink (even one with a legal name, and even one pointing back inside), and a name whose file this listing did not read. - The same rule governs the
knownfallback used past the cap, so a refused name cannot come back through the fallback. One rule, not two copies. project_statedoes not follow a symlink out of the project folder: it returns the shape it already returns for a file that is not there, never the outside file's text.- A legitimate hand-written name (
decisions/handwritten-notes.md, no date prefix) keeps working, and a legitimate decision kept inside the cap keeps itspathandsha256.
Reproduction at base 55287698
Red first, base source + only the new tests, core/test/project-board.test.js: 40 tests / 37 pass / 3 fail, three runs identical.
| class | case | actual | expected |
|---|---|---|---|
.. multi segment (decisions/zz/../../../outside.md) |
a decision name with a .. segment never becomes a row or a past-cut path | decisions/zz/../../../outside.md |
`` |
absolute (/etc/passwd, and an absolute path to <base>/outside.md) |
an absolute decision name is refused, never read | refuses today already; the case pins it | `` |
symlink (legal name decisions/2026-01-02-link.md → outside) |
a symlinked decision name is refused, never followed | decisions/2026-01-02-link.md |
`` |
name this listing did not read (decisions/sub/2026-01-02-deep.md, a real file) |
a decision name this listing did not read never becomes a past-cut path | decisions/sub/2026-01-02-deep.md |
`` |
In the red state the first, third and fourth also turn the outside file into one row of decisions.files, with the outside heading as title. Each class is seeded on its own, so the cases cannot mask one another.
project_state on the same base returns {"ok":true,…,"text":"# Outside secret\n"} for a symlinked state file.
Scope
core/src/host/project-board.jsandcore/src/host/project-state.js: one shared name check at the point the listing is produced, used by both the listing filter and theknownfallback.core/test/project-board.test.js: the cases above, each class seeded separately.- The two
patches/ds-bot/files/src/host/mirrors, the test mirror,docs/subsystems/core-host.mdand a changelog fragment, produced the way the gate expects. - Out of scope: the
meetingtool's own file naming (it generates the name), and any change to how decisions are stored.
Dedupe
Searched before opening: path traversal 0, decisions directory escape 0, isDecisionPath 0, project_state symlink 0; symlink decision 5 unrelated; archivedOf 4, of which #1818 (read from the folder the meeting archived in) and #2034 (name a decided meeting's file past the 200-row cap) are the neighbours this builds on and neither checks the name. No issue covers this defect.
Activity
HugoXK commentedon Nov 1, 2026
Release
Written-by: FlyBot Lead
Released: f34184e67ec64a2bb6c6f9ea7ad0f112
Reason: merged into main as #2038, merge sha 00a3e67
At: 2026-11-01T13:05:01Z