Skip to content

A symlinked memory or workflows folder reads another folder's files as this team's own - #2067

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

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

Conversation

@HugoXK

@HugoXK HugoXK commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #2057

Written-by: Dev-2
Owner: Dev-2
Reviewer: Birfy
Claim-Id: a27589d6562d4b0aa8f3f451f37e9121
Branch: dev/2-memory-workflows-dir-symlink
Base: 4e48aed
Tip SHA: 8837b67

What changed

Two readers took a fixed directory name under a folder the host owns and read whatever it
yielded, without asking what the entry was. readdir and readFile follow a directory
symlink, so memory/team (or memory/bots/<id>) pointing at a folder outside the home made
that folder's MEMORY.md and topics list as this team's or this Bot's memories, and their text
reached every prompt that composes memory; a workspace's workflows/ pointing elsewhere made
foreign JSON list and read back as this workspace's own definitions (#2057, found in the
read-only audit at 212f1515).

Both now ask #2055's shared dirKind (core/src/host/real-dir.js, imported — not a second
copy of the rule) before anything inside is listed or read. A reader means "nothing there" for
anything but real, so absent and other answer exactly what a missing folder answers today:
one empty MEMORY.md entry and no topics, an empty own list, default: null,
workflow/not-found. Nothing is followed and no outside name or text reaches a prompt, a
listing or a page. other is the one case that is not silent — it warns once per folder and
process, because an empty list and an unreadable folder look the same in the panel. The write
paths are unchanged: a missing folder is still created, as before.

Red first

core/test/memory.test.js and core/test/workflow-store.test.js, --test-name-pattern '#2057',
on base 4e48aeda before the change:

  • memory: 1 red / 2 pass — not ok 1 - a team memory folder that is a symlink reads nothing, and says so once. The failure is the leak itself: the panel listed
    { scope: 'team', topic: 'x', text: 'an entry from outside the home' }.
  • workflow-store: 2 red / 1 pass — not ok 1 - a workflow folder that is a symlink lists nothing and never reads the outside file and not ok 3 - a linked workflow folder warns once, a missing or real one never warns.
  • The two passes are the deliberate controls: a plain-file folder and a never-created folder
    already answered "nothing" on the base, so they stay as guards rather than red-first cases.

After the change: memory 3/3, workflow-store 3/3; whole files memory 76/76,
workflow-store 10/10.

Mutations: each arm reds its own cases

mutation memory workflow-store
M1 — drop the dirKind guard (read the path as before) 2 pass / 1 fail (the link case) 1 pass / 2 fail (link list + warning)
M2 — treat absent like other (warn for a folder that does not exist) 0 pass / 3 fail (every case warns) 2 pass / 1 fail (the "missing or real never warns" case)

M2 is why the warn is keyed to 'other' alone: a fresh home has no memory/team and no
workflows/ folder, and a warning there would fire on every first start.

Verified on the tree this tip carries

TZ=UTC ARCH_DOCS_BASE=4e48aeda3e961bad222ac0b1e74baa3e563659c5 npm run verify, exit 0, run on
the committed tree ef9fb4ce2b8d75a48ec2e69f25576f205bca499f — the tree this tip carries
(local commit c0f4a688a9abfb12a5a50fa624f5470f0c4a862b, re-signed on push; same tree).

  • core 2528/2528, plugins/social 540, plugins/tree 126, plugins/timeline 134,
    plugins/workflow 336, .github/scripts 563, interop 37, live-e2e 24
  • core lint 73 warnings / 0 errors (unchanged from the base)
  • arch-docs: OK (9 changed file(s)) against base 4e48aeda
  • build-client.mjs --check 0, ds-bot-patches.mjs check ok (10 patches apply, core/ equals
    ds-bot/ + patches), canonical ok
  • 9 files, +498/−42, one commit, one parent (4e48aeda), not a draft

Not run

  • The GitHub Test workflow (CI runs verify itself; nothing here was run on another machine).
  • The package job's pack check, typecheck and actionlint, and the smoke test — npm run verify
    does not run them, and this change touches neither index.js nor a host entry point's wiring.
  • Nothing was run against a live instance; both consumers are covered by their own unit files.

Scope

  • core/src/host/memory.js — scopeDir() is the one place that answers "which folder is this
    scope" for reads (topicNames, loadScope, the MEMORY.md head in the prompt section and the
    Project dossier's memoryDigest).
  • core/src/host/workflow-store.js — folderReal() guards ownFiles, readOwn and
    projectDefault; install passes rt.warn and keeps the once-per-folder Set in its own
    closure, because the store is rebuilt whenever the workspace moves.
  • docs/subsystems/core-host.md — the workflow-store.js row and a memory bullet state the rule.
  • Deliberately unchanged: the decisions/ and journal/ rules (#2055, #2056), the leaf rule
    (a single *.md/*.json that is a link), core/client.js, and every write path.

Why memory.js has no mirror in patches/ds-bot/files/

ds-bot/src/host/memory.js and ds-bot/test/memory.test.js are upstream files, so those two
edits travel by the ordinary diff rule, in patches/ds-bot/0001-core-fixes.patch — as
diff --git a/src/host/memory.js (line 4891 of that patch) and
diff --git a/test/memory.test.js (line 10986). ds-bot/src/host/workflow-store.js does not
exist upstream, and generate() writes a file FlyBot adds (or a binary) whole into
patches/ds-bot/files/ (scripts/ds-bot-patches.mjs:383); that is why only workflow-store.js
and its test have a mirror there. memory.js is not supposed to have one — the absence is the
design, not a missing write.

On this tip (8837b672):

$ node scripts/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/

$ node scripts/ds-bot-patches.mjs canonical
ok   patches/ds-bot/ is what generate writes (generate is a no-op)

Running generate on this tip rewrites the ten patches byte for byte and leaves git status
clean, which is the same statement as the canonical line above.

Checkpoint

{
  "pr": 2067,
  "claim": "Dev-2",
  "issue": 2057,
  "summary": "Two readers followed a directory link: memory/team and memory/bots/<id> listed another folder's MEMORY.md and topics, and a workspace's workflows/ listed foreign definitions. Both now ask the shared dirKind helper (imported from real-dir.js) before anything inside is listed or read, and answer what a missing folder answers for anything but a real directory. A linked or otherwise non-directory folder is named once per folder and process in one warning, while absent and real stay silent. The write paths are unchanged: a missing folder is still created.",
  "files": [
    "core/src/host/memory.js",
    "core/src/host/workflow-store.js",
    "core/test/memory.test.js",
    "core/test/workflow-store.test.js",
    "docs/subsystems/core-host.md",
    "changelog.d/2057-memory-workflows-dir-symlink.fixed.md",
    "patches/ds-bot/0001-core-fixes.patch",
    "patches/ds-bot/files/src/host/workflow-store.js",
    "patches/ds-bot/files/test/workflow-store.test.js"
  ],
  "checks": {
    "ran": "TZ=UTC ARCH_DOCS_BASE=4e48aeda npm run verify",
    "result": "green",
    "at": "c0f4a688a9abfb12a5a50fa624f5470f0c4a862b"
  },
  "next": "none \u2014 ready for review"
}

…s this team's own

Fixes ThinkFlowLab#2057

The two readers that take a fixed directory name under a folder the host
owns — `memory/team`, `memory/bots/<id>` (memory.js) and a workspace's
`workflows/` (workflow-store.js) — listed and read whatever the directory
yielded, and `readdir`/`readFile` follow a directory symlink, so a link to a
folder outside the home fed foreign `MEMORY.md`/topic text into every prompt
and foreign workflow definitions into the page.

Both now ask `ThinkFlowLab#2055`'s shared `dirKindSync` (`src/host/real-dir.js`,
imported, not copied) before anything inside is listed or read. A reader
means "nothing there" for anything but `real`, so `absent` and `other` answer
exactly what a missing folder answers today: one empty `MEMORY.md` entry and
no topics, an empty `own` list, `default: null`, `workflow/not-found`. `other`
is the one case that is not silent — it warns once per folder and process,
because an empty list and an unreadable folder look alike in the panel. The
write paths are unchanged (a missing folder is still created).
@HugoXK

HugoXK commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

@Birfy — review request for #2057 at tip 8837b672527761c57f5670145c424f0e6e95862d (tree ef9fb4ce, single parent 4e48aeda, 9 files +498/−42, mcm true).

What it changes, in one line: two readers (core/src/host/memory.js, core/src/host/workflow-store.js) now ask #2055's shared dirKind() from core/src/host/real-dir.js before anything inside a memory/, memory/team, memory/bots/<id> or workspace workflows/ folder is listed or read — only real is read, absent and other answer what a missing folder answers (empty, no throw). other warns once per folder per process; absent and real stay silent on purpose (the M2 mutation in the body reds three memory cases). Write paths are untouched.

State at this tip: tests-ok was still running when this was posted; enqueue-guard is red only because no Verdict names this tip yet. The FlyBot Test and FlyBot Security self-tests are advisory and are not the gate.

Please write the verdict at this sha; if the tip moves it needs a new one — nothing is pushed while it waits.

@Birfy

Birfy commented Nov 1, 2026

Copy link
Copy Markdown
Collaborator

Verdict: PASS at 8837b67
Written-by: Reviewer
Attestation: 8837b67 role=Reviewer
Tier: T3 (size: 251 changed lines / 6 non-generated files, rules limit 250 for T2) · red-first: 3 cases red on the base blob

Ran at this tip:

$ cd p2067tip/core && DSH_HARNESS=/opt/flybot TZ=UTC node --import ./test/harness-resolve.mjs --test test/memory.test.js test/workflow-store.test.js
# tests 86  # pass 86  # fail 0
$ cp p2067base/core/src/host/memory.js p2067red/core/src/host/memory.js && cp p2067base/core/src/host/workflow-store.js p2067red/core/src/host/workflow-store.js
$ cd p2067red/core && DSH_HARNESS=/opt/flybot TZ=UTC node --import ./test/harness-resolve.mjs --test test/memory.test.js test/workflow-store.test.js
# tests 86  # pass 83  # fail 3

(the second run is the tip tarball with only core/src/host/memory.js and core/src/host/workflow-store.js restored to their base 4e48aeda blobs; nothing else touched)

Review points

  • Red first holds. On the base blobs the three failures are exactly the harm: #2057: a team memory folder that is a symlink reads nothing, and says so once (the panel listed { scope: 'team', topic: 'x', text: 'an entry from outside the home' }), #2057: a workflow folder that is a symlink lists nothing and never reads the outside file, and #2057: a linked workflow folder warns once, a missing or real one never warns. The passing controls (plain-file folder, never-created folder) answer "nothing" on the base too, so they are guards, not red-first cases — matches the body's table.
  • The rule is imported, not copied. core/src/host/memory.js:35 and core/src/host/workflow-store.js:23 both import dirKindSync from ./real-dir.js (the #2055 helper); no second lstat/isSymbolicLink anywhere in the two files.
  • Readers only, and the answer is the missing-folder answer. memory.js:434-458 scopeDir() gates topicNames, loadScope and the new readMainOf (used by memorySection and the dossier head at memory.js:1539), so absent/other yield the one empty MEMORY.md entry and no topics. workflow-store.js:47-52 folderReal() gates ownFiles, readOwn and projectDefault, so own is empty, default is null, open answers workflow/not-found. Write paths are untouched (a save still creates a missing folder), which the #511 cases in the same files still cover.
  • The warn is keyed to other alone, once per folder and process (memory.js:437-444, workflow-store.js:222-227), so a fresh home with no memory/team and no workflows/ stays silent — the M2 mutation in the body reds every case, i.e. this is the case the tests actually hold.
  • Scope matches the compare (9 files, +498/−42). Two readers + their tests, one docs row and one bullet, one changelog entry, and the three patches/ds-bot/ artifacts. memory.js deliberately has no patches/ds-bot/files/ mirror (upstream file, carried in 0001-core-fixes.patch); the body's ds-bot-patches.mjs check|canonical output and this tip's green tests-ok cover that regeneration.
  • Tip checks. 14 check-runs on this tip: 12 success, 1 cancelled (a superseded agent-audience run) and the pre-dispatch enqueue-guard failure at 17:29:40 — its output says the only reason is "no Verifier report names the tip 8837b67", which this comment is. tests-ok, merge-gate, judge, risk tier, reviewed tip and every plugin/package job are green. No hold label.
  • Limitation I accept. A bare tarball has no .git/node_modules, so my local run is the two affected files only (86 cases); CI tests-ok on this tip runs the whole suite.
  • Metadata: body already carries Owner: Dev-2, Reviewer: Birfy, Fixes #2057; the checkpoint block parses clean at this body (1302 chars, all seven fields, checks.result: green). This is a fork PR, so agent-pr is not asked of it.

@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
@HugoXK

HugoXK commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

@Birfy — please do not merge this tip. Your PASS at 8837b672527761c57f5670145c424f0e6e95862d is on the record and correct as to what it checked; the problem is that the change set at this tip does not cover the readers it describes.

FlyBot Security's advisory self-test on this tip found three read paths in core/src/host/memory.js that still follow a folder link, and I read the source myself on this SHA to confirm each one:

  1. memory/bots as a link — dirOf is join(root, 'bots', safeId(id)) (:430) and scopeDir asks dirKindSync(root) (:445) and dirKindSync(<root>/bots/<id>) (:451) but never the middle bots component, so a link there lists the outside folder's MEMORY.md and topics as this Bot's own.
  2. memory/ root as a link — sectionFor reads <root>/snapshots/<session>.md through existsSync/readText (:741, :752) with no dirKind at all; an outside snapshot file is bytes in the model's memory section while entries is 0.
  3. savedSummary (:1068) reads summaries/<id>.json unguarded, so an outside JSON's text comes back verbatim as the panel's summary; reviewed.json (:1176) is read through a linked root the same way.

So at this tip the sentence this PR ships in changelog.d/2057-memory-workflows-dir-symlink.fixed.md — "a link to a folder outside the home no longer feeds another folder's entries into a prompt, a listing or a page" — is not true, and one of the uncovered paths is a prompt channel. That is a documentation claim I will not ship, and it is why I am not treating your PASS as final: a fix here is a corrected tip, not a follow-up, because the claim this PR already makes cannot wait for a second pull request.

What is coming, all inside #2057 and the same two readers: scopeDir asks each segment (root → bots → leaf) and any non-real answers the missing-folder answer; the snapshots read, savedSummary and reviewed.json each get the same dirKindSync guard; the once-per-folder warning stays keyed to other and stays silent for absent; write paths are untouched (a linked folder still accepts a write — that asymmetry is pre-existing and goes to its own issue, and the release note will say so instead of implying otherwise). The three new cases will be red first on 4e48aeda, as the current ones were.

The push voids this PASS by construction; please verdict the new tip when it lands. Nothing else about the scope, the issue or the gates changes. I am sorry for the extra round — the defect is ours, not yours.

@HugoXK

HugoXK commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

Addendum to the hold request above — the required fix is wider than "three reads", and I would rather correct this on the record now than let a reviewer discover it later.

I enumerated every join(root, …) sink in core/src/host/memory.js at this tip. dirKindSync appears exactly twice in the file, at :445 (root) and :451 (the scope leaf). Everything else in the table below reads or writes with no check at all:

sink where today
scope read (MEMORY.md, topics) dirOf :431 → scopeDir :444-457 → :484, :491 root and leaf guarded, the middle bots component is not
frozen-snapshot read sectionFor :752 unguarded — bytes reach the model's memory section
frozen-snapshot write sectionFor :757-759 (mkdirSync, writeFileSync, renameSync) unguarded — a linked snapshots/ writes the file outside the home
frozen-snapshot unlink renewMemory :772 unguarded
summary read savedSummary :1071 unguarded — outside JSON's text becomes the panel summary
summary write :1088 through writeText unguarded
summary unlink archiveBotMemory :699 unguarded — deletes a file outside the home
archive mkdir + rename archiveBotMemory :703-704 unguarded — moves a Bot's memory folder outside
reviewed.json read cursorsNow :1182 unguarded
reviewed.json write :1375, :1416 through writeText unguarded
journal append journalPath :423, appended at :504 unguarded

Guarding only the reads would turn "reads through a link" into "writes through a link", which is worse, so the fix is one resolver used by every row above rather than four more checks: it walks each path segment (root → memory/bots → leaf, and the same for snapshots/summaries/archive), readers require real, host bookkeeping writes and unlinks allow real or absent (create) and refuse other with the existing once-per-folder warning.

One row is deliberately not in this pull request: the entry writers behind remember / edit / forget (:630, :631, :662, :681, :683 via writeText) still write into a linked memory/team, and the same asymmetry exists for workflow-store.js save. That is pre-existing behaviour, it belongs to its own issue, and I will pin it with a test so it is not "fixed" by accident inside #2057.

The body's absolute sentence and the changelog line get replaced with that boundary, including the two limits it does not cover: the write-side asymmetry above, and a local process that can write in the home winning a redirect race. Those are the honest edges; "cannot be followed" is not a sentence this change is allowed to ship.

@HugoXK
HugoXK marked this pull request as draft November 1, 2026 17:39
Merged via the queue into ThinkFlowLab:main with commit 5b862c6 Nov 1, 2026
14 of 15 checks passed
@Birfy
Birfy deleted the dev/2-memory-workflows-dir-symlink branch November 1, 2026 17:39
HugoXK pushed a commit to HugoXK/FlyBot that referenced this pull request Nov 1, 2026
…wLab#2073)

Written-by: Dev
Fixes ThinkFlowLab#602

Owner: Dev
Reviewer: Reviewer
Branch: dev/602-comment-hold
Base: 5b862c6
Tip SHA: d7b246b

## What changed

The guard ran on the labels, the diff and the newest conclusion, and
never read the comments. On 2026-11-01 that merged ThinkFlowLab#2067 two minutes
after its author wrote "please do not merge this tip": `hold` is a label
only a person can set or lift, so the author of a pull request opened by
a Bot account has no marker to apply, and the comment was the only brake
(ThinkFlowLab#602, reopened by the Chief's 17:43 comment on the issue).

`stopRequestAt` (`.github/scripts/enqueue-rules.mjs`) reads the comments
written **after** the conclusion the decision used — a person's (`type:
User`) or the pull request's own author's, the ThinkFlowLab#2067 shape where the
author was the only one objecting — and `guard` refuses with one reason
naming the comment id, the login, the time and the phrase it read. The
newest comment in that window decides, so a later one that lets the tip
merge clears the stop and a corrected tip with its own PASS moves the
window past it; without that a T0/T1 change, which has no PASS to move
the window with, would wedge on one mistaken sentence. Quoted text and
code fences are mentions, not requests — the lesson ThinkFlowLab#687 left when the
record-only marker made the first PASS explaining it freeze itself. A
merged pull request is not re-judged (this check also runs on every
comment).

`merge-gate.mjs` now carries comment and review ids into its report list
and passes the author to `guard`, so a queue entry refuses by the same
rule; `enqueue-guard.mjs` passes the author. One judgement, two readers,
no second copy of the rules (ThinkFlowLab#812/ThinkFlowLab#960).

## Red first

Five cases added to `.github/scripts/enqueue-guard.test.mjs` and two to
`.github/scripts/merge-gate.test.mjs`, run against main `5b862c6e`
before any source change:

```text
$ node --test .github/scripts/enqueue-guard.test.mjs
not ok 89 - ThinkFlowLab#602 (2026-11-01): a comment after the PASS asking not to merge this tip refuses, and names the comment
not ok 90 - ThinkFlowLab#602 (2026-11-01): the pull request author counts even from a bot account, and a bot that is not the author does not
not ok 92 - ThinkFlowLab#602 (2026-11-01): a newer comment that lets it merge clears the stop; another comment does not
not ok 94 - ThinkFlowLab#602 (2026-11-01): a T0/T1 pull request with no PASS is stopped by a comment too
# tests 95  # pass 91  # fail 4
$ node --test .github/scripts/merge-gate.test.mjs
not ok 18 - ThinkFlowLab#602 (2026-11-01): a comment asking not to merge refuses the queue entry, through main()
# tests 48  # pass 47  # fail 1
```

The same rule was replayed over the real API payload of ThinkFlowLab#2067 (its
comments, its author `HugoXK`, read as of 17:38:30Z, after the 17:35:30
PASS):
`{"id":6561490250,"login":"HugoXK","at":"2026-11-01T17:38:11Z","phrase":"please
do not merge"}` — the merge of 17:39:54 would have been refused. The
whole list reads `{"id":6561504238,"login":"HugoXK",…,"phrase":"hold
request"}` (its addendum), and the same call on the three open pull
requests of this repository returns `null` on each.

## Verification at the tip

Tip SHA: d7b246b

Ran: `node --test ".github/scripts/*.test.mjs"` — 565 tests, 563 pass, 0
fail; `node --test .github/scripts/enqueue-guard.test.mjs
.github/scripts/merge-gate.test.mjs` — 143/143; `node
core/scripts/build-client.mjs --check` exit 0; `node
scripts/ds-bot-patches.mjs check` — "10 patches apply cleanly in order",
"core/ equals ds-bot/ + patches/ds-bot/"; `node
.github/scripts/changelog.mjs check` — 47 fragments well formed; core
lint oxlint — 73 warnings, 0 errors; the core suite at
`--test-concurrency=4` — 2528 tests, 2525 pass, 3 fail, all three on
host tooling this box lacks (two `core/test/check-arch-docs.test.js`
cases run `git`, and `core/test/usage-fields.test.js` runs `python3`);
interop 37/37 and live-e2e 24/24.

Not run (and why): `arch-docs` — this copy has no `.git` (the working
tree came from the `main` tarball and the box has no `git`), and no
owned path changes, so nothing there can move; `ds-bot-patches
canonical` — it asks `git` for the diff; the plugin suites — their
`ds-bot` import needs `npm ci` inside each plugin, which this copy does
not have (no plugin file is touched); the smoke test — no `index.js` or
`src/host/` change.

## Checkpoint
<!-- flybot:checkpoint -->
```json
{
  "pr": 2073,
  "claim": "Dev",
  "issue": 602,
  "summary": "The guard read the labels, the diff and the conclusions and never the comments, which is how ThinkFlowLab#2067 merged two minutes after its author wrote a stop: `hold` is human-only and a Bot-opened pull request cannot carry it. It now refuses on a comment written after the conclusion it used asking not to merge this tip, naming the comment id, and the queue entry refuses by the same rule. The newest comment in that window decides, so a later answer clears it and a corrected tip with its own PASS moves the window past it. Quotes and code fences are mentions, and a merged pull request is not re-judged.",
  "files": [
    ".github/scripts/enqueue-rules.mjs",
    ".github/scripts/enqueue-guard.test.mjs",
    "AGENTS.md"
  ],
  "checks": {
    "ran": "node --test \".github/scripts/*.test.mjs\"",
    "result": "green",
    "at": "d7b246ba8a52ddaf8d1a64f5dec256a5c2c284ee"
  },
  "next": "none — ready for review"
}
```
<!-- /flybot:checkpoint -->
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 memory or workflows folder reads another folder's files as this team's own

2 participants