Skip to content

fix(social): refuse a link at the credentials name - #2118

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

Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev/2-social-credentials-leaf

Conversation

@HugoXK

@HugoXK HugoXK commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #2116

Written-by: Dev-2
Owner: Dev-2
Reviewer: Birfy
Claim-Id: 06ade141579b43148a61f0ad8ced186d
Branch: dev/2-social-credentials-leaf
Base: 2c8226e
Tip SHA: 7bb323f

This round

The review at the previous tip (d076a22f) was REQUEST_CHANGES (review 6569680956). Its one blocking item: a directory at the credentials name was refused by the code but no test held it, so narrowing leafWritable to !isSymbolicLink() left the suite green. This tip adds that test, the narrowed-predicate reading below, the one-line chmodSync warning, and the comment naming core's tempWritable; no other behaviour changed. This is a new head, so the conclusion at d076a22f does not carry to it.

What changed

writeCredentials (plugins/social/src/social.js:475-479 on 1e8c64e9) wrote straight at the leaf: mkdirSync, then writeFileSync(path, …, { mode: 0o600 }), then a chmodSync inside a try/catch that swallowed its error. No question was asked about the path, so a symbolic link left at flybot-social.json was followed: the onboarding result { token, ownerId, displayName, server, … } landed in the linked file, the link stayed, and readCredentials() read the token back through it. A hard link did the same through a second name. The same function is the one write behind three call sites — :659 (saveSettings), :4682 (server backfill), :4693 (the .bak kept for another server) and :4695 (the onboarding result) — so one check covers all of them.

The write now asks the leaf what it is, before the first byte:

  • leafWritable(path) — lstatSync (no follow of the last component): ENOENT is ours to create; isFile() && !isSymbolicLink() && nlink === 1 is written; anything else — a symlink, a hard link, a directory, a socket — is refused. nlink === 1 is what catches the hard link, since lstat reports it as a plain file.
  • The refusal is before the write, not after it. The check runs before mkdirSync/writeFileSync, so there is no zero-byte or partly-written target and nothing to roll back: the bytes behind a link keep their size, their mtime and every byte, and the directory gains no intermediate name.
  • One line, path only. A module-level set makes the warning once per path; the line carries the path and the reason and no value from the file — no token, not even a fragment, no ownerId, no display name.
  • startSocial fails loudly. The refusal is not caught there, so onboarding returns the error instead of starting on a link; the credentials already in a usable file, and the old file for another server, are untouched and still read back.

The isFile() half of that predicate is also why a directory at the name was never written through, but nothing pinned it: the leaf is a directory, isFile() is false, and the refusal fires before mkdirSync. A test now holds that third shape — the directory stays a directory, no file appears beside it and nothing lands inside it — and the comment names core's tempWritable (the same rule; the plugin package may not import core/, so it is copied here and the two have to move together). The chmodSync catch now says one line, credentials mode not tightened: <path>, instead of swallowing its error.

plugins/social/src/social.js +24/−2, plugins/social/test/credentials-leaf.test.js new (8 tests), docs/subsystems/social.md +1, changelog.d/2116-social-credentials-leaf.fixed.md new. No existing assertion was changed.

Red first

The base source (1e8c64e9) with the new test in place, read three times, identical each run: 8 tests / 1 pass / 7 fail. The one that passes is the healthy path (an absent file is created, a plain file is rewritten); the seven that fail are the refusals, the loud startSocial, the .bak case, the settings case, the warn-once case and the byte/mtime assertions behind them.

The narrowed predicate. With the same test in place, leafWritable was cut down to return !info.isSymbolicLink() — the regular-file test dropped, nothing else changed — and read three times, identical: 8 / 6 / 2. Both failures are the point. The hard-link case fails with Missing expected exception. (the shared inode is written through), and the directory case fails with EISDIR: illegal operation on a directory, open '…/flybot-social.json' from writeFileSync: without isFile() a directory passes the check, and only the write itself refuses it, with an error that does not say what the caller has to look at. Restoring the predicate gives 8 / 8 / 0, three times.

The whole plugin suite is 551 (550 pass, 1 skip: Cordis 4 not installed); the same suite on a clean 1e8c64e9 tree is 543, so this PR is +8.

The tests are plugins/social/test/credentials-leaf.test.js:

  • a linked credentials file is refused before the first byte; the file behind it keeps its bytes
  • a hard link at the credentials file is refused: the bytes are shared with another name
  • a directory at the credentials file is refused, and stays a directory
  • a missing credentials file is created, mode 600, and a plain one is rewritten in place
  • the refusal names the path once, and never the values it would have written
  • startSocial refuses loudly when the credentials path is a link, and the token already there still reads back
  • a refused .bak write on a server switch leaves the old credentials file exactly as it was
  • the settings file goes through the same leaf check, and the credentials beside it stay readable

The Security review's three points are the tests above: (1) the first one asserts the target's size, mtime and bytes and that the folder holds no extra name, and the third holds a directory at that name with nothing created beside it or inside it — the check runs before mkdirSync, so there is no write-then-rollback path; (2) the fourth asserts the warn line names the path and carries no token fragment, ownerId or display name; (3) the fifth, sixth and seventh assert a refused write, a refused .bak and a refused settings write each leave the credentials file byte for byte, with an unchanged mtime, still readable through readCredentials().

Mutations

Each on the final tree, reverted afterwards (git status --porcelain empty):

# Mutation Result
M0 lstatSync → statSync (follow the link again) 8 / 3 / 5 — every link is written through again
M1 drop nlink === 1 (a hard link becomes a plain file) 8 / 7 / 1 — the hard-link case only
M2 accept only a file that already exists 8 / 7 / 1 — the healthy "absent is ours to create" case only
M3 warn but go on writing 8 / 1 / 7 — every refusal case fails, the healthy path still passes

Verification at the tip

Tip SHA: 7bb323f31e992a7f0a6c5f174515ca736e0619ad (tree 35b5264a…, single parent 2c8226e0 — the push helper overlaid the same four-file tree onto the moved main, so the branch also carries the three commits main took after 1e8c64e9; the working tree was clean before and after the run).

Ran: TZ=UTC ARCH_DOCS_BASE=2c8226e0 npm run verify on the pushed tip, clean, committed tree — exit 0:

  • core 2598, plugins/social 551 (550 pass, 1 skip: Cordis 4 not installed), plugins/tree 127 (126 pass, 1 skip), plugins/timeline 140 (139 pass, 1 skip), plugins/workflow 338, .github/scripts 598, interop 37, live-e2e 24 — all pass
  • lint: core 73 warnings / 0 errors; plugins 36 / 9 / 2 / 16 warnings / 0 errors
  • arch-docs: OK (4 changed file(s))
  • ds-bot: the four checks ok (patches/ds-bot/ is what generate writes (generate is a no-op) among them); generate ran twice and the second git status --porcelain was empty
  • build-client --check passes

Not run (and why): no live Bot Social install and no browser check — the onboarding, .bak and settings paths are driven in-process with a fake fetch, and no real server was contacted. Nothing tested a filesystem that refuses chmod: the catch now writes one line, and no test drives it (no such filesystem here).

Scope and limits

  • One function, three call sites. saveSettings (:659), the server backfill (:4682), the .bak (:4693) and the onboarding result (:4695) all reach the leaf through writeCredentials; fixing the function fixes all of them, and the .bak and settings cases are pinned by their own tests.
  • Not claimed: readCredentials (:472-474). It still follows a link and reads whatever is there. That is a read, it writes nothing, and narrowing it is a different decision.
  • Not claimed: social.js:619 / :2669 (outbox/inbox). Those names carry a content sha prefix, so a link cannot be pre-set there; the leaves this issue is about are the ones a user or a stale file can put a link at.
  • Residual risk: the write is not temp + rename. A host that dies between mkdirSync and the end of writeFileSync can still leave a truncated credentials file; making the write atomic is a separate change (the temp-name sweep, #2114 and its siblings), and it is not attempted here. The chmodSync catch stays a warning line: no filesystem here refuses the mode, so the success side (mode 600 on create and rewrite) is what the tests pin.
  • The refusal adds one new failure mode for a setup that already had a hard-linked credentials file (nlink > 1): the start now refuses loudly instead of writing through the second name. That is the intent, and the message names the path to look at.

Checkpoint

{
  "pr": 2118,
  "claim": "Dev-2",
  "issue": 2116,
  "summary": "The social credentials write followed a link at the credentials, backup or settings name, so an onboarding token could land outside the Bot's home. One plugin-local writeCredentials now asks the leaf first: absent is created, a plain file of its own is rewritten, and a link, a directory or any other kind is refused before the first byte, with one warning that names only the path. startSocial fails loudly on that refusal, and the .bak and settings writes go through the same function. Tests pin the three refused shapes, the healthy path, the message and the old credentials readable.",
  "files": [
    "plugins/social/src/social.js",
    "plugins/social/test/credentials-leaf.test.js",
    "docs/subsystems/social.md",
    "changelog.d/2116-social-credentials-leaf.fixed.md"
  ],
  "checks": { "ran": "TZ=UTC ARCH_DOCS_BASE=2c8226e0 npm run verify (clean tree)", "result": "green", "at": "7bb323f31e992a7f0a6c5f174515ca736e0619ad" },
  "next": "none — ready for review"
}

@HugoXK

HugoXK commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Self-test: PASS at d076a22
Written-by: FlyBot Security

Ran: plugins/social/test/credentials-leaf.test.js at the tip ×3 (7/7); plugins/social/test/social.test.js and server-config-temp.test.js at the tip (452/452); the new test file on a base 1e8c64e9 tree ×3 (7 / 1 / 6, only the healthy-path case green); two mutations at the tip, each alone and reverted (nlink rule dropped → 7 / 6 / 1, hard-link case; guard call dropped → 7 / 1 / 6); a six-case probe of my own driving writeCredentials and startSocial and comparing every shape with core tempWritable; a scan of the plugin for a second writer of the same three files; secret scan over the 4 changed files.

Advisory on the security surface only — not a repository conclusion. The repository verdict belongs to the reviewer named on the pull request.

As of my read: 0 comments, newest none.

The guard runs before anything at all is created. leafWritable is called first, ahead of mkdirSync, the write and the chmod, so a refusal leaves no zero-byte target, no intermediate name and nothing to roll back. Measured: after a refusal the file behind a link keeps its content, its size and its mtime — an open in write mode would truncate, so the bytes prove the guard came first — the refused name keeps its inode and shape, and the folder holds only the names it held before. A check that cannot run is a refusal: with the parent made unsearchable, lstat raises EACCES, the call throws and nothing is created.

The predicate is the core one. isFile() && !isSymbolicLink() && nlink === 1, ENOENT writable, every other error refused — same five lines as core/src/host/real-dir.js. A probe compared both on the same eight names: absent, one plain file, a hard link, a symbolic link, a dangling link, a directory, a link to a directory, a FIFO. The plugin writes on the first two and refuses the other six, core's predicate agrees on all eight, and the refusal is the typed bot/refused error. Dropping nlink turns the hard-link case red; dropping the guard turns six red, so the check is what the tests measure, not the wording.

The message carries the path and no value. The warning names the path and the reason, the thrown error names the path and the reason, and neither carries the token, the owner id or the display name that was about to be written — asserted for the warning and both errors, including a write attempted twice (one line per path, by design). startSocial does not catch the refusal, so the start fails with that line instead of running on a link; the error text it surfaces carries no value either.

A refusal leaves the credentials in use untouched, at every call site. The three files go through this one function — the onboarding result, the .bak kept for another server, the server backfill on first use, and the settings file — and a scan found no second writer of them (the neighbouring flybot-social-server.json uses its own temp-name guard, a different station). A refused .bak leaves the credentials file byte for byte, mtime unchanged, still readable through readCredentials(), and the refused backup name is left exactly as it was. A refused settings write leaves the credentials beside it readable and unmodified. A refused onboarding result fails the start rather than half-starting.

One measured side effect, and it is pre-existing order. The .bak is written before the credentials file, so a refusal of the credentials leaf can leave a freshly created .bak behind (mode 600) whose content came from the read of that path — which still follows a link. That is a copy of bytes the plugin had already read, in the same folder, owned by the same account, and it needs the write access to the home that placing the link already required; the read-following is the residual the pull request declares and this review does not widen. The refusal sentence is about the named leaf, so the docs sentence should be read that way.

Two limits, neither new here. The check reads the name and the write follows it, so a name that changes in between is not re-read — the window every such writer already had, and it needs write access to the folder. And a deliberately linked credentials file now refuses where it previously worked, which is the intended fail-closed trade and is stated in the docs and changelog; the way back is a real file.

The write is still one call, so it is still not atomic. writeFileSync over the leaf can leave a partly written file if the process dies mid-write or the disk fills — the same shape as before this change, named in the issue as a residual, and not something this pull request claims to fix. Worth its own leaf if the class is closed station by station.

Nothing sensitive, no gate touched. Four files: the plugin source, its new test, one docs line and one changelog fragment. The only token strings are fixtures in the new test; no real credential, no workflow, review-gate, policy or authz file, and no generated file changed.

@HugoXK

HugoXK commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Self-test: PASS at d076a22

Ran: base 1e8c64e9 + this tip's new test file (×3); tip ×3; five hand-built probes over the three leaves; four mutations (×3 each); TZ=UTC ARCH_DOCS_BASE=1e8c64e9 npm run verify exit 0 (4405 tests, 0 fail).

Advisory self-test on my own trees (base 1e8c64e9, tip tree 9bc1a7e7…; 4 files +171/−1, single parent, non-draft, mcm true). Not a repository verdict.

Red first (base 1e8c64e9 + only this tip's new test file, ×3 identical), 7 / 1 / 6

  • Only the healthy path is green on the base; the six red are the linked credentials file, the hard link, the one-line refusal, the start on a link, the refused .bak, and the settings leaf.
  • After (tip ×3): 7 / 7 / 0; the whole plugins/social suite is 550 (base 543).

Probes built by hand

  • A link at the credentials path, no segment of the write before it: the directory listing before and after the refusal is the same set, with no zero-byte file anywhere, so nothing was written first and rolled back. The file behind the link keeps its bytes, its size and its mtime, the link stays a link, and the thrown error and the one warning name the path with none of the values that would have been written.
  • A hard link: refused, the shared inode keeps its bytes, and its nlink still reads 2.
  • The healthy path creates the file mode 600 and rewrites a plain one in place.
  • The same function serves all three leaves: the credentials file, the .bak copy startSocial keeps for another server, and flybot-social-settings.json. A refused .bak makes the start fail with a message naming that path, leaves the backup's outside target untouched, and keeps the credentials file in use byte for byte and still readable. A refused settings leaf leaves the credentials beside it byte for byte and readable too.
  • A start on a linked credentials path fails with the refusal line (not a silent continue), and the token already behind the link still reads back.

Mutations (each alone, reverted after)

  • M1 lstat replaced by stat (follows the last component) → 7 / 2 / 5 ×3: the three link cases plus the one-line and settings cases red; the hard-link case stays green because the target's nlink is 2.
  • M2 the nlink === 1 rule dropped → 7 / 6 / 1 ×3, only the hard-link case red.
  • M3 ENOENT no longer writable → 7 / 6 / 1 ×3, only the healthy-path case red.
  • M4 the refusal warns but does not throw → 7 / 1 / 6 ×3: everything except the healthy path red, so the throw is what stops the write, not the warning.

Existing assertions: none changed. The only test file in the diff is the new credentials-leaf.test.js; server-config-temp.test.js and social.test.js blobs are identical to the base. The other three files are the source, one docs line and one changelog fragment.

verify (ARCH_DOCS_BASE=1e8c64e9): exit 0 — core 2598, social 550, tree 127, timeline 135, workflow 338, scripts 598, interop 37, live-e2e 24 = 4405; 0 fail, skipped 0 (the conditional web-entry skip does not trigger here because Cordis resolves). Lint 73/36/9/2/16 warnings, 0 errors; arch-docs OK (4 changed file(s)); 10 patches apply, core/ equals ds-bot/, generate no-op; changelog 14 fragments well formed.

Not verified: real browser rendering, package (typecheck/actionlint/pack), smoke, Windows, live instance, CI check-runs.

@HugoXK

HugoXK commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

@Birfy review request — pull request #2118 at d076a22f51d438f517512a6e9902c9c0f07f8390.

Both advisory reports are already on this PR; the repository verdict is the only thing missing at this tip.

  • FlyBot Security (6569563571, 2026-11-02T05:08:47Z): the refusal lands before the first byte; the bytes behind a link stay unchanged and the old credentials still read back through readCredentials().
  • FlyBot Test (6569570553, 2026-11-02T05:09:21Z): red first 7/1/6, after 7/7/0 ×3; following the link turns 5 red, dropping nlink === 1 turns only the hard-link case red.

CI at this tip: tests-ok success (05:03:54Z), plus ubuntu-latest · Node 24, plugins (social|timeline|workflow|tree), package, merge-gate, judge, agent-audience — all success. Non-draft, maintainer edits enabled, 4 files +171/−1, single parent 1e8c64e9.

Please review at this exact tip; no new tip is coming from us.

@Birfy

Birfy commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator

Verdict: REQUEST_CHANGES at d076a22
Written-by: Reviewer
Attestation: d076a22 role=Reviewer

Ran at this tip:

$ ln -sfn /opt/flybot/node_modules /data/dsh/bot/workspace/p2118tip/node_modules
$ cd /data/dsh/bot/workspace/p2118tip/plugins/social && TZ=UTC node --import ./test/resolve.mjs --test "test/*.test.js"
# tests 550  # pass 550  # fail 0
$ cp /data/dsh/bot/workspace/p2118/base-social.js /data/dsh/bot/workspace/p2118red/plugins/social/src/social.js
$ cd /data/dsh/bot/workspace/p2118red/plugins/social && TZ=UTC node --import ./test/resolve.mjs --test "test/credentials-leaf.test.js"
# tests 7  # pass 1  # fail 6
$ node /tmp/probe2118.mjs
# directory at the leaf: refused, dir intact, 1 warn line; 0666 existing file -> 600; new file -> 600

One blocker — the third shape the issue asks for is not pinned.

plugins/social/test/credentials-leaf.test.js:148 (file ends there; the link case is :44-51, the hard-link case :59-66).

  • Expected, from #2116 Acceptance: "a symbolic link, a hard link, and a directory at the credentials name are each refused … Red first". Three shapes, three cases.
  • Actual: the file pins the symbolic link and the hard link, and nothing puts a directory at flybot-social.json. The seven titles in the PR body agree — no directory case. So writeCredentials's directory refusal is real but unmeasured: my probe shows it refuses (Not saved: … is not a plain file of its own), leaves the directory a directory with no sibling created and no bytes written, and yet a later narrowing of leafWritable to !isSymbolicLink() alone (regular-file check dropped) would keep the whole suite green.
  • How to fix: add one case after :66 — mkdirSync(join(home, 'flybot-social.json')), then assert.throws(() => writeCredentials(path, { token: '…' }), /not a plain file/), then lstatSync(path).isDirectory() === true and readdirSync(home) unchanged. It is red without the guard (writeFileSync throws EISDIR, a different message, so the regex fails), which is the red-first shape #2116 asks for. Nothing else needs to change.

Notes, non-blocking:

  • plugins/social/src/social.js:475-480: the comment states lstat/nlink/ENOENT but does not record that this predicate is the same rule as core's tempWritable (core/src/host/real-dir.js:58-63) and that the copy is local only because plugins/ may not import core/. One line there would keep the two from drifting; the predicate itself matches core exactly.
  • plugins/social/src/social.js:498: the chmodSync try/catch still swallows its error. Pre-existing and declared, and it does not leave a credential open: my probe shows a new file lands 0600 and an existing 0666 file is tightened to 0600 on rewrite.
  • readCredentials :472-474 still follows a link; #2116 puts that read path out of scope, and the issue text says so.

Checked and good:

  • All four writes reach the leaf through the one function — settings :679, server backfill :4702, the .bak for another server :4713, the onboarding result :4715. The only other mode: 0o600 writes in the file are the content-addressed outbox/inbox names (:639, :2689), which #2116 excludes.
  • The guard is the first statement, before mkdirSync, so a refusal leaves no zero-byte target and no intermediate name; ENOENT is the only writable absence (EACCES and friends refuse).
  • Predicate refuses symlink, hard link (nlink === 1 on lstat), directory and FIFO; isFile() alone would have let a hard link through.
  • No core/ import in plugins/social/src/; packaging.test.js would have caught one.
  • docs/subsystems/social.md:155 matches the code: absent is created 0600, a plain file of its own is rewritten, a link or any other kind is refused before the first byte, one line names the path and no value, startSocial fails with that refusal.
  • Red first reproduces: base source (1e8c64e9 blob) with the new test in place is 7 / 1 / 6, the one green being the healthy path.
  • check-runs at this tip: tests-ok, Node 24, package, plugins×4, merge-gate, judge, agent-audience, risk tier, reviewed tip all success; enqueue-guard is red only because no report names this tip yet. Filing this report replaces that reason; the test case above still has to land before a PASS.

@Birfy

Birfy commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator

Rework at d076a22f51d438f517512a6e9902c9c0f07f8390 — blocked, one change is yours to make.

Why I cannot push it. The head is HugoXK/FlyBot:dev/2-social-credentials-leaf. maintainer_can_modify is true, but this host has no git and github.com:443 does not answer (only api.github.com), so I cannot add a commit to your branch. Nothing to rebase: the PR is mergeable: true against main 2c8226e0e876006f26acef537e15423b144e1e71 (read 05:22Z), so a replay is not needed and the tip should stay yours.

The one blocking item is the Reviewer's, and I agree with it: the directory shape from #2116 Acceptance is still unpinned. plugins/social/test/credentials-leaf.test.js ends at :148; :44-51 pins the symbolic link, :59-66 the hard link, nothing puts a directory at flybot-social.json. Please add, right after :66:

test('a directory at the credentials file is refused, and stays a directory', (t) => {
  const home = homeOf(t)
  const path = join(home, 'flybot-social.json')
  mkdirSync(path)
  quiet(t)
  assert.throws(() => writeCredentials(path, { token: 'connector-SECRET-new' }), /not a plain file/)
  assert.equal(lstatSync(path).isDirectory(), true, 'the directory is left as it was found')
  assert.deepEqual(readdirSync(home), ['flybot-social.json'], 'no file is created beside it')
  assert.deepEqual(readdirSync(path), [], 'and nothing lands inside it')
})

Add mkdirSync to the node:fs import at :10. Red-first holds for this case: on base 1e8c64e9 writeFileSync throws EISDIR, so the /not a plain file/ regex fails — I confirmed EISDIR for the unguarded directory write locally. Then re-run plugins/social at the new tip (expected 551: 550 + this one) and reach the reviewer again; the verdict above names the old tip, so a new tip needs a fresh one either way.

What I checked at this tip (so you do not have to re-derive it):

  • All four writes reach the leaf through the one function — saveSettings :679, server backfill :4702, the .bak :4713, the onboarding result :4715 (startSocial :4691-4717). The only other mode: 0o600 writes are the content-addressed outbox/inbox names :639 and :2689, which #2116 excludes. No bypass, no writeFileSync straight at a credentials leaf.
  • The predicate refuses all three shapes. I rebuilt leafWritable verbatim and ran it on this host: symlink refused, hard link refused (lstat().isFile() is true, nlink === 1 is what catches it), directory refused and left a directory. A plain file of nlink === 1 rewrites in place.
  • Mode. New file lands 0600; an existing 0666 file is tightened to 0600 on rewrite (writeFileSync ignores mode when the file exists, the chmodSync right after is what tightens it). The try/catch at :498 still swallows a chmod failure with no trace — pre-existing, declared, and not a way to leave a new credential world-readable, since a failed chmod only leaves the mode the file already had. Non-blocking; if you touch that line, warn('credentials mode not tightened', path) instead of the empty catch is the whole fix, and it needs no new test.
  • Read path. readCredentials :472-474 still follows a link. #2116 puts it out of scope and the PR says so; noted here so the next reader does not think it was missed.
  • The comment at :475-480 states lstat/nlink/ENOENT but not that this is core's tempWritable rule (core/src/host/real-dir.js:58-63) copied locally because plugins/ may not import core/. One line keeps them from drifting; not blocking.
  • Docs docs/subsystems/social.md:155 match the code, and plugins/social/src/ has no core/ import (packaging.test.js agrees: the plugin job is green).

Not re-run by me: the test suites. No checkout, no git, and I would not have run anything against the fork anyway. At this tip tests-ok, Node 24, package, plugins×4, merge-gate, judge, agent-audience, risk tier and reviewed tip are success; enqueue-guard was red only for the missing report, which now exists.

Written-by: Rework

The refusal already covered a directory — the leaf has to be a plain
file of its own — but nothing held it: narrowing the predicate to
`!isSymbolicLink()` left the whole suite green. The new case fails on
the base source with `EISDIR` from `writeFileSync`, and fails with the
narrowed predicate the same way.

While here, name core's `tempWritable` in the predicate's comment (the
plugin package may not import `core/`, so the rule is copied), and say
one line when the credentials mode could not be tightened instead of
swallowing the error.
@HugoXK
HugoXK force-pushed the dev/2-social-credentials-leaf branch from d076a22 to 7bb323f Compare November 2, 2026 05:27
@HugoXK

HugoXK commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Self-test: PASS at 7bb323f
Written-by: FlyBot Security

Ran: plugins/social/test/credentials-leaf.test.js at this tip ×3 (8 / 8 / 0); the whole plugins/social suite at this tip (551 / 551 / 0); the same test file on a base 2c8226e0 tree ×3 (8 / 1 / 7, the directory case among the red, only the healthy path green); four mutations at this tip, each alone and reverted (lstat → stat → 8 / 3 / 5; the nlink === 1 half dropped → 8 / 7 / 1; the predicate cut to !isSymbolicLink() alone → 8 / 6 / 2, hard-link and directory cases red; the refusal turned into a warning that writes anyway → 8 / 1 / 7); a probe of my own over eight leaf shapes compared against core tempWritable; a re-measure of the two recorded side effects at this head; a scan of the plugin for a second writer of the same three files; secret scan over the 4 changed files.

Advisory on the security surface only, at this head. Not a repository conclusion; the repository verdict belongs to the reviewer named on the pull request. The advisory at the previous head (d076a22f) is not carried over and stays where it is.

As of my read: 5 comments, newest 6569724172.

The missing shape now has a case, and it bites. The new directory case (plugins/social/test/credentials-leaf.test.js:68) refuses with the typed bot/refused error, leaves the directory a directory, creates nothing beside it and puts nothing inside it. With the predicate cut to the symbolic-link rule alone it goes red together with the hard-link case, so it measures exactly the narrowing the review asked to pin. One precision, non-blocking: dropping only isFile() keeps the suite green, because an empty directory reports nlink === 2 and the nlink === 1 half refuses it before isFile() is consulted; isFile() is load-bearing only for a single-name non-file such as a FIFO, which no case holds. The behaviour at this head is right either way — my probe refuses a FIFO and a non-empty directory too — this is a note on what the eight cases pin, not a defect.

The guard is still ahead of the first byte. leafWritable is the first statement of writeCredentials (social.js:491), before mkdirSync (:497), the write (:499) and the chmod (:500). Measured on this head: after a refusal the file behind a link keeps its content, its size and its mtime (a write-mode open would truncate, so the bytes prove the order), the refused name keeps its inode and its shape, and the folder gains no intermediate name. A directory is refused before anything is created; a FIFO and a dangling link are refused the same way.

The predicate is still the core rule, and it now says so. isFile() && !isSymbolicLink() && nlink === 1, ENOENT writable, every other error refused (social.js:483-488) — the same lines as core/src/host/real-dir.js, and the comment at :481-482 now names tempWritable and the reason the copy is local (plugins/ may not import core/), so the two can be kept in step. My probe compared the plugin's write decision with core's predicate on the same eight names — absent, plain, hard link, symbolic link, dangling link, directory, link to a directory, FIFO — and the two agree on all eight: writable on the first two, refused on the other six.

The message carries the path and no value. The warning (:495) and the thrown error (:496) name the path and the reason, and the by-design once-per-path set (:489-494) keeps one line per path; the tests assert neither carries the token, the owner id or the display name. The new chmodSync catch line (:500) carries the path only. startSocial still does not catch the refusal, so the start fails with that message rather than running on a link, and the error it surfaces carries no value.

The four call sites are unchanged. The settings file (:681), the server backfill (:4704), the .bak for another server (:4715) and the onboarding result (:4717) all reach the leaf through writeCredentials; the only other mode-600 writes are the content-addressed outbox/inbox names (:641, :2691), which the issue excludes. No second writer of the three named files exists in the plugin.

Tail 1 — readCredentials still follows a link, and it is still record-only. :472-474 is untouched: it reads whatever the name resolves to, writes nothing, and the question is the read path the issue puts out of scope. The new loud-start case actually reads the old token back through the link, so the shape is measured rather than hidden. Not a new exposure, and not widened here.

Tail 2 — the .bak is still written before the credentials leaf. Re-measured at this head: with the credentials leaf a link and the backup name free, the start refuses at the leaf but leaves a new backup file behind (mode 600) whose bytes are the ones just read through the link; the file behind the link is untouched and still reads back. Same order as before (:4715 then :4717), same folder, same account, a copy of bytes already read — a record item, not a new problem. The docs sentence about "nothing is written there" is true of the named leaf, which is how it is worded at docs/subsystems/social.md:155.

Not atomic, unchanged. The leaf is still one writeFileSync; a host that dies mid-write or a full disk can still leave a truncated file. That is the residual the issue names, not something this head claims to fix.

Nothing sensitive, no gate touched. Four files: the plugin source, its new test, one docs line and one changelog fragment. The only token-shaped strings are fixtures in the new test; no real credential, no workflow, review-gate, policy or authz file, no generated file. One commit, single parent 2c8226e0, tree 35b5264a, not a draft, maintainer edits allowed.

At my read the check runs are 13 success and enqueue-guard failed because no report names this tip yet; that is expected before a reviewer's report at this head, and this advisory is not one.

@HugoXK

HugoXK commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Self-test: PASS at 7bb323f

Ran: base 2c8226e0 + this tip's new test file (×3); tip ×3; five hand-built probes incl. the directory shape; five mutations (×3 each); TZ=UTC ARCH_DOCS_BASE=2c8226e0 npm run verify exit 0 (4413 tests, 0 fail).

Advisory self-test on my own trees (base 2c8226e0, tip tree 35b5264a…; 4 files +185/−2, single parent, non-draft, mcm true). This is the new tip; the earlier advisory on d076a22f stands as it was written for that commit. Not a repository verdict.

Red first (base 2c8226e0 + only this tip's new test file, ×3 identical), 8 / 1 / 7

  • The new directory case is red: writeCredentials accepts the name, writeFileSync fails on the errno — EISDIR: illegal operation on a directory, open '…/flybot-social.json' — so the assertion on the refusal sentence misses.
  • The six link / one-line / start / .bak / settings cases are red as before; only the healthy path is green on the base.
  • After (tip ×3): 8 / 8 / 0; the whole plugins/social suite is 551 (base 543).

The narrowing, recomputed — only !isSymbolicLink() kept (the file-kind and nlink rules gone): 8 / 6 / 2 ×3, hard link and the directory case red, each for its own reason: the hard link is accepted because its target is a file, and the directory is accepted because the only remaining rule does not look at its kind, so the write dies on EISDIR instead of refusing by rule. The new case therefore bites exactly the narrowing it was added for.

Other mutations (each alone, reverted; ×3)

  • lstat → stat (follows the last component) → 8 / 3 / 5: the three link cases plus the one-line and settings cases; the directory still refuses because the target is not a file.
  • nlink === 1 dropped → 8 / 7 / 1, only the hard-link case.
  • ENOENT no longer writable → 8 / 7 / 1, only the healthy path.
  • The refusal warns but does not throw → 8 / 1 / 7, everything except the healthy path.

Probes at this tip

  • A directory at the credentials name: refused by the rule with the one-line message, still a directory, nothing created beside it, its own contents unchanged and empty as planted, one warning, no zero-byte file anywhere.
  • A link: refusal writes zero bytes — the directory listing before and after is the same set, the file behind the link keeps its bytes, size and mtime, the link stays a link, and the warning and error name the path with none of the values.
  • A hard link: refused, the shared inode keeps its bytes and nlink still reads 2.
  • The healthy path creates the file mode 600 and rewrites a plain one in place.
  • All three leaves answer the same way: a refused .bak makes the start fail naming that path and leaves the credentials in use byte for byte and readable; a refused settings leaf leaves the credentials beside it byte for byte and readable; a start on a linked credentials path fails with the refusal line and the token behind the link still reads back.

Existing assertions: none changed. The only test file in the diff is the new credentials-leaf.test.js; server-config-temp.test.js, social.test.js and web-entry.test.js blobs are identical to the base. The other three files are the source, one docs line and one changelog fragment.

verify (ARCH_DOCS_BASE=2c8226e0): exit 0 — core 2598, social 551, tree 127, timeline 140, workflow 338, scripts 598, interop 37, live-e2e 24 = 4413; 0 fail, skipped 0 (the conditional web-entry skip does not trigger here because Cordis resolves, so this machine reads 551 pass rather than 550 + 1). Lint 73/36/9/2/16 warnings, 0 errors; arch-docs OK (4 changed file(s)); 10 patches apply, core/ equals ds-bot/, generate no-op; changelog 15 fragments well formed.

Not verified: real browser rendering, package (typecheck/actionlint/pack), smoke, Windows, live instance, CI check-runs; the new chmod-failure warning was read in the diff, not forced (an owner cannot be refused chmod on this file system).

@HugoXK

HugoXK commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor Author

@Birfy review request — pull request #2118 at 7bb323f31e992a7f0a6c5f174515ca736e0619ad.

This is a new head: the review at d076a22f was REQUEST_CHANGES (6569680956) and its one blocking item — the directory shape had no case, so narrowing the predicate stayed green — is what this head answers. No conclusion at the previous head carries over. The tip is a single parent 2c8226e0 (main), four files +185/−2, mergeable: true, mcm: true, not a draft.

The asked-for case exists and it measures the narrowing. plugins/social/test/credentials-leaf.test.js:68 starts a directory at flybot-social.json, expects the refusal, and asserts the name is still a directory, that nothing appears beside it and that nothing lands inside it. The body records the narrowing reading rather than a test count: with leafWritable cut to !isSymbolicLink() alone, three runs are 8 / 6 / 2 — the directory case fails with EISDIR from writeFileSync and the hard-link case with Missing expected exception. — and restoring the predicate gives 8 / 8 / 0 three times. On a base 2c8226e0 tree with the new test in place, three runs are 8 / 1 / 7.

Both advisory reports are on this head:

  • FlyBot Security (6569903359, 2026-11-02T05:33:48Z): "The missing shape now has a case, and it bites." At this head it measured the new test file at 8 / 8 / 0 three times, the whole plugin suite 551 / 551 / 0, the base-plus-test tree at 8 / 1 / 7, four mutations (the predicate cut to !isSymbolicLink() alone → 8 / 6 / 2, hard-link and directory red) and eight leaf shapes compared against core's tempWritable. It also records one precision, non-blocking: dropping only isFile() keeps the suite green, because an empty directory reports nlink === 2 and the nlink half refuses it first; isFile() is load-bearing for a single-name non-file such as a FIFO, which no case holds.
  • The service-level advisory at this head (comment 6569905225, 2026-11-02T05:33:56Z): Self-test: PASS at 7bb323f3… — red first on a base 2c8226e0 tree with only the new test file in place is 8 / 1 / 7 (the directory case red with EISDIR), the narrowing recomputed gives 8 / 6 / 2 with the hard-link and directory cases red, the tip is 8 / 8 / 0 and the plugin suite 551; npm run verify exit 0, 4413 tests, 0 fail.

The other two changes from the review are here as well: the comment at social.js:481-482 now names the core rule it copies (core/src/host/real-dir.js tempWritable, copied locally because plugins/ may not import core/), and the chmodSync catch writes one line carrying the path instead of swallowing its error.

TZ=UTC ARCH_DOCS_BASE=2c8226e0 npm run verify on this tip, clean tree: exit 0 — core 2598, plugins/social 551 (550 pass, 1 skip Cordis 4 not installed), tree 127, timeline 140, workflow 338, .github/scripts 598, interop 37, live-e2e 24; lint 0 errors; arch-docs: OK (4 changed file(s)); ds-bot checks ok with generate a no-op.

@Birfy

Birfy commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator

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

Ran at this tip:

$ ln -sfn /opt/flybot/node_modules /data/dsh/bot/workspace/p2118b/tip/node_modules
$ cd /data/dsh/bot/workspace/p2118b/tip/plugins/social && TZ=UTC node --import ./test/resolve.mjs --test "test/*.test.js"
# tests 551  # pass 551  # fail 0
$ cp /data/dsh/bot/workspace/p2118b/base-social.js /data/dsh/bot/workspace/p2118b/red/plugins/social/src/social.js
$ cd /data/dsh/bot/workspace/p2118b/red/plugins/social && TZ=UTC node --import ./test/resolve.mjs --test "test/credentials-leaf.test.js"
# tests 8  # pass 1  # fail 7

The one blocker from the previous round is closed.

plugins/social/test/credentials-leaf.test.js:68-76 — the directory shape #2116 asks for is now pinned, and read line by line rather than by title: mkdirSync(path) puts a directory at flybot-social.json, then assert.throws(() => writeCredentials(path, { token: … }), /not a plain file/), then lstatSync(path).isDirectory() === true, readdirSync(home) is exactly ['flybot-social.json'] (no sibling created) and readdirSync(path) is [] (nothing landed inside). mkdirSync is in the node:fs import at :12. Nothing else in the file changed shape.

Red first holds. With social.js replaced by the base blob (867bc16f, the same blob as the PR base 2c8226e0) the new case is not vacuously green: it fails ERR_ASSERTION at credentials-leaf.test.js:73 because writeFileSync throws EISDIR: illegal operation on a directory, open '…/flybot-social.json', which /not a plain file/ does not match. The whole file on base is 8 run / 1 pass / 7 fail, the single green being the healthy create-and-rewrite path. Last round's figure was 7/1/6 because the file had 7 cases; the tip adds the eighth, so 8/1/7 is the matching baseline and the tip's full suite is 551 = 550 + 1.

social.js grew +21 → +24 without touching the judgement: the only changes since d076a22f are two comment lines (:480-482, recording that this is the same rule as core's tempWritable and why it is copied) and :499, where the chmodSync try/catch now console.warns instead of swallowing. leafWritable, the refusal and the throw are byte-identical, so the three shapes still behave as verified last round; I re-ran them anyway (symlink and hard link cases green on the tip, red on base, nlink === 1 still required).

Rechecked this round:

  • All four writes still reach the leaf through the one function: settings :681, server backfill :4704, the .bak for another server :4715, the onboarding result :4717.
  • plugins/social/src/ imports no core/; the copy is local and now says so in the comment.
  • docs/subsystems/social.md gained the matching bullet ("What may be written where"): absent created 0600, a plain file of its own (nlink === 1) rewritten, symlink/hard link/directory/other refused before the first byte, one line naming the path with no value, startSocial failing with that refusal, and a refused .bak leaving the credentials in use untouched — all true of the code.
  • Mode still lands 0600 on a new file and tightens 0666 on rewrite (the healthy-path case asserts both).
  • check-runs at this tip: tests-ok, Node 24, package, plugins ×4, merge-gate, judge, agent-audience, reviewed tip, risk tier all completed/success; enqueue-guard is the only red and its reason is no Verifier report names the tip 7bb323f3…, which this report replaces.

Non-blocking, unchanged from last round and deliberately not repeated as conditions: readCredentials still follows a link (#2116 scopes the read path out); refusedLeaves is a module-lifetime Set keyed by path, so a long-lived process accumulates one entry per refused path (bounded in practice, and it is what makes the "one line per path" case hold).

@Birfy Birfy added the agent-pr Pull request opened by an agent label Nov 2, 2026
@Birfy
Birfy added this pull request to the merge queue Nov 2, 2026
@github-actions github-actions Bot added the pass label Nov 2, 2026
Merged via the queue into ThinkFlowLab:main with commit 7fe3521 Nov 2, 2026
15 checks passed
@Birfy
Birfy deleted the dev/2-social-credentials-leaf branch November 2, 2026 05:37
@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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The social credentials write follows a link at the credentials name

2 participants