Skip to content

Create the host's own files private to the user - #2698

Merged
Birfy merged 1 commit into
mainfrom
fix/2697-private-files
Nov 8, 2026
Merged

Birfy merged 1 commit into
mainfrom
fix/2697-private-files

Conversation

@Birfy

@Birfy Birfy commented Nov 8, 2026 •

Copy link
Copy Markdown
Collaborator

Written-by: Dev

Fixes #2697

Owner: Dev
Reviewer: Reviewer

What changed

The host's own files were created with whatever umask the process inherited, and an atomic rewrite re-created them with that mode too, so on a stock 022 the saved team, a Bot's memory and its frozen snapshot, the chat index, the decision log, the OCR cache and inflight.json were 0644 in a 0755 home — and a chmod 0600 an owner made by hand came back 0644 after the next remember.

core/src/host/private-files.js is now the one place those modes live: ensurePrivateDir/ensurePrivateDirSync (mkdir with 0o700, then chmod, because mkdir leaves an existing folder alone and the umask filters the mode it does set) and stagePrivateFile/replacePrivateFile (write the temp name, chmod it to the mode the file being replaced already has, then rename), plus writePrivateFile for the in-place inflight.json write and tightenFile for SQLite's own files. The writers changed: memory.js (MEMORY.md, topics, snapshots, journal, archive folder), store.js (home mkdir and state.json), chat-index.js (the index and its -wal/-shm, tightened right after SQLite makes them, with the 0700 home covering a WAL file that appears between two checks), decisions.js (decisions.jsonl and its trim), images.js (ocr.json) and resume.js (inflight.json).

inflight.json (resume.js:78) carries no message body of its own: it records the Session, the room, the origin and the trigger of a turn that was running, plus the arguments of a delivery no turn has taken up yet — and those arguments are the message text. So it is the same kind of private thing as state.json, and it is handled with the same rule (created 0600 in a 0700 home; an existing file keeps the mode it has, because it is written in place).

Content that is public or computed may keep the ambient mode, but as a decision written where it is made: every writer this change touches names the mode of a file holding user content, and the writers outside the host's own state (workflow-store.js, journal.js, secrets.js, plugin exports) are not changed here.

docs/subsystems/core-host.md states the invariant. Tests: core/test/private-files.test.js sets umask 022 in-process (restored after each test), asserts mode & 0o777 is 0600/0700 for each file and folder, covers a seeded-0600 state.json after a real save and a chmod 0400 MEMORY.md after the next remember, and drives each writer's real module in a scratch home.

Ran at this tip:

$ cd core && node --import ./test/harness-resolve.mjs --test test/private-files.test.js
$ node scripts/ds-bot-patches.mjs generate && node scripts/ds-bot-patches.mjs check && node scripts/ds-bot-patches.mjs canonical
$ node core/scripts/build-client.mjs --check
$ cd core && npm run lint
$ node scripts/lint-plugins.mjs
$ node .github/scripts/changelog.mjs check
$ node .github/scripts/check-mojibake.mjs --check

The new file is 7/7 green at the tip; the same file against the unmodified base blobs is 0/7 under umask 022. The affected suites (memory, memory-empty, memory-count-read-failure, memory-panel-readonly, memory-search, decisions, follow-through, history, readonly-window-reads, retired-bot-paths, state-journal, legacy-state, state-link-answer, state-link-hardlink, project-state, project-state-unreadable, connector-access, i18n, project-board*, project-team, task-handoff-route, chat-link-open, bot-messages, private-files) are 490 tests green in three batches.

Not run: the full npm run verify (as instructed), and the whole core suite in one process — this sandbox runs out of threads past ~250 test files (pthread_create: Resource temporarily unavailable, spawn … EAGAIN), so the affected suites were run instead. The smoke test was not run (no desktop bench on this host).

Checkpoint

{
"pr": 2698,
"claim": "Dev",
"issue": 2697,
"summary": "Every host file holding user content is now created 0600 inside a 0700 folder, whatever umask the process inherited. An atomic rewrite reads the mode of the file it replaces and chmods the temp name before the rename, so a chmod the owner made by hand survives the next remember. The chat index and its WAL files are tightened right after SQLite creates them, and inflight.json is created private. Public or computed content keeps the ambient mode as an explicit choice, and core-host.md states the invariant.",
"files": [
"core/src/host/private-files.js",
"core/src/host/memory.js",
"core/src/host/store.js",
"core/src/host/chat-index.js",
"core/src/host/decisions.js",
"core/src/host/images.js",
"core/src/host/resume.js",
"core/test/private-files.test.js",
"docs/subsystems/core-host.md",
"changelog.d/2697-private-files.fixed.md"
],
"checks": {
"ran": "node scripts/ds-bot-patches.mjs check && node scripts/ds-bot-patches.mjs canonical && node core/scripts/build-client.mjs --check && cd core && npm run lint && node scripts/lint-plugins.mjs && node .github/scripts/changelog.mjs check",
"result": "green",
"at": "fa7da8269e177d28847096f6629112f2a010783f"
},
"next": "Reviewer: core/src/host/private-files.js is the whole rule; the rest is call sites."
}

The host's own files carry the user's content, and a write that names no mode takes the
process umask: on a stock 022 the saved team, a Bot's memory, the chat index, the decision
log, the OCR cache and inflight.json land 0644 in a 0755 home, and an atomic rewrite
widens a file the owner narrowed to 0600.

core/src/host/private-files.js is the one place those modes live: a file is 0600, the
folder holding it 0700, and an atomic rewrite chmods the temp name to the mode of the file
it replaces before the rename, so a chmod the owner made by hand survives the next
remember. The chat index is tightened after SQLite creates it, and the files that stay
ambient are the ones that are public or computed.

Fixes #2697
@Birfy

Birfy commented Nov 8, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict: PASS at fa7da82
Written-by: Reviewer
Attestation: fa7da82 role=Reviewer

Ran at this tip:

$ node --import ./test/harness-resolve.mjs --test test/private-files.test.js
# tests 7  # pass 7  # fail 0
$ node --import ./test/harness-resolve.mjs --test test/private-files.test.js test/memory.test.js test/memory-search.test.js test/decisions.test.js test/images.test.js test/store.test.js test/history.test.js test/session-identity.test.js
# tests 172  # pass 172  # fail 0
$ node --import ./test/harness-resolve.mjs --test test/follow-through.test.js test/readonly-window-reads.test.js test/retired-bot-paths.test.js test/task-handoff-route.test.js test/project-team.test.js test/identity.test.js test/state-link-hardlink.test.js test/diagnostics.test.js test/remote-room.test.js test/member-auth.test.js test/connector-access.test.js test/i18n.test.js
# tests 299  # pass 299  # fail 0
$ node --import ./test/harness-resolve.mjs --test test/project-board.test.js test/project-board-panels.test.js test/project-board-readonly.test.js test/project-board-view.test.js test/busy-ack.test.js test/busy-ack-ledger.test.js test/busy-ack-arrival.test.js test/face-room-hidden.test.js test/room-membership.test.js test/room-ticket-seat.test.js test/authz.test.js
# tests 211  # pass 211  # fail 0
$ node scripts/ds-bot-patches.mjs check   # 10 patches apply cleanly; core/ equals ds-bot/ + patches/ds-bot/

Red first, at the same tree with core/src/host/{chat-index,decisions,images,memory,resume,store}.js restored from the base blobs and the new test kept:

$ node --import ./test/harness-resolve.mjs --test test/private-files.test.js
# tests 7  # pass 1  # fail 6   (0644 !== 0600 at test/private-files.test.js:222 and five more)

Review:

  1. Not umask-dependent: private-files.js names 0o600/0o700; ensurePrivateDir(Sync) mkdirs with the mode and then chmods (mkdir's mode is umask-filtered and an existing folder untouched), stagePrivateFile(Sync) writes the temp name at the mode the target has now and chmods it before rename, writePrivateFile is the in-place variant, tightenFile chmods SQLite's own files with a swallowed error for a name SQLite has not made yet. chat-index.js hardens the index right after DatabaseSync creates it and again after the first commit makes -wal/-shm, with the 0700 home covering the gap; the error is warned, not thrown, so a read-only mount does not lose the index.
  2. A rewrite never widens: the mode is read with lstat (a link is not this file), a missing name is the creation case, and a non-file falls back to 0600. Cases prove it: a seeded 0600 state.json stays 0600 after a real save, and a hand-chmod 0400 MEMORY.md stays 0400 after the next remember (test/private-files.test.js:78, :110). Reverting the six writers turns six of seven red, the helper-only case passing because the new module is kept.
  3. Coverage: state.json (store), MEMORY.md + topics + snapshot + journal + archive folder (memory), chat-index.sqlite (+-wal/-shm), decisions.jsonl (append and trim), ocr.json (images), inflight.json (resume); home, bots/, memory/ folders are 0700. resume.js's content nature is stated in the body and in the module comment.
  4. Existing behavior unchanged: every hunk is a call-site swap; decisions.js keeps the tempWritable refuse-before-write and the leafWritable/trimRefused paths, images.js keeps its temp-name check and the ocrWriting chain, store.js keeps both ensureLock calls around the write, memory.js keeps the snapshot's tempWritable warning, and resume.js still writes in place (Windows rename reason preserved). Every chmod on a possibly missing name is caught, so no Windows/macOS throw.
  5. Generated files: patches/ds-bot/files/ mirrors decisions.js, private-files.js and the test byte-identically (cmp), ds-bot-patches check is green, and core/client.js is byte-identical to base — this diff touches no core/src/client.
  6. Scope: only the 15 listed files; ds-bot/, plugins/** and the temp-name symlink refusal (kept in all three writers) are untouched; core-host.md carries the invariant and the changelog fragment (Host files take the ambient umask: team state, Bot memory, chat index and OCR cache are world-readable, and a rewrite widens a 0600 #2697) is present.
  7. Tip checks: tests-ok, Node 24 · DSH 0.2.0-rc.2, package, policy-sign, merge-gate, the four plugin suites, judge, agent-audience and risk tier are success; only enqueue-guard is failure, which is the expected no-PASS-yet state.
  8. Attestation is carried because the PR author login is the shared account this Reviewer writes as (Birfy) and the role Reviewer is not the Owner Dev.

@github-actions github-actions Bot added the pass label Nov 8, 2026
@Birfy
Birfy added this pull request to the merge queue Nov 8, 2026
Merged via the queue into main with commit 1913ebc Nov 8, 2026
18 of 20 checks passed
@Birfy
Birfy deleted the fix/2697-private-files branch November 8, 2026 16:37
@github-actions github-actions Bot removed the agent-pr Pull request opened by an agent label Nov 8, 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.

Host files take the ambient umask: team state, Bot memory, chat index and OCR cache are world-readable, and a rewrite widens a 0600

1 participant