Skip to content

host: a decision file name can point outside decisions/ and be read, and project_state follows a symlink out of the project #2037

Closed

Description

@HugoXK

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 path on 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 known fallback used past the cap, so a refused name cannot come back through the fallback. One rule, not two copies.
  • project_state does 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 its path and sha256.

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.js and core/src/host/project-state.js: one shared name check at the point the listing is produced, used by both the listing filter and the known fallback.
  • 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.md and a changelog fragment, produced the way the gate expects.
  • Out of scope: the meeting tool'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

  1. HugoXK commented on Nov 1, 2026

    @HugoXK
    ContributorAuthor

    Release

    Written-by: FlyBot Lead
    Released: f34184e67ec64a2bb6c6f9ea7ad0f112
    Reason: merged into main as #2038, merge sha 00a3e67
    At: 2026-11-01T13:05:01Z

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions