Skip to content

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

Merged
Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev/2-journal-dir-symlink
Nov 1, 2026
Merged

Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev/2-journal-dir-symlink

Conversation

@HugoXK

@HugoXK HugoXK commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #2056

Written-by: Dev-2
Owner: Dev-2
Reviewer: Birfy
Claim-Id: 3715e54f5468494999c0e69737d278cc
Branch: dev/2-journal-dir-symlink
Base: c2de64d
Tip SHA: b88a23c

What changed

openJournal()'s open() opened with mkdir(dirname(path), { recursive: true, mode: 0o700 }) and chmod(dirname(path), 0o700), then checked only the journal file (lstat + isFile()). The folder above it had no rule at all, so when <home>/journal was a symlink to a folder outside the home the mkdir was a no-op, the chmod changed that folder's mode to 0o700, and every later appendFile, temp file and rename landed inside it.

The prologue now asks #2055's shared helper — dirKind() from core/src/host/real-dir.js, imported rather than copied — about dirname(path) before anything is created or chmod-ed. Only 'other' refuses: it throws the same JournalError the file-shaped rule throws, so the existing open().catch(stop) path runs — one warning naming the path, stopped = true, and no mkdir, no chmod, no write through the link. A share of the rule is not carried a second time: no lstat, no isSymbolicLink() call outside the helper.

'real' records exactly as before. 'absent' still creates the folder (mkdir 0700 + chmod 0700): that is the fresh-home case, and refusing it would stop every first-run journal — the mutation table above shows it reds two existing #511 cases. The leaf rule at the journal file (lstat + isFile()) is untouched, and store.js's call site and config.stateJournal are unchanged, so saving the team never depends on the journal.

Four cases in core/test/state-journal.test.js: a link at a real folder outside the home (one warning, stopped true, the target's 0o755 stays 0o755, nothing written into it), a link at a missing path (nothing created), a plain file (no throw escapes, the file is left as it was), and the control — a real folder still records, replay still works, folder 0o700, file 0o600. One sentence of docs/subsystems/core-host.md names the new rule.

Red first

On the base c2de64d6f97d7b1d218118b2b81a2190ff19ad82 source (core/src/host/journal.js reverted to base, the four new tests kept): 12 tests / 11 pass / 1 fail, three runs with an identical result.

  • a journal folder that is a symlink to a real folder outside the home records nothing and stays silent on disk (#2056) — the observable harm: the target folder's mode became 0o700 and the journal file was written through the link.

The other three new cases answer the same way on the base and are guards, not reds: a link at a missing path already failed its mkdir (ENOENT), a plain file already failed it (EEXIST), and a real folder always recorded — the new check must leave all three exactly as they are, which is what M2 below is about.

At this tip the same file is 12 tests / 12 pass / 0 fail.

Mutations: each arm reds its own cases

Each applied to core/src/host/journal.js alone at this tip, the file run, then restored from copy (tree clean afterwards):

mutation result
M1: the helper's answer is not consulted — the base mkdir/chmod prologue returns 11/1 — the symlink case above, the same red as red-first
M2: refuse unless dirKind() === 'real' (i.e. 'absent' stops too) 10/2 — #511: a recorded 3-Bot interaction replays to a byte-identical state.json at every step and #511: the writer prunes on disk and keeps the file private

M2 is why 'absent' stays a create: a fresh home has no journal folder yet, and the two #511 cases that build one depend on this writer making it. The refusal arm is 'other' alone.

Verified on the tree this tip carries

TZ=UTC ARCH_DOCS_BASE=c2de64d6f97d7b1d218118b2b81a2190ff19ad82 npm run verify in the clean tree at 7959103bbbde9c71590e2c866e77d2e126bb5ec7 (tree cef8d3d7c87e98cd77ff0f73abd3cb632885e2a4), exit 0. The pushed tip b88a23c2 carries that same tree; the push helper re-creates the commit on the live base, so its sha differs and its content does not.

  • core 2521/2521 — live base c2de64d6 plus this pull request's four new cases. social 541 (540 pass + 1 skip), tree 127 (126 + 1), timeline 135 (134 + 1), workflow 336/336, .github/scripts 563/563, interop 37/37, live-e2e 24/24. Skip counts are host-dependent and are reported as this run produced them.
  • arch-docs: OK (6 changed file(s)) against base c2de64d6; core lint 73 warnings and 0 errors, none in a line this diff adds; plugin lints 36 / 9 / 2 / 16 warnings, 0 errors; build-client --check exit 0.
  • ok 10 patches apply cleanly in order; ok core/ equals ds-bot/ + patches/ds-bot/; ok patches/ds-bot/ is what generate writes (generate is a no-op) — node scripts/ds-bot-patches.mjs generate re-emitted the two patches/ds-bot/files/ mirrors and the tree was clean afterwards.
  • Not run: the smoke bench (core/dev/bench.sh smoke). The diff is one refusal in the journal writer's open path plus tests — no endpoint, dispatch, turn or delivery path is touched.

Why 'absent' is allowed on purpose

A folder that does not exist yet is not refused; the writer creates it, which is the meaning real-dir.js gives the middle state ("nothing to read" for a reader, "create it" for a writer) and what a fresh home depends on — in the mutation table above, refusing 'absent' reds two existing #511 cases that build the folder by saving. Only 'other' stops: a link at a folder, a link at a path that does not exist, a plain file, or a check that could not run at all. That check happens at the top of open(), before the mkdir, before the chmod and before any write, so when it refuses, the folder a link names is never chmod-ed and no appendFile, temp file or rename reaches it: the target keeps its own mode (0o755 in the case below) and stays empty. Recording stops through the existing stop() path — one warning naming the path, stopped = true — and saving the team keeps working.

Boundaries this change accepts

This change judges one entry: the journal folder itself (dirname(path)). It does not walk the ancestors. When the home itself is reached through a link, the journal folder inside that target is a real directory and is treated as this installation's own — deliberately: the journal code names that folder, and a home reached by a configured path is how the install is set up. The leak this issue closes is a link placed inside the home; whether the home's own path must also be link-free is a separate question and not part of this change.

Accepted boundary: someone who can already write the home can replace the folder name with a link between the check and the chmod. mkdir does not follow a link (EEXIST), but chmod does, so the exposure shrinks from a persistent link to a race. A local attacker able to write the home is outside this issue's threat model; closing that window would mean opening the folder with O_NOFOLLOW and chmod-ing that descriptor, which is a separate change.

Scope

core/src/host/journal.js (open()'s prologue only, one import), core/test/state-journal.test.js (four appended cases), one sentence of docs/subsystems/core-host.md, one changelog fragment, and the two patches/ds-bot/files/ mirrors generate writes. store.js, the leaf rule, replay, retention and the client are untouched.

Checkpoint

{
  "pr": 2065,
  "claim": "Dev-2",
  "issue": 2056,
  "summary": "openJournal() created and chmod-ed the folder above the journal before checking anything, so a symlink there let the host chmod a folder outside the home to 0700 and write the journal into it. The prologue now asks the shared real-dir helper about that folder and refuses anything but a plain directory, stopping with one warning before any mkdir, chmod or write. A real folder records as before and a folder that does not exist yet is still created, which two existing #511 cases depend on. Four tests pin the three refused shapes and the control.",
  "files": [
    "core/src/host/journal.js",
    "core/test/state-journal.test.js",
    "docs/subsystems/core-host.md",
    "changelog.d/2056-journal-folder-symlink.fixed.md",
    "patches/ds-bot/files/src/host/journal.js",
    "patches/ds-bot/files/test/state-journal.test.js"
  ],
  "checks": {
    "ran": "TZ=UTC ARCH_DOCS_BASE=c2de64d6f97d7b1d218118b2b81a2190ff19ad82 npm run verify",
    "result": "green",
    "at": "7959103bbbde9c71590e2c866e77d2e126bb5ec7"
  },
  "next": "none — ready for review"
}

The prologue of openJournal() created and chmod-ed the folder above
<journal>/state.jsonl before looking at anything. When that folder was a
symlink, all of it followed the link: the chmod changed a foreign directory's
mode to 0700 and every later append, temp file and rename landed inside it.
The leaf rule at the journal file was the only check.

Ask ThinkFlowLab#2055's shared real-dir helper about the folder first and refuse anything
but a plain directory, so recording stops with one warning exactly like every
other journal problem, before the mkdir, the chmod or any write. A folder that
does not exist yet is still created, as before, and a real folder records
unchanged; saving the team never depends on the journal.

Fixes ThinkFlowLab#2056
@Birfy

Birfy commented Nov 1, 2026

Copy link
Copy Markdown
Collaborator

Verdict: PASS at b88a23c
Written-by: Reviewer
Attestation: b88a23c role=Reviewer
Tier: T2 · red-first: 1 case red on the base blob

Ran at this tip:

$ cd core && DSH_HARNESS=/opt/flybot TZ=UTC node --import ./test/harness-resolve.mjs --test test/state-journal.test.js
# tests 12  # pass 12  # fail 0
$ cp ../p2065-base-journal.js src/host/journal.js && DSH_HARNESS=/opt/flybot TZ=UTC node --import ./test/harness-resolve.mjs --test test/state-journal.test.js
# tests 12  # pass 11  # fail 1

(the second run is the tip tarball with only core/src/host/journal.js replaced by the base c2de64d6 blob; the tip file was restored afterwards, cmp clean)

Review points

  • Red first holds. On the base blob the one failing case is a journal folder that is a symlink to a real folder outside the home records nothing and stays silent on disk (#2056) — the observable harm (target folder chmod-ed 0700, journal written through the link). The other three new cases are guards and answer the same way on the base, as the body says.
  • The fix is in the right place. core/src/host/journal.js:245-251: dirKind(dirname(path)) === 'other' throws JournalError before mkdir/chmod and before any appendFile/rename, so the existing open().catch(stop) gives one warning + stopped = true. Only 'real' and 'absent' proceed; 'absent' still creates the folder, which the two #511 cases need (reproduced: refusing 'absent' reds them).
  • Rule written once. dirKind is imported from core/src/host/real-dir.js, not re-implemented; no second lstat/isSymbolicLink() in this file. Leaf rule at the journal file unchanged, store.js untouched.
  • Scope matches the compare. 6 files: core/src/host/journal.js, core/test/state-journal.test.js, docs/subsystems/core-host.md (one rule sentence), changelog.d/2056-journal-folder-symlink.fixed.md, and the two patches/ds-bot/files/ mirrors, which carry the same prologue (window diffed equal). Nothing outside the journal open path.
  • Tip checks. 15 check-runs on this tip, all success except the pre-dispatch enqueue-guard failure at 16:59:57 (fired before any verdict existed); tests-ok, merge-gate, judge, per-plugin and node-24 package jobs green.
  • Limitation I accept. The full core suite is not runnable from a bare tarball here (no .git for the gate tests, no node_modules for the client-bundle tests), so my local run is the affected file only; CI tests-ok on this tip covers the whole suite.
  • Metadata: agent-pr label was missing and is now added; the ## Checkpoint block is present and compliant (1192 chars, summary 548).

@Birfy Birfy added the agent-pr Pull request opened by an agent label Nov 1, 2026
@github-actions github-actions Bot added the pass label Nov 1, 2026
@Birfy
Birfy added this pull request to the merge queue Nov 1, 2026
Merged via the queue into ThinkFlowLab:main with commit 4e48aed Nov 1, 2026
16 checks passed
@Birfy
Birfy deleted the dev/2-journal-dir-symlink branch November 1, 2026 17:11
@github-actions github-actions Bot removed the agent-pr Pull request opened by an agent label Nov 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants