Skip to content

A linked leaf file is written through: the memory journal and the workflow save follow a symlink - #2083

Merged
Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev/2-leaf-file-link
Nov 2, 2026
Merged

Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev/2-leaf-file-link

Conversation

@HugoXK

@HugoXK HugoXK commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #2079

Written-by: Dev-2
Owner: Dev-2
Reviewer: Reviewer
Claim-Id: 2b62ad39f22f4f66ab03f4628b4dc395
Branch: dev/2-leaf-file-link
Base: fca35af
Tip SHA: 9ac79a2

What changed

Two host writers asked what their folder was and never what their file was, so a leaf file swapped
for a link was written straight through it into a file outside the home:

  • journal() (core/src/host/memory.js) guarded with writeFolder(root) and then appended to
    memory/journal.jsonl, so a linked journal took every record; four call sites reach it
    (add / replace / remove / archive).
  • save() and setDefault() (core/src/host/workflow-store.js) guarded with folderWritable()
    and then wrote workflows/<id>.json and workflows/project.json through a linked file.

The leaf is now asked the way the folder already was. core/src/host/real-dir.js gains
fileKind / fileKindSync, the file-side twin of dirKind / dirKindSync: lstat does not
follow the last component, so a link is other rather than the file it names, and the three states
keep their meanings — absent is a writer's to create, real is a plain non-link file, other is
refused, fail closed (only ENOENT is absent).

The two writers split the answer by what a failure means:

  • memory journal — a refused leaf drops that one record with one warning per file and process,
    and the memory write itself still lands. The journal is a side channel, so saving memory never
    depends on it.
  • workflow store — save and setDefault answer ok: false with workflow/not-a-file, print
    one warning per file, and leave both the link and the file it names untouched.

A missing file is still created, mode 0600; a plain non-link file is written as before. The folder
rules, #2057 / #2068's read side, and the entry writers behind remember / edit / forget
are unchanged.

Red first — base fca35af6, source untouched, three runs

The base moved while this PR waited: #2082 rewrote the same docs/subsystems/core-host.md line this
PR edits, which made the branch dirty. It was re-cut on fca35af6 and both readings were re-taken
there — the numbers below are this base's, not the ones from 8540c35c. The doc keeps #2082's
wording and appends this issue's sentence to it.

core/test/memory.test.js + core/test/workflow-store.test.js, three times: 108 tests / 105
pass / 3 fail
, identical each run, exit 1 — the three new cases and nothing else.

failing test fact asserted base
#2079: a linked journal file takes no record, and the memory write still lands the file the link names is byte-identical it grew two journal lines (op: add, - Ship on Friday, - Ship on Monday)
#2079: a linked workflow file refuses a save instead of writing through it save(...).ok is false true (true !== false) — the outside file was rewritten
#2079: a linked project file refuses to become the default setDefault(...).ok is false true — project.json was written through the link

The read-class facts are text (readFileSync(target)), the write-class facts are the file system
before and after the call; no case asserts a return value alone. Each case also carries its
controls: the memory side checks MEMORY.md still took both writes, and the workflow side checks
an absent file is still created (with no group or world bits) after the same store refused the
linked one. core/test/real-dir.test.js gains the file-state case (plain / missing / link /
directory); it cannot load on the base at all, because fileKind does not exist there.

Mutations

mutation red
M1 — drop the journal leaf guard 1: #2079: a linked journal file takes no record …
M2 — drop the save leaf guard 1: #2079: a linked workflow file refuses a save …
M3 — drop the setDefault leaf guard 1: #2079: a linked project file refuses to become the default

Each mutation was restored and checked against the fixed file's sha256.

Verified on the tree this tip carries

TZ=UTC ARCH_DOCS_BASE=fca35af6f29a10214f239b78a2087b82845ba4ce npm run verify, exit 0, on the
tree 2cf4ab97b4abb967cf7d7fa73a13e84e03e97747 this tip carries (9ac79a28a3, one commit, one
parent fca35af6).

  • core 2561/2561, plugins/social 541 (540 pass), plugins/tree 127 (126), plugins/timeline
    135 (134), plugins/workflow 336, .github/scripts 575, interop 37, live-e2e 24
  • core lint 73 warnings / 0 errors; plugin lints 36 / 9 / 2 / 16 warnings, 0 errors
  • arch-docs: OK (13 changed file(s)) against base fca35af6
  • ds-bot-patches.mjs check: ok ds-bot/ = Fei2411/DeepSeek-Bot@2b10c70 (tree 5834c00, v0.1.5),
    ok 10 patches apply cleanly in order, ok core/ equals ds-bot/ + patches/ds-bot/; canonical:
    ok patches/ds-bot/ is what generate writes (generate is a no-op) — a second generate after
    the commit leaves git status empty
  • changelog.mjs check: 4 fragments in changelog.d/ are well formed
  • 13 files, +351/−25, one commit, one parent, not a draft

core/src/host/memory.js and its test travel inside patches/ds-bot/0001-core-fixes.patch (there
is no files/ mirror for either, as the issue says); workflow-store.js, real-dir.js and their
tests are upstream-new and therefore mirrored under patches/ds-bot/files/.

Not run

  • The GitHub Test workflow (CI runs verify itself; nothing here ran on another machine).
  • The package job's pack check, typecheck and actionlint, and the smoke test — npm run verify
    does not run them; run the smoke test if this host-side change is judged to need it.
  • Nothing against a live instance.

Scope and limits

  • Reading a single *.md / *.json leaf that is a link is still unchanged, deliberately: this
    issue is the write-through, and #2057 recorded the read side of a leaf as out of scope.
  • The entry writers (remember / edit / forget / save) still follow the folder rule they
    already had; only their journal records pass the new leaf check.
  • A local process that can write inside the home can still win a redirect race between the lstat
    and the write. The check narrows the named defect; it does not make the home a trust boundary.
  • Not touched: the folder resolvers, the read side, writeText's temp + rename, the workflow
    folder rule, and any client code.

Checkpoint

{
  "pr": 2083,
  "claim": "Dev-2",
  "issue": 2079,
  "summary": "The memory journal appended to memory/journal.jsonl and the workflow store wrote workflows/<id>.json and workflows/project.json after asking only whether the folder was a plain directory, so a leaf file swapped for a link was written through into the file it named. real-dir.js now asks the leaf too: absent, plain non-link file, or other. The journal drops that record with one warning and the memory write still lands; save and setDefault refuse with workflow/not-a-file and touch neither the link nor its target.",
  "files": [
    "core/src/host/memory.js",
    "core/src/host/real-dir.js",
    "core/src/host/workflow-store.js",
    "core/test/memory.test.js",
    "core/test/real-dir.test.js",
    "core/test/workflow-store.test.js",
    "docs/subsystems/core-host.md",
    "changelog.d/2079-leaf-file-link.fixed.md",
    "patches/ds-bot/0001-core-fixes.patch",
    "patches/ds-bot/files/src/host/real-dir.js",
    "patches/ds-bot/files/src/host/workflow-store.js",
    "patches/ds-bot/files/test/real-dir.test.js"
  ],
  "checks": {
    "ran": "TZ=UTC ARCH_DOCS_BASE=fca35af6 npm run verify",
    "result": "green",
    "at": "9ac79a28a38530a6e65eba96fb4531fd4e200595"
  },
  "next": "none — ready for the reviewer, and for the Security advisory at this tip"
}

@HugoXK

HugoXK commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Self-test: PASS at 7f169e5
Written-by: FlyBot Security
as of my read: 0 comments, newest none

Advisory only — not a review, no Verdict:; the repository reviewer's verdict is the authority. This is the write-escape self-test for #2079 (a leaf file swapped for a link), not a style pass.

Diff: 13 files +351/−25, single parent 8540c35c95e1, tree fb6d7d96… as the body states. Nothing under .github/, no authz change (workflow-save keeps its TASK_ENDPOINTS entry), no client code, no secret-like strings in the changed files. fileKind/fileKindSync is the dirKind twin: lstat, three states, only ENOENT is absent, any other error is other.

Ran — my own probes, 10/10 at this tip:

  • M1 journal leaf is a directory: refused, the directory stays empty, MEMORY.md takes the entry, one warning.
  • M2 leaf is a link: target byte-identical, one warning across two records; unlink it and the next record appends normally — the refusal is per record, not a permanent stop — and the created journal is 0600. M6 a dangling link: refused, the named file is never created, the memory entry still lands.
  • M3 plain leaf: appended as before. M5 no memory/ yet: journal created 0600 with the record, no warning.
  • M4 journal made unreadable-for-append (0400) as a control on ordering: the memory entry had already landed, the journal took nothing. A refused leaf never fails the save (the repo case returns Saved to …); a genuine IO error on the journal still surfaces to the caller even though the entry landed — pre-existing, unchanged by this PR.
  • W1 save with a directory at workflows/mine.json: ok:false, workflow/not-a-file, directory empty, one warning. W2 a link: target and link untouched, one warning, another id still saves, and after removing the link the file is created 0600.
  • W3/W4 setDefault with project.json as a directory, a link or a dangling link: refused, nothing written through; once it is plain, the file is created 0600 and the default is written.
  • Repo tests at this tip: memory.test.js 90/90, workflow-store.test.js 18/18, real-dir.test.js 5/5. Base 8540c35c with the same test files: 108 tests / 105 pass / 3 fail — exactly 86, 107, 108 — and real-dir.test.js cannot load there (fileKind does not exist), as the body says. So the three cases are red first, and the three mutation arms are the same three guards.
  • Gates: build-client --check exit 0; ds-bot-patches check 10 patches apply in order and core/ equals ds-bot/ + patches; canonical generate is a no-op; changelog check ok; cmp identical for real-dir.js, workflow-store.js and both mirrored tests; memory.js and its test travel in 0001-core-fixes.patch and have no files/ mirror. At this sha tests-ok and the other checks are green; enqueue-guard is red only for the missing Verdict naming this tip.

The docs and changelog sentences match what I drove, including "a leaf that is a link is still read" (the read paths are untouched) and the named race.

Residual, as the body and docs already say: both guards ask and then write, so a process that can write inside the home can still win the lstat → write race; a hardlinked leaf also reads as a plain file. Not a regression and not fixable inside this issue.

Two notes outside this PR, not a FAIL: the workflow warnedFiles set lives in the store instance, so "once per file and process" is exact while a workspace stays the same and can repeat after switching away and back; and the same leaf class exists in core/src/host/decisions.js:71 (home/decisions.jsonl) and core/src/host/diagnostics.js:145 (home/logs/diagnostics.jsonl), which append without asking the leaf — #2079 names only the memory journal and the workflow store.

Not verified: full npm run verify locally (tests-ok covers this tip) and no live instance.

@HugoXK

HugoXK commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

@Birfy review request — tip 7f169e51c0b9365b22c896c2bf84e3e1ff69ae97

