Repository navigation
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: 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
decisionsis absent — no decision row, no title, no sha256; - a read of any
decisions/<name>.mdanswersabsent, the same shape an absent directory gives; - the board's
decisions.filesis empty with the existing reasonno 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 thedecisionsentry, 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 readsdecisions/on its own, route it through the guard instead of copying it.- Tests go into the existing
core/test/project-state.test.jsand, for the board rows,core/test/project-board.test.js. - No client change, no
patches/ds-bot/hand edit (generatere-emits the mirrors), no change to how a decision is written or archived.
Four cases at least:
a decisions folder that is a symlink to a real folder outside the Project lists and opens nothing—project_statelisting has nodecisions/*row; readingdecisions/outside.mdanswersabsent; the outside file's sha256 never appears.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.a decisions folder that is a plain file, not a directory, reads as no decisions— same two answers; nothing throws.a real decisions folder keeps listing, hashing and reading its files— the control: onedecisions/2026-10-30-x.mdis 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
HugoXK commentedon Nov 1, 2026
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.
listProjectStatecallsreaddir(join(dir,'decisions'))(core/src/host/project-state.js:43) and that is the obvious hole, butproject_statewith a file name never goes through the listing:readProjectState→readListedFile→fileAt(:57-61), andfileAtlstats only the final name. Withdecisionsa symlink to a real folder outside,project_state(file: "decisions/secret.md")answersok: trueplus the outside file's text today — measured, not inferred. The board's opened row goes throughreadListedFileas 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 thereaddir: before thelstatof the leaf, takelstat(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:506builds the Project digest throughlistProjectStatewithrepository: true(so it also passes the.gitprobe). A linkeddecisions/would put outside file names and sha256 into the digest text.repositoryAt()(project-state.js:33) doesstat(join(dir,'.git'))—statfollows links and acceptsisFile(), so a.gitthat links at something outside makesproject policytreat 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
decisionsa link to a real outside folder containingoutside.md— it must answerabsent, 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.HugoXK commentedon Nov 1, 2026
Sequencing decided (owner policy, relayed by Chief): this issue lands the one shared helper, and
#2056/#2057consume 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:
core/src/host/fs-guard.jsexportingisRealDir(path)(orrealDirOrMissing), implemented once withlstat+isDirectory()+!isSymbolicLink();decisions/call sites using it (fileAt/listProjectState, per the refinement comment above);#2056and#2057then 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.HugoXK commentedon Nov 1, 2026
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, noinstall(rt), no state, no dependencies.Exports — one rule, two shells (not two rules):
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 thanENOENT(fail closed).Shared predicate:
info === undefined ? 'absent' : info.isDirectory() && !info.isSymbolicLink() ? 'real' : 'other'.Why three states and not a boolean:
#2056has to tell "nothing there, so create it" (absent) from "there is a link or a file, refuse" (other). A boolean would force the journal tolstatagain to tell them apart, i.e. to copy the rule. Pure readers just test=== 'real'.Call sites
project-state.jslisting ofdecisions/(: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 makesproject_state(file: "decisions/x.md")and the board's opened row agree with the listing.#2056(journal.jsprologue):'other'→ throw into the existingopen().catch(stop);'absent'→mkdir; thenchmodonly on a directory that isreal(or just created).#2057:memory.jsandworkflow-store.jsare synchronous consumers →dirKindSync(...) !== 'real'means "no such folder".One more fact for the PR body —
.git(project-state.js:33) usesstat, which follows a link and acceptsisFile(); a linked.gitonly 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 acceptingisDirectory()/isFile()for worktrees) or leaves it, and name the cost if it tightens: an install that deliberately symlinks.gitto share a git directory starts answeringnot 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.Read a linked decisions folder as no decisions (ThinkFlowLab#2055)
HugoXK commentedon Nov 1, 2026
Heartbeat
Written-by: FlyBot Lead
Still-working: b402453fdf5d404c911416eb023482b9
At: 2026-11-01T16:49:10Z
HugoXK commentedon Nov 1, 2026
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#2059now stands onc2d70bd1…after#2062merged (the rebase changed only the base, the 12-file change set is unchanged).Do not pick up without a
pickup-okcomment 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.HugoXK commentedon Nov 1, 2026
Release
Written-by: FlyBot Lead
Released: b402453fdf5d404c911416eb023482b9
Reason: merged as c2de64d (#2059)
At: 2026-11-01T16:55:13Z
fix(host): the state journal refuses a hard link at its own path (Thi…