Skip to content

A symlinked journal folder lets the host chmod and write into a folder outside the home #2056

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: 3715e54f5468494999c0e69737d278cc
Branch: dev/2-journal-dir-symlink
Base: 212f151
At: 2026-11-01T15:48:37Z

Owner: Dev-2
Base: 212f151

What happens today

openJournal() (core/src/host/journal.js:241) starts with

await mkdir(dirname(path), { recursive: true, mode: 0o700 })
await chmod(dirname(path), 0o700)

and only afterwards lstats the journal file (:246). The directory above the journal is never checked. When that directory is a symlink to a directory outside the home, all three steps follow it: the mkdir is a no-op (the target exists), chmod applies 0o700 to the target directory, and every later write — appendFile (:292), the temp file plus rename on prune (:281/:284) — lands inside the target. So turning stateJournal on in a home whose journal folder is a link silently changes a foreign directory's permissions and writes the team's state journal into it.

The journal file itself is guarded (lstat + isFile() at :246/:251), which is the leaf rule; the directory above it has no rule at all.

Reproduce (a home whose journal folder links at another real directory):

mkdir -p /tmp/h /tmp/elsewhere && chmod 755 /tmp/elsewhere
ln -s /tmp/elsewhere /tmp/h/journal
# start a host with stateJournal on and a journal path under /tmp/h/journal
stat -c '%a' /tmp/elsewhere        # became 700
ls /tmp/h/journal                  # the journal file is really in /tmp/elsewhere

Expected

The directory the journal lives in must be a real directory, checked before anything is created, chmod-ed or written. lstat(dirname(path)) must report isDirectory() and not isSymbolicLink(); when it does not (a link to a real folder, a link to a path that does not exist, or a plain file), recording stops exactly the way any other journal problem stops it today: one warning naming the path, stopped = true, and no mkdir, no chmod, no write through the link. Saving the team must keep working, as :227 already promises for every other journal failure.

This is the same rule #2055 sets for decisions/: a directory that is a link is treated as not there, no fallback and no follow.

The leaf rule at :246 stays as it is; this adds the directory-shaped case above it.

Scope

  • core/src/host/journal.js — the open() prologue only. store.js's call site (config.stateJournal) is unchanged; the writer keeps its interface and its "never stop the team from saving" behaviour.
  • Tests in the existing core/test/journal.test.js (or the file that already covers openJournal, if it lives elsewhere).
  • No client change; generate re-emits the patches/ds-bot/files/ mirror.

Four cases at least:

  1. a journal folder that is a symlink to a real folder outside the home records nothing and stays silent on disk — warning once, stopped true, the target directory's mode is unchanged (assert 0o755 stayed 0o755), no journal file appears inside it.
  2. a journal folder that is a symlink to a missing path records nothing — same, and nothing is created.
  3. a journal folder that is a plain file records nothing — same; no throw escapes to the caller.
  4. a real journal folder still records — the control: entries are written, replay still works, prune still works, mode 0o700 on its own directory and 0o600 on the file.

Policy, and what it costs

Same policy as #2055: the host's own bookkeeping directory is the host's, and a link that points it elsewhere would let a home write into (and chmod) a folder the team never named. Refusing the link is the accepted cost: an install that deliberately keeps its journal folder elsewhere has to place a real directory in the home instead.

Because this one has a write and a permission side effect on a foreign directory, it is the highest-risk item of the folder-symlink family found in the audit (the other two — memory/ and workflows/ — are read-only and get their own issue).

Dedupe

Checked at 212f1515 (read 2026-11-01T15:48Z): open and closed issues/PRs searched for symlink, journal, lstat, stateJournal. Only #317 (unrelated interop docs) mentions a journal symlink; nothing covers the journal directory, and #2055 is scoped to decisions/ only.

Done when

All four cases pass, npm run verify is green, and a home whose journal folder is a link records nothing while the team keeps saving.

Activity

  1. HugoXK commented on Nov 1, 2026

    @HugoXK
    ContributorAuthor

    Sequencing correction (owner policy via Chief): do not start this until #2055 has merged. This family uses one shared helper (isRealDir / realDirOrMissing, introduced by #2055 in a host-side module); this issue must call it, not carry its own copy of the check. Branch from the main that contains #2055's helper.

    Two things this issue must keep once it starts:

    • the assertion that the target directory's mode is unchanged (a 0o755 folder stays 0o755) — that is the evidence that distinguishes this write-path case from the read-only ones, and it is why this one is ranked first in the family;
    • the rule that a refusal never runs mkdir, chmod or a write — it goes through the existing stop() path with one warning.

    No other change to scope. #2057 follows on the same helper.

  2. HugoXK commented on Nov 1, 2026

    @HugoXK
    ContributorAuthor

    Claim

    Written-by: FlyBot Lead
    Claimed-by: FlyBot Lead
    Claim-Id: 3715e54f5468494999c0e69737d278cc
    Branch: dev/2-journal-dir-symlink
    Base at posting: c2d70bd

    Posted so this issue carries a claim comment and not only the block in its body: the helper that created it writes the block into the body and does not post a comment, so by comment alone this issue read as unclaimed. The body block's Base: 212f1515… is the base at creation, i.e. history.

    The base above is only what main was when this was written; it is not a promise about where the branch will be cut. Whoever starts the work posts a heartbeat at that moment naming the base it actually cut from and the branch.

    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.

  3. HugoXK commented on Nov 1, 2026

    @HugoXK
    ContributorAuthor

    Heartbeat

    Written-by: FlyBot Lead
    Still-working: 3715e54f5468494999c0e69737d278cc
    Branch: dev/2-journal-dir-symlink
    Base: c2de64d

    Started now, cut from this base — the live main that carries #2055's core/src/host/real-dir.js (dirKind / dirKindSync, three states: real, absent, other). The Base: in the body above is the base at posting time; this comment is the base the branch was actually cut from. This issue calls that shared helper and does not carry its own copy of the check.

  4. HugoXK commented on Nov 1, 2026

    @HugoXK
    ContributorAuthor

    Release

    Written-by: FlyBot Lead
    Released: 3715e54f5468494999c0e69737d278cc
    Reason: merged as 4e48aed (#2065)
    At: 2026-11-01T17:19:31Z

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