T2 security-surface PR (#2079): a leaf file swapped for a symlink is written through by the memory journal, workflowStore.save and setDefault.

At this tip: tests-ok and Node 24 · DSH 0.2.0-rc.2 green, 13 of 15 runs success; enqueue-guard is red only because this tip has no Verdict yet. Security self-test PASS at the same tip: comment 6566090412. Body carries the six fields (Fixes #2079, Claim-Id, Base: 8540c35c95e1…, Tip SHA: = this tip, checkpoint "at" = this sha).

Please give the formal Verdict at this exact tip.

@github-actions github-actions Bot added the risk:T2 Risk tier T2 (host/plugin code or an unnamed path), set by the controller from the diff label Nov 2, 2026
@Birfy

Birfy commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator

Verdict: PASS at 7f169e5
Written-by: Reviewer
Attestation: 7f169e5 role=Reviewer

Ran at this tip:

$ cd core && DSH_HARNESS=/opt/flybot node --import ./test/harness-resolve.mjs --test test/memory.test.js test/real-dir.test.js test/workflow-store.test.js
# tests 113  # pass 113  # fail 0
$ cp tip tree, restore base 8540c35c blobs for core/src/host/{memory,real-dir,workflow-store}.js, same three suites
# tests 109  # pass 105  # fail 4
$ node scripts/ds-bot-patches.mjs check
# ok 10 patches apply cleanly in order; ok core/ equals ds-bot/ + patches/ds-bot/

Red first (8540c35c blobs, tip tests): 4 fail, and exactly the new cases —
#2079: a linked journal file takes no record, and the memory write still lands,
#2079 in test/real-dir.test.js (no fileKind on base, so the file cannot load),
#2079: a linked workflow file refuses a save instead of writing through it,
#2079: a linked project file refuses to become the default. Nothing else moves.

Scope: compare 8540c35c...7f169e51 = one commit, 13 files +351/−25, no merge commit; the 13 files are the ones the body lists. Check-runs at this tip: 14 success, one agent-audience cancelled (superseded by its green re-run), enqueue-guard red only for the missing Verdict this comment supplies.

What I checked in the change:

  • fileKind / fileKindSync mirror dirKind: lstat, absent only on ENOENT, any other error other — fail closed; a directory or link is never a file here.
  • memory.journal() asks journalPath after writeFolder, drops that one record with a once-per-file warning, and the entry write still lands — the journal stays a side channel (core/src/host/memory.js:540).
  • save() and setDefault() ask the leaf too, answer workflow/not-a-file and write nothing, leaving the link and its target alone; an absent file is still created, now 0o600 (core/src/host/workflow-store.js:174,:203). No behaviour change on the folder rules or the read side.
  • patches/ds-bot/ carries the same bytes as core/; docs/subsystems/core-host.md and the changelog fragment cover the change.

@Birfy Birfy added the agent-pr Pull request opened by an agent label Nov 2, 2026
@github-actions github-actions Bot added the pass label Nov 2, 2026
@Birfy
Birfy enabled auto-merge November 2, 2026 00:48
@Birfy
Birfy disabled auto-merge November 2, 2026 00:49
…hinkFlowLab#2079)

The memory journal appended to memory/journal.jsonl and the workflow store wrote workflows/<id>.json and workflows/project.json after asking only whether the folder was a plain directory, so a leaf file swapped for a link was written through into the file it named.

fileKind/fileKindSync in src/host/real-dir.js ask the leaf the way dirKind asks a folder: absent, a plain non-link file, or other. The journal drops that one record with one warning per file and the memory write still lands; save and setDefault refuse with workflow/not-a-file and leave the link and its target untouched. A missing file is still created (0600).
@HugoXK
HugoXK force-pushed the dev/2-leaf-file-link branch from 7f169e5 to 9ac79a2 Compare November 2, 2026 00:55
@HugoXK

HugoXK commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Self-test: PASS at 9ac79a2
Written-by: FlyBot Security
as of my read: 3 comments, newest 6566413485

Advisory only — not a review, no verdict line; the repository reviewer's conclusion is the authority. This is the write-escape self-test for #2079 at the re-cut tip, not a style pass. Advisory 6566090412 and the reviewer's conclusion 6566413485 both name 7f169e51c0b9, so neither describes this tip.

At this tip: 13 files +351/−25, one commit, single parent fca35af6f29a1 (= live main), tree 2cf4ab97b4abb, head HugoXK/FlyBot dev/2-leaf-file-link. No file under .github/, .claude/ or core/src/client/: no gate, workflow or authz surface moves with this change. The red enqueue-guard on this head says only that no conclusion names 9ac79a28a385 — expected at a moved tip, and the review side's step, not mine.

What I judged

  1. One leaf reader, fileKindOf in core/src/host/real-dir.js: lstat does not follow the last component; absent only on ENOENT; a plain non-link file is real; a link, a directory, a socket or a check that could not run is other. Fail closed.
  2. memory.js:540 asks the journal leaf before mkdir/appendFile; a refused leaf drops that one record with one warning per file and process, and the memory entry write (its own temp + rename) still lands — saving memory never depends on the journal.
  3. workflow-store.js:171-186 (save) and :200-206 (setDefault): folder check first, then the file check; refusal is { ok: false, code: 'workflow/not-a-file' }; neither the link nor the file it names is touched; the warning is deduped per file inside the store.
  4. Permission surface: workflow-save still goes through authz.js's TASK_ENDPOINTS unchanged, and the refusal is the store's ordinary { ok: false, code } answer — no new path for an unauthorised caller, nothing widened.
  5. No secret, token, key or new env/network read is added; the diff adds only paths under core/, docs/, changelog.d/ and the generated patches/ds-bot/.
  6. Generated files: every patches/ds-bot/files/ mirror is byte-equal to its core/ source; node scripts/ds-bot-patches.mjs check → ok 10 patches apply cleanly in order, ok core/ equals ds-bot/ + patches/ds-bot/ (pin skipped: a tarball is not a git checkout).
  7. docs/subsystems/core-host.md keeps the base (docs(host): drop a spliced half-sentence and a stale write note #2082) wording as a prefix in both changed lines and appends the A linked leaf file is written through: the memory journal and the workflow save follow a symlink #2079 sentences; the leaf read side and the redirect race stay named as out of scope.

Ran at this tip (tarball of 9ac79a28…, Node 22.23.2, TZ=UTC)

$ cd core && node --import ./test/harness-resolve.mjs --test test/memory.test.js test/real-dir.test.js test/workflow-store.test.js
# tests 113  # pass 113  # fail 0
$ cd core && node --import ./test/harness-resolve.mjs --test test/*.test.js
# tests 2561  # pass 2560  # fail 1
  the one failure is check-arch-docs «empty diff passes»: "cannot diff against HEAD" (no .git in a tarball);
  the same case fails identically on the base tree, so it is this box, not the change
$ node scripts/ds-bot-patches.mjs check     → ok 10 patches apply cleanly in order; ok core/ equals ds-bot/ + patches/ds-bot/
$ node .github/scripts/changelog.mjs check  → changelog: 4 fragment(s) in changelog.d/ are well formed
Not run: plugins/*, .github/scripts, the package job's pack/typecheck/actionlint, the smoke test (nothing there is touched).

Red first, re-taken on this base (the tip's test files on fca35af6 source, three runs): # tests 108 # pass 105 # fail 3 identical each run, exit 1 — the linked journal takes a record, save(...).ok is true, setDefault(...).ok is true, and only those three cases fail.

Independent probes, 8 cases not among the shipped tests, all green at the tip: a journal link to a directory (nothing created behind it, memory lands, one warning); a FIFO where the journal belongs (refused, not opened — no hang, memory lands); a hardlink leaf (see residuals); a plain workflow file rewritten / a missing one created 0600 / an existing mode untouched / unchanged bytes not rewritten; a linked workflow still listable (read side unchanged, as declared); setDefault over a link leaves link and target untouched with one warning across three calls; fileKind edges (missing parent → absent, unix socket → other); a plain journal appended with no warning.

Residuals, kept as record rather than defects of this PR

  • A hard link at the leaf is a plain file to lstat, so the write still lands on the shared inode. Inherent to the check; the PR already states the home is not made a trust boundary.
  • The temp leaves are not asked: memory.js:806 writes ${snapshot}.tmp (deterministic name) and writeText writes ${path}.${pid}.tmp. A link planted at that name would be written through and then renamed over. Pre-existing and a different writer class from A linked leaf file is written through: the memory journal and the workflow save follow a symlink #2079's three; it belongs with the leaf backlog (#2089, the decisions.js/diagnostics.js line).
  • The lstat → write race and the leaf read side (a linked *.md/*.json is still read) stay out of scope, as the PR and the doc say.
  • Fail-closed means a legitimately symlinked journal now drops records with one warning while the memory write lands; that is the intended trade, and the warning names the file.

No verdict, no label, no assignee from me; the old advisory is left exactly as written.

@HugoXK

HugoXK commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

@Birfy review request — tip 9ac79a28a38530a6e65eba96fb4531fd4e200595

T2 security-surface PR (#2079): a leaf file swapped for a symlink is written through by the memory journal, workflowStore.save and setDefault. The branch was re-cut on live main fca35af6f29a10214f239b78a2087b82845ba4ce, so every reading below belongs to this head: 13 files +351/−25, one commit, single parent fca35af6.

At this tip: tests-ok, Node 24 · DSH 0.2.0-rc.2, merge-gate, package, judge, reviewed tip, risk tier, agent-audience, plugins (social/timeline/tree/workflow) = success, 13 of 14 runs. enqueue-guard is red because no conclusion names this tip yet, which is what this request is for. Security self-test PASS at this same tip: comment 6566627280.

Red first was re-taken on this base, not carried over: the two write-class test files against fca35af6 source give 108 tests / 105 pass / 3 fail on three runs, the same three cases (a linked journal file takes no record, and the memory write still lands; a linked workflow file refuses a save instead of writing through it; a linked project file refuses to become the default), and 108/108/0 after the fix. npm run verify exit 0 with core 2561/2561 and .github/scripts 575.

The body carries the six fields: Fixes #2079, Written-by: Dev-2, Claim-Id 2b62ad39f22f4f66ab03f4628b4dc395, Branch: dev/2-leaf-file-link, Base: = fca35af6f29a10214f239b78a2087b82845ba4ce, and Tip SHA: = this sha, with the checkpoint "at" field also set to it.

Please give the formal Verdict at this exact tip.

@Birfy

Birfy commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator

Verdict: PASS at 9ac79a2
Written-by: Reviewer
Attestation: 9ac79a2 role=Reviewer

Ran at this tip:

$ cd core && DSH_HARNESS=/opt/flybot node --import ./test/harness-resolve.mjs --test test/memory.test.js test/real-dir.test.js test/workflow-store.test.js
# tests 113  # pass 113  # fail 0
$ cp tip tree, restore base fca35af6 blobs for core/src/host/{memory,real-dir,workflow-store}.js, same three suites
# tests 109  # pass 105  # fail 4
$ node scripts/ds-bot-patches.mjs check
# ok 10 patches apply cleanly in order; ok core/ equals ds-bot/ + patches/ds-bot/
  • Red first: with the three source files back at base fca35af6, the tip tests fail 4 — the three A linked leaf file is written through: the memory journal and the workflow save follow a symlink #2079 cases (linked journal takes no record, linked workflow file refuses save, linked project file refuses setDefault) plus test/real-dir.test.js, which cannot load because fileKind does not exist on base. Green at the tip: 113/113.
  • Range: 13 files +351/−25, single commit, single parent fca35af6 (= live main); source, tests, docs, changelog fragment and the ds-bot mirrors only — no .github/, no client, no authz surface.
  • Tip check-runs: 14, 13 success; the only failure is enqueue-guard, which is red solely because no verdict named this tip yet.
  • patches/ds-bot check: 10 patches apply in order and core/ equals ds-bot/ + patches. Checkpoint block present and within limits. T2.

@Birfy
Birfy added this pull request to the merge queue Nov 2, 2026
Merged via the queue into ThinkFlowLab:main with commit 4551bb3 Nov 2, 2026
15 checks passed
@Birfy
Birfy deleted the dev/2-leaf-file-link branch November 2, 2026 01:21
@github-actions github-actions Bot removed the agent-pr Pull request opened by an agent label Nov 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pass risk:T2 Risk tier T2 (host/plugin code or an unnamed path), set by the controller from the diff

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A linked leaf file is written through: the memory journal and the workflow save follow a symlink

2 participants