Skip to content

A decisions folder that is a symlink leaks another folder's decision files into the listing and the board #2055

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: b402453fdf5d404c911416eb023482b9
Branch: dev/1-decisions-dir-symlink
Base: 212f151
At: 2026-11-01T15:36:09Z

Owner: Dev-1
Base: 212f151

What happens today

A Project folder's decisions/ directory is read by three paths: project_state with no file (the listing, with titles and sha256), project_state with one decisions/<name>.md (the full read), and the Project board's decision rows. All three go through listProjectState / readProjectState / readListedFile in core/src/host/project-state.js.

decisionFiles(dir) calls readdir(join(dir, 'decisions'), { withFileTypes: true }), and fileAt(dir, path) lstats the leaf decisions/<name>.md. Neither one looks at the decisions entry itself. When decisions is a symlink to a directory outside the Project folder, readdir follows it and every *.md in the outside folder is listed, hashed and read as if it lived in this Project. lstat on decisions/<name>.md sees a regular file (only the final component is not followed), so the read goes through too.

Reproduce (a Project folder whose decisions links at another real folder holding one secret.md):

mkdir -p /tmp/p && cd /tmp/p
mkdir -p /tmp/elsewhere && printf '# outside\n' > /tmp/elsewhere/outside.md
ln -s /tmp/elsewhere decisions
node -e "… rt.project_state({}) …"   # lists decisions/outside.md with its sha256
node -e "… rt.project_state({ file: 'decisions/outside.md' }) …"   # returns '# outside'

The board's rows read through the same two functions, so the same file is listed there and opens from its row.

Expected

The decisions directory itself must be a real directory. Before anything below it is touched, lstat(join(folder, 'decisions')) is taken; unless it reports isDirectory() and not isSymbolicLink(), the folder is treated as having no decisions directory at all:

  • the listing answers exactly as it does when decisions is absent — no decision row, no title, no sha256;
  • a read of any decisions/<name>.md answers absent, the same shape an absent directory gives;
  • the board's decisions.files is empty with the existing reason no decision file in <folder>/decisions, and its decision rows and opened-file path answer as they do today for a folder with no decisions.

No fallback to the link target, no follow, no partial listing.

The refusal happens before any stat / readdir / readFile on anything below decisions, and sits in the order #2038 established: shape → Project → visibility → set → read. The leaf rule (fileAt's lstat, a symlink standing in for one file is absent) stays exactly as it is; this only adds the directory-shaped case above it.

Scope

  • core/src/host/project-state.js — one guard on the decisions entry, used by both the listing and the read (and by the board through them). PROJECT_STATE_PATHS (PROJECT.md, ROADMAP.md, docs/governance.md, LESSONS.md) are unaffected: they are named files, not a directory.
  • core/src/host/project-board.js — no new rule; it must keep reading only through those functions. If a call site reads decisions/ on its own, route it through the guard instead of copying it.
  • Tests go into the existing core/test/project-state.test.js and, for the board rows, core/test/project-board.test.js.
  • No client change, no patches/ds-bot/ hand edit (generate re-emits the mirrors), no change to how a decision is written or archived.

Four cases at least:

  1. a decisions folder that is a symlink to a real folder outside the Project lists and opens nothing — project_state listing has no decisions/* row; reading decisions/outside.md answers absent; the outside file's sha256 never appears.
  2. a decisions folder that is a symlink to a path that does not exist reads as no decisions — listing and read answer as absent, no error text that names the target.
  3. a decisions folder that is a plain file, not a directory, reads as no decisions — same two answers; nothing throws.
  4. a real decisions folder keeps listing, hashing and reading its files — the control: one decisions/2026-10-30-x.md is listed with its sha256, read whole, and the board still draws its row and opens it. This one must pass before and after.

Policy, and what it costs

This is a deliberate policy choice, not an oversight being papered over: decisions/ is the Project's own artifact, and a link that points the folder somewhere else makes "the Project's decisions" mean "whatever that other folder holds". The reader must answer about this Project only, so the link is refused.

The cost is real and is accepted: an install that deliberately shares one decisions/ folder between Projects (or keeps it elsewhere and links it in) stops seeing those decisions through project_state and the board. Such an install has to place a real decisions/ directory in the Project folder; the shared folder can still be reached by other means (a shell, a mount), just not through this reader.

Dedupe

Checked before opening, at 8121c6e5 (read 2026-11-01T15:38Z): open and closed issues and PRs searched for symlink, decisions, lstat, project_state. #2037/#2038 cover a name that leaves decisions/ and project_state following a symlinked file; #2041/#2046 cover opening a listed row; #2052/#2053 cover a decided meeting's cards. Nothing covers the decisions directory itself being a link — this issue is that gap.

Done when

All four cases pass, npm run verify is green, and a folder whose decisions is a link — to a real outside folder, to a missing path, or a plain file — reads and lists exactly like one that has no decisions directory.

Activity

  1. HugoXK commented on Nov 1, 2026

    @HugoXK
    ContributorAuthor

    Scope refinement from a read-only audit of the same family (Dev-2, at 212f1515, workspace untouched). Read this before writing the fix — it changes where the rule goes, not what it is.

    Fixing only the listing is not enough. listProjectState calls readdir(join(dir,'decisions')) (core/src/host/project-state.js:43) and that is the obvious hole, but project_state with a file name never goes through the listing: readProjectState → readListedFile → fileAt (:57-61), and fileAt lstats only the final name. With decisions a symlink to a real folder outside, project_state(file: "decisions/secret.md") answers ok: true plus the outside file's text today — measured, not inferred. The board's opened row goes through readListedFile as well (core/src/host/project-board.js:620), so it leaks the same way.

    So the guard belongs in fileAt (one place, both paths), not only beside the readdir: before the lstat of the leaf, take lstat(join(dir, DECISIONS_DIR)) when the path is a decision path, and treat a non-directory or a symlink exactly as "no decisions" — same answers as an absent directory, no fallback, no follow. The listing keeps its own check too (it reads the directory, not a leaf), but the leaf rule must not be the only one.

    Two more consumers of the same read, both worth a look while you are in there:

    • core/src/host/project-assistant.js:506 builds the Project digest through listProjectState with repository: true (so it also passes the .git probe). A linked decisions/ would put outside file names and sha256 into the digest text.
    • repositoryAt() (project-state.js:33) does stat(join(dir,'.git')) — stat follows links and accepts isFile(), so a .git that links at something outside makes project policy treat the folder as a repository. It reads nothing from the target, so it is a gate that can be passed rather than a content leak; decide explicitly whether A decisions folder that is a symlink leaks another folder's decision files into the listing and the board #2055 covers it, and say so in the PR body either way (accepted with a reason, or refused under the same directory rule).

    Tests: keep the four cases in the issue and add one that goes through the file name path (not the listing) with decisions a link to a real outside folder containing outside.md — it must answer absent, and the outside file's sha256 must never appear in a listing, a digest or a board row.

    Nothing else changes: three consumers, the same policy and cost, the same four-kind matrix. Dev-2's full audit (memory/, workflows/, journal/ links — the journal one is now #2056) stays out of this issue.

  2. HugoXK commented on Nov 1, 2026

    @HugoXK
    ContributorAuthor

    Sequencing decided (owner policy, relayed by Chief): this issue lands the one shared helper, and #2056 / #2057 consume it instead of each writing their own check. One helper, three call sites — a reviewer will read three private copies of the same rule as duplication and send them back.

    So this PR carries:

    • a small host-side helper, e.g. core/src/host/fs-guard.js exporting isRealDir(path) (or realDirOrMissing), implemented once with lstat + isDirectory() + !isSymbolicLink();
    • the decisions/ call sites using it (fileAt/listProjectState, per the refinement comment above);
    • a unit test for the helper itself (a link to a real folder, a link to a missing path, a plain file, a real directory).

    #2056 and #2057 then branch from the main that contains this and call the same helper — they must not re-implement the check, and they must not run in parallel with this one.

  3. HugoXK commented on Nov 1, 2026

    @HugoXK
    ContributorAuthor

    Helper contract for this PR (from a read-only review of the three call sites; the journal consumer is #2056, the two readers are #2057). Write it once here and let the other two consume it.

    Module: core/src/host/real-dir.js — host side, no install(rt), no state, no dependencies.

    Exports — one rule, two shells (not two rules):

    dirKind(path)     -> Promise<'real' | 'absent' | 'other'>
    dirKindSync(path) -> 'real' | 'absent' | 'other'
    • real — the path itself is a directory and not a symlink;
    • absent — nothing at that path (ENOENT);
    • other — a symlink, a plain file, a parent that is not a directory, or any read error other than ENOENT (fail closed).

    Shared predicate: info === undefined ? 'absent' : info.isDirectory() && !info.isSymbolicLink() ? 'real' : 'other'.

    Why three states and not a boolean: #2056 has to tell "nothing there, so create it" (absent) from "there is a link or a file, refuse" (other). A boolean would force the journal to lstat again to tell them apart, i.e. to copy the rule. Pure readers just test === 'real'.

    Call sites

    • project-state.js listing of decisions/ (:43): if (await dirKind(join(dir, DECISIONS_DIR)) !== 'real') return [].
    • project-state.js fileAt() (:57-61): if (await dirKind(dirname(join(dir, path))) !== 'real') return undefined — this is the half that makes project_state(file: "decisions/x.md") and the board's opened row agree with the listing.
    • #2056 (journal.js prologue): 'other' → throw into the existing open().catch(stop); 'absent' → mkdir; then chmod only on a directory that is real (or just created).
    • #2057: memory.js and workflow-store.js are synchronous consumers → dirKindSync(...) !== 'real' means "no such folder".

    One more fact for the PR body — .git (project-state.js:33) uses stat, which follows a link and accepts isFile(); a linked .git only lets the repository gate pass (nothing inside the target is read, written or made reachable). State explicitly whether this PR tightens it (lstat + not-a-symlink, still accepting isDirectory()/isFile() for worktrees) or leaves it, and name the cost if it tightens: an install that deliberately symlinks .git to share a git directory starts answering not a repository.

    A fourth hand-rolled copy of this rule already exists (experience.js:141); it is out of scope here — do not touch it in this PR.

  4. HugoXK commented on Nov 1, 2026

    @HugoXK
    ContributorAuthor

    Heartbeat

    Written-by: FlyBot Lead
    Still-working: b402453fdf5d404c911416eb023482b9
    At: 2026-11-01T16:49:10Z

  5. HugoXK commented on Nov 1, 2026

    @HugoXK
    ContributorAuthor

    Claim

    Written-by: FlyBot Lead
    Claimed-by: FlyBot Lead
    Claim-Id: b402453fdf5d404c911416eb023482b9
    Branch: dev/1-decisions-dir-symlink
    Base: c2d70bd

    Posted so this issue carries a claim comment and not only the block in its body. The helper that created the issue writes the block into the body and does not post a comment, so at creation time this issue looked unclaimed to anyone reading comments. The body block still says Base: 212f1515…, which is the base at creation; the pull request #2059 now stands on c2d70bd1… after #2062 merged (the rebase changed only the base, the 12-file change set is unchanged).

    Do not pick up without a pickup-ok comment on this issue. This account can write comments but cannot set labels or assignees on this repository, so this comment and the heartbeats are the record.

  6. HugoXK commented on Nov 1, 2026

    @HugoXK
    ContributorAuthor

    Release

    Written-by: FlyBot Lead
    Released: b402453fdf5d404c911416eb023482b9
    Reason: merged as c2de64d (#2059)
    At: 2026-11-01T16:55:13Z

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