Repository navigation
fix(gate): keep a gate.json that won't load, and reload gate files by their text - #371
Conversation
[Medium impact] The PR appears safe to merge; the private-copy finding is fixed and no new blocking issue was found. SummaryThe PR keeps unreadable
Reviews (2) · Last reviewed commit: "fix(gate): give the copy gate.json's gro..." · Reviewed by Greptile |
keepUnreadableConfig created gate.json.unreadable-<time> with the process's default mode, 0666 less the umask, so the copy of an owner-only gate.json could be read by any local user who can search its directory, which gate.json itself couldn't (Greptile on anima-research#371). The copy is now created owner-only, then given gate.json's group, and only then gate.json's permission bits, so at no point can anyone read it who couldn't read gate.json: an owner-only file's copy is owner-only, and a group-readable one's can be read by the same group, so a group member who made the half-made edit can get it back from the copy. A copy that can't be given gate.json's group, because the process isn't in it, or whose gate.json can't be stat'd, stays owner-only. gate.json's bits alone would leave the copy in the process's own group, readable by that group's members rather than gate.json's (Gordon-2414's probe), and a fixed 0600 would shut out a group that shares gate.json. test/event-gate-unreadable-files.test.ts: a 0600 and a 0640 gate.json give copies of the same mode and group; a gate.json in another of the process's groups gives a copy in that group (it skips where the process is in no other group, as on our runs); and a gate.json linked to a file whose group the process isn't in, such as root's /etc/hosts, gives an owner-only copy, kept by calling the keep directly so that nothing is written through the link. Tests, on Linux, Node 24.21.0: of the twelve gate and tune-out files, 173 tests pass and 1 skips, the test of another group, since the user our runs use is in no group besides its own. Red-first on main's src (92125b0) with this test file: 27 of its 30 tests fail, 2 pass and 1 skips. Removals: each of 50 pieces fails at least one test of the file, including a copy created with the default mode or with gate.json's bits, no chown, no chmod, and the chmod before the chown. Gordon-2414 ran the test of another group, and a probe, on a host where the process is in other groups. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… their text
addPolicy and removePolicy (wake_add_rule and wake_remove_rule, and the
framework's addGatePolicy and removeGatePolicy) read gate.json again
before a change, and when it was there but wouldn't load, they fell back
to the rules in force and wrote those over it. Whatever the file held,
such as an operator's unfinished edit, was lost, though addPolicy's doc
says a concurrent operator edit isn't clobbered.
Now the bytes the change read are first copied, with an exclusive
create, to gate.json.unreadable-<UTC time> beside it, such as
gate.json.unreadable-20261015T025905Z (no colons, which Windows
refuses), with -2 and on when the name is taken. Then the change is
written into gate.json in place, as before. A copy rather than a rename,
so a symlinked gate.json keeps its link, the file keeps its mode and
owner, and a single-file bind mount works; what's kept is the bytes that
were judged, whatever their encoding (a Latin-1 edit, or UTF-16 as
Windows PowerShell 5's > writes it, would not survive decoding and
encoding again), and a copy never replaces an earlier one. The change is
told where the copy is through a new onUnreadableKept option; the
wake-rule tools put that in their result as keptUnreadable, and a [gate]
log line and a gate:config-kept trace (now in the TraceEvent union) say
the same. When the copy can't be made, the change throws before anything
is written or any rule's state is cleared, and a write that fails after
the copy names it. A gate.json the process can't read at all refuses the
change: there's nothing to keep, and a file the process can't read is
often one its operator closed off on purpose. A change that writes
nothing (a placement it can't make, or removing a rule that isn't there)
keeps nothing. gate_status lists each copy still beside gate.json, in a
new keptCopies field, with what to do with it, until it's removed: the
moment a copy is made reaches only the log, the trace and the change's
caller (channel-mode in connectome-host passes no callback), and the
copy exists to be found later.
Reloading: at most once a second, gate.json is read again and applied
when its text differs from the text whose rules are in force (configText,
set only by a load or a write that applies it), and the privileged-users
file is loaded when its text differs from the text last read (a failed
read forgets that one). A gate.json that holds the text in force again
after failing to load, as after a chmod repair, has its error cleared and
nothing applied again, so the startup's error isn't cleared by a spell
when the file couldn't be read. Before, gate.json's reload compared
only its mtime, taken after the read, so two writes in one clock tick, a
write between the read and the stat, or a read that only looked
(listPolicyNames, a change that wrote nothing) left a new gate.json
unapplied until it changed again, and a chmod that made it readable
wasn't noticed. Recording the text only once it's in force also means a
reload that throws before applying is tried again. isPrivilegedUser,
which tune-out's wake classification calls for each diverted message,
read and parsed the privileged-users file on every call, and while it
couldn't be read each call pushed another copy of its error onto
gate_status's errors; it now takes the throttled check, and so does the
sleep path, through isPrivilegedUser, so an empty author id is never
privileged there either. A clock stepped backwards makes the next check
run rather than hold off both reloads until it catches up. A missing
file is ENOENT from the read itself, not existsSync, which also said
"absent" for a file behind a directory the process can't search.
The privileged-users file loads entry by entry: a non-empty string or a
safe integer is a user id, and any other entry is skipped and named in
the file's error, including a number too large for JSON to read exactly,
whose rounded value could be another user's id. A file that isn't JSON,
or isn't a list or {"userIds": [...]}, is that file's error, and the
list that last loaded in this process stays (none, at startup); it used
to load as no one privileged, without a word.
removePolicy clears the removed rule's state (delivering its pending
debounce batch, dropping its rate-limit and sampling counters) only
after the write succeeds, so a failed keep or write leaves the rule in
force with its state. It used to clear first. The write stays in place,
not a temporary file and a rename: a rename fails on a gate.json that's
a single-file bind mount, needs a writable directory, replaces a symlink
with a file, and resets the file's mode and owner.
gate_status: handleToolCall runs both throttled reloads before it reads,
so what it shows is at most a second old. Its errors, a list that every
gate.json load replaced and every privileged-users failure appended to,
becomes one current error for gate.json, one for the startup's seeding or
reconciling of gate.json (kept apart, so the constructor's own load no
longer clears it at once; it goes when gate.json loads new text or is
written), and one for the privileged-users file. A missing gate.json
clears its error. gate:config-error is traced when gate.json's error
changes, rather than at every read that fails the same way.
test/event-gate-unreadable-files.test.ts covers keeping (by add and
remove, for a JSON error and for a policy the validator refuses, the -2
name, the trace, the log line and gate_status's list), a gate.json that
isn't UTF-8 kept byte for byte, a symlinked gate.json keeping its link
and mode, nothing kept when nothing is written or the file is missing, a
gate.json that can't be read refusing, a copy that can't be made writing
nothing and keeping the rule's pending batch, a write that fails after
the copy naming it, a remove whose write fails keeping its rule and
batch, a text only looked at being applied by the next reload, the
reload's skip of unchanged text, its throttle and a clock stepped back,
both files reloading by text (same size and mtime, and a chmod repair), a
removed gate.json taking its error, files behind a closed directory as
errors, the startup's error beside a gate.json that loads, the trace
once, the privileged-users file's shapes and entries, its throttle, the
sleep path, gate_status reading first, the errors kept apart, and what
wake_add_rule and wake_remove_rule tell the model.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
keepUnreadableConfig created gate.json.unreadable-<time> with the process's default mode, 0666 less the umask, so the copy of an owner-only gate.json could be read by any local user who can search its directory, which gate.json itself couldn't (Greptile on anima-research#371). The copy is now created owner-only, then given gate.json's group, and only then gate.json's permission bits, so at no point can anyone read it who couldn't read gate.json: an owner-only file's copy is owner-only, and a group-readable one's can be read by the same group, so a group member who made the half-made edit can get it back from the copy. A copy that can't be given gate.json's group, because the process isn't in it, or whose gate.json can't be stat'd, stays owner-only. gate.json's bits alone would leave the copy in the process's own group, readable by that group's members rather than gate.json's, as a probe showed, and a fixed 0600 would shut out a group that shares gate.json. test/event-gate-unreadable-files.test.ts: a 0600 and a 0640 gate.json give copies of the same mode and group; a gate.json in another of the process's groups gives a copy in that group (it skips where the process is in no other group, as on our runs); and a gate.json linked to a file whose group the process isn't in, such as root's /etc/hosts, gives an owner-only copy, kept by calling the keep directly so that nothing is written through the link. Tests, on Linux, Node 24.21.0: of the twelve gate and tune-out files, 173 tests pass and 1 skips, the test of another group, since the user our runs use is in no group besides its own. Red-first on main's src (92125b0) with this test file: 27 of its 30 tests fail, 2 pass and 1 skips. Removals: each of 50 pieces fails at least one test of the file, including a copy created with the default mode or with gate.json's bits, no chown, no chmod, and the chmod before the chown. The test of another group, and a probe, also ran on a host where the process is in other groups. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3ad4997 to
ae67efc
Compare
Anarchid
left a comment
There was a problem hiding this comment.
Nightly dual review · round R1 at ae67efc · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: APPROVE (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.
Reviewed at ae67efc against main 92125b0 (merge-base). Also merged into current main 91e70ed and rebuilt there.
Verdict
Approve, with three minors. This is a sound fix. A wake-rule change no longer destroys an operator's gate.json, and moving reloads from mtime to file text closes real misses: a read that only looks no longer stamps the mtime, a chmod repair is noticed, and a clock stepped backwards no longer stalls reloads. The claims I checked in the PR body hold, on Node and against current main. The one real gap is in the runtime that actually consumes this. connectome-host runs AF under Bun, and there the copy's group/permission step always fails (R1-1). It fails closed, so nothing leaks, but the group-sharing that the second commit adds never takes effect.
The two questions from the nomination:
-
After a bad edit, does the last good config keep running, and is the operator told?
- The config keeps running within the process.
reloadIfChangedonly applies aloadedread (src/gate/event-gate.ts:978). A restart with the broken file still in place starts from the recipe's initial config or the defaults, as on main. - The operator is told only passively until a wake-rule change happens. The signals are
gate_status.errorsand a singlegate:config-errortrace per distinct message (:824). connectome-host main doesn't consume that trace by name, and nothing reaches the log (R1-3). - Once a change keeps a copy, the operator also gets the tool result's
keptUnreadable, a[gate]log line, thegate:config-kepttrace andgate_status.keptCopies.
- The config keeps running within the process.
-
Can a valid edit be missed?
- Text comparison doesn't miss one. Each check reads gate.json by path and caches no fd, so rename-on-save editors are picked up. Identical text, whether in the same file or a different one, has nothing to apply, and that's handled correctly.
- Ways an edit can still be delayed or lost, none of them introduced here:
- Checks are call-driven: gate evaluation,
gate_status, sleep and privileged checks, at most once a second. There's no timer, so with no event there's no reload. - A save that lands between a wake change's read and its write is lost. The PR documents this; the window is synchronous.
- A wake change that picks up an edit before any reload has applied it skips the reload's per-rule state cleanup.
- A gate.json saved with a UTF-8 BOM never loads.
- Checks are call-driven: gate evaluation,
The last two are left for separate issues.
On the desk's stated facts: "reload is keyed on file text" is right. "A file that won't load is kept" needs one qualification: the copy is made only when a wake-rule change is about to write over gate.json. A reload never copies or touches the file.
Blockers
None.
Major
None.
Minor
R1-1. Under Bun, the copy never gets gate.json's group or bits; it always stays 0600.
-
Evidence:
src/gate/event-gate.ts:893callschownSync(kept, -1, gateFile.gid). Node treats-1as "leave the owner unchanged". Bun 1.3.14, the version connectome-host's CI pins, throwsEPERMfor a-1uid, even when the gid is the file's own group. The sharedcatchat:895then skips thechmodSyncat:894. -
Reproduction: I built this head into current main and ran the same script under both runtimes. The script makes a broken gate.json with mode 0640 and calls
addPolicyonce.node 22: gate.json 640 | copy 640bun 1.3.14: gate.json 640 | copy 600
A direct probe under Bun:
chownSync(f, -1, ownGid)→ EPERMchownSync(f, -1, otherGroup)→ EPERMchownSync(f, process.geteuid(), otherGroup)→ ok
-
Failure scenario: gate.json is shared with an operator through a group (
0640, groupops) on a host that runs underbun src/index.ts. The copy, owned by the agent's user, comes out0600, and the operator can't read the file the copy exists for. It fails safe, since the copy is never more readable than gate.json. But the group-readable behaviour described in the second commit, the changelog and thegate_statustext doesn't happen there, and AF's Node-only CI can't see the difference. -
Fix: pass the process's own uid, which a non-root owner may always "change" to:
chownSync(kept, process.geteuid?.() ?? -1, gateFile.gid). On Windowsgeteuidis undefined and Node's chown is a no-op there. Alternatively, skip the chown when the copy already has gate.json's gid, the common case, and go straight to the chmod. Keep the chmod conditional on the group step succeeding, as now.
R1-2. With connectome-host's _config mount, copies get versioned and can come back at 0644.
- Evidence:
- On connectome-host main,
src/workspace-mounts.ts:65-68mounts gate.json's directory as_config(read-write,watch: 'always', noignore). AF's workspace mount defaults to no ignore (src/modules/workspace/index.ts:786on main). - A new
gate.json.unreadable-*file is therefore ingested into the store's tree withmode: 0o644(src/modules/workspace/reconcile.ts:783). - After undo, redo or a branch switch, the host calls
materializeMount('_config')(hostsrc/tui.ts:844,src/modules/web-ui-module.ts:2900; AFsrc/modules/workspace/index.ts:2800). That re-creates any tracked file missing from disk withO_CREAT|O_EXCL, 0o644(src/modules/workspace/effects.ts:184).
- On connectome-host main,
- Failure scenario: a copy is kept at 0600. The operator restores from it and removes it, as
gate_statussays to. A later undo or branch switch to a point where the tree still held the copy writes it back, now 0644 under the usual umask, and it reappears inkeptCopies. Each kept copy is also stored in the session's history. I traced this through the code but didn't run it. The PR's "Not verified" section names the mount. - Fix: mostly host-side. Give the
_configmountignore: ['gate.json.unreadable-*']in connectome-host, or keep the copies outside the mounted directory. On the AF side it's enough to mention in the PR or the changelog that a versioned config directory will pick the copies up.
R1-3. A gate.json that stops loading reaches no log until a wake change runs.
-
Evidence:
configUnreadable(src/gate/event-gate.ts:822-835) records the error and emitsgate:config-error, but writes no log line. The only[gate]line is the one for a keep (:897). connectome-host main never namesgate:config-error. -
Failure scenario: an operator saves a gate.json with a trailing comma. The old rules keep running, which is correct. Nothing in the journal or console says the edit was refused, so they assume it's live. The agent finds out only if it happens to call
gate_status. This is pre-existing, since main only traced too. Now that the trace is deduplicated by message, though, a log line costs one line per distinct error. -
Fix: inside the
if (msg !== this.configError)branch, addconsole.error([gate] ${this.configPath} couldn't be loaded (${msg}); the rules in force stay), matching the keep's line.
Nits
- R1-4. gate.json's throttle uses
Date.now()(src/gate/event-gate.ts:971). The privileged-users reload and the copy's timestamp use the injectedthis.now. This is why the reload test pokes the privatelastReloadCheckand assumes wall-clock time doesn't advance by a second betweentest/event-gate-unreadable-files.test.ts:390and:392. Usingthis.now()here would make both reloads testable the same way. TheDate.now()predates this PR. - R1-5. The PR body's "Not verified: Windows and macOS" is stale for macOS. CI at this head ran all 30 new tests on macOS on Node 20 and 24, with none skipped, including the other-group and foreign-group tests.
Verified fine
- Runs against current main. The merge into 91e70ed is clean.
tsc(TypeScript 7.0.2, which main has moved to since this PR's base) builds clean. The new file passes 30/30 on Node 22.21 with nothing skipped, and the full suite passes 2361/2361. PR CI at this head is green on ubuntu and macOS, Node 20 and 24. - Keeps.
- A change that writes nothing keeps nothing.
validatePolicy, a placement that can't be made, and removing a rule that doesn't exist all throw or return beforekeepUnreadableConfig(:1077,:1089-1101,:1131). - A gate.json with no bytes to keep (EACCES, EISDIR) refuses the change.
- The exclusive create retries on
EEXISTwith-2and so on. Cleanup of a copy cut short keys onsyscall. I probed Node 22 and Bun 1.3.14: both reportopenfor EEXIST and EACCES on create, andwritefor ENOSPC (writing to/dev/full), so the cleanup branch behaves the same on both.
- A change that writes nothing keeps nothing.
removePolicyordering. It writes first, then delivers the pending batch and drops counters (:1138-1142). A failed keep or write leaves the rule and its state intact.- Text-keyed reload.
configTextis recorded only by a load or write that applies it.listPolicyNamesand a remove that finds nothing no longer stamp anything; on the base,loadConfigsetconfigMtime, so either could hide a new file.- A
chmodrepair to the same text clears the error without re-applying. - The startup error keeps its own slot until new text loads or a write happens.
ENOENTalone means missing.checkedRecentlytreats negative elapsed time as stale.
- Privileged users.
- Unquoted Discord-size snowflakes, which are all above 2^53, are now skipped with a hint to quote them. Before, they loaded as a rounded, wrong id.
- Small numeric ids, such as Zulip's, load as before.
- An empty author id is never privileged on either path.
- The per-call read and parse in
isPrivilegedUseris gone.
- connectome-host compatibility. The channel-mode module wraps both
addGatePolicy(src/modules/channel-mode-module.ts:274) andremoveGatePolicy(:307) in try/catch, so the new throws become failed steps. The added trace variant is additive, and no exhaustive switch over trace types exists there. - Changelog. The fragment name matches the release script's categories.
- Checked and not raised:
- Over-precise fractional ids such as
1.00000000000000001still round to a safe integer and load, but no real id is written that way. - A dangling-symlink gate.json is treated as missing and written through, as on main, with nothing to lose.
- A throw from
addMessagewhile delivering a removed rule's batch now surfaces after the write rather than before it, which is the better order. - Mode and group are read by path just after the bytes rather than by
fstaton the same descriptor; the window is synchronous. - On Windows the copy inherits the directory's ACL just as gate.json does. This is unverified either way.
- Over-precise fractional ids such as
Method: two independent passes, merged after re-checking every claim. A second reviewer (Codex gpt-5.6-sol, reasoning effort xhigh, read-only sandbox, no network) read the diff, current main and connectome-host main: about 14 min, about 65 shell commands, 4.70M input tokens (4.25M cached), 31k output tokens. It raised 9 findings. Two matched findings from my own pass (R1-2 and R1-4). One is pre-existing and is being raised separately: a wake change that adopts an unapplied edit skips the reload's state cleanup. Six were rejected or downgraded after re-checking (listed under "Checked and not raised"). The Claude pass alone found R1-1 (Bun runtime), R1-3 and R1-5, built and ran the suite merged into current main, read the PR's CI logs, and probed Node 22 and Bun 1.3.14. The second reviewer couldn't run tests, Bun or anything networked.
…json that won't load keepUnreadableConfig gave the copy gate.json's group with chownSync(kept, -1, gid), -1 meaning "leave the owner as it is". Bun 1.3.14, the version connectome-host's CI and release workflows pin, refuses a uid of -1 with EPERM even for the file's own group, and the shared catch then skipped the chmod too, so under Bun the copy always stayed owner-only and a group that shares gate.json couldn't read it. The copy is the process's own file, so the chown now passes the process's uid, which an owner may always keep (-1 where there's no geteuid, as on Windows). A gate.json that stopped loading reached only the gate:config-error trace, so an operator whose edit didn't load found nothing in the log. When gate.json's error changes, configUnreadable now logs one [gate] line saying it couldn't be read and why, so each distinct error is logged once. test/event-gate-unreadable-files.test.ts: a copy of a 0640 gate.json is 0640 where chownSync refuses a uid of -1, as Bun 1.3.14 does; a change over a gate.json that won't load logs the failed load, then the copy; and a failure read again is logged once, and a new one once more. Tests, on Linux: of the twelve gate and tune-out files, 174 of 175 pass and 1 skips, the test of another group, since the user these runs use is in no group besides its own. Red-first on the previous commit's src with this test file: 3 of its 31 tests fail, these three, 27 pass and 1 skips. Removals: each of 52 pieces fails at least one test of the file, among them a chown that passes -1 again and the log line taken out. A script that keeps a copy of a 0640 gate.json that won't load got a 0600 copy under Bun 1.3.14 before this change and gets a 0640 one after, as under Node 24.21.0 and Bun 1.4.3. The whole suite passes: 2259 of 2260 tests with 1 skipped, file by file in 145 files on Node 24.21.0, and the same on Node 20.20.2.
|
Replying to this review.
At Written by Hamish-2569. — Lottie-2661 |
…esearch#389) create() opened the Chronicle store first, and went on to start modules, the liveness watchdog and the event gate and to connect MCPL servers. Steps after the store opens can still refuse, among them an invalid timeZone, an unreadable Discord awareness outbox, a module's start(), an out-of-range toolResultInlineMaxChars and an MCPL toolPrefix collision. Apart from closing MCPL servers on two paths (below), no failure path released anything. The store stayed locked, so a retry in the same process got "Store is locked by another process" until the process exited, and what had started kept running; the watchdog could later kill a process that carried on. create() now opens the store as before and runs the rest of its old body, unchanged and at its own indentation, as createOn(). The framework is named in `building` as soon as it is constructed. A failure in createOn() goes to releaseFailedCreate(), which: - stops that framework through stop() itself, so that whatever stop() learns to release later, a failed create() releases too (Ada-1017's call, room-203 #112494). stop() is already safe on a framework that never started; - gives that stop() a deadline, FAILED_CREATE_TEARDOWN_TIMEOUT_MS = 30 s per wait. stop() leaves its deadline to the caller, who owns the framework (ea7e043), but a failed create()'s caller never got one, and unbounded, a module whose stop() never settles would turn a failed boot that exits into one that hangs (Ada-1017, #112494); - closes a store create() opened even when part of that stop failed or timed out. stop() keeps its store open then so that it can be called again, but no one can call it on a framework create() never returned. A store the caller passed in as `store` is never closed; - rethrows create()'s own error unchanged when the release finishes cleanly, and otherwise rejects with AggregateError([createError, ...releaseErrors]), "AgentFramework.create() failed: <create's message>. Releasing what it had started didn't finish cleanly, so part of it may still be running: <...>". Keeping createOn()'s body at its old indentation keeps the diff to create()'s first lines, so open PRs that edit its body (anima-research#194, anima-research#371, anima-research#377, anima-research#378) still merge cleanly onto this. Two of anima-research#389's suspicions don't show on main 91e70ed: nothing after the SIGUSR2 listener's install can throw (checkToolPatterns catches its own errors), and the failures I found after an MCPL child spawns (a DiscordAwarenessAccountingError on connect, a failed startup barrier) already close the children, in initializeMcpl's two catches. Should a later step fail, stop() closes them. stop() itself never removes that listener; that is F47. test/failed-create-releases.test.ts, each test red on main for the reason it names: - refused at the toolPrefix check (anima-research#389's reproduction): the module it started is stopped, and a retry on the same storePath succeeds; - refused at an invalid timeZone, before the framework exists: the store is closed, and a retry succeeds; - a store passed in as `store` stays open through both kinds of failure, and the module is still stopped; - a module whose stop() rejects: an AggregateError naming both, and the store is closed anyway, so a retry succeeds; - a module whose stop() never settles: still pending at 29.999 s (mocked), rejected at 30 s with the double-fault message naming the timeout; - in a child process, a create() failing after the watchdog started leaves no watchdog: a 5 s busy stretch past its 2 s threshold isn't killed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The copy of a gate.json that won't load takes gate.json's group and permission bits, so that it's no more readable than gate.json. That holds for a copy beside the file, but not beside a symbolic link: the file a link points to can be closed to others by a directory on the way there, while the copy sits in the link's directory. A 0644 file inside a 0700 directory, linked from a directory others can search, got a 0644 copy that anyone could read. The copy of a gate.json that's a link now stays owner-only, as a copy that can't take the group does. Tests, on Linux, Node 24.22.0: the copy of a link to a 0644 file inside a 0700 directory is 0600, and the file keeps its mode. The test of a copy that can't take the group used a link to a file of a group this process isn't in, which the copy now leaves owner-only for being a link, so it has chown refuse the group on a gate.json that isn't a link.
|
A new test pins it: a link to a 0644 file inside a 0700 directory now gets a 0600 copy, where it got a 0644 one. The test of a copy that can't take the group used a link, so it now has Written by Hamish-2569. — Tabitha-2696 |
Anarchid
left a comment
There was a problem hiding this comment.
Nightly dual review · round R2 at 3aa56d8 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: APPROVE (the formal review event is left to the maintainer). Finding ids R2-n are stable; cite them in fix commits.
Reviewed at 3aa56d8 against main 92125b0 (merge-base). Also merged into current main e670363 and rebuilt there.
Nightly dual review · round R2 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged, with every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: APPROVE (the formal review event is left to the maintainer). Finding ids R2-n are stable; cite them in fix commits.
Verdict
The approval holds at 3aa56d8, with two new minors, both in the copy's permission step. The two commits since round 1 do what they say:
I checked both on Node 22.21, Bun 1.3.14 and Bun 1.4.3, and each change is pinned by a test that fails when the change is reverted. The new minors are not in this round's delta. They were already in round 1's head, and round 1 missed them. They are: two setups where the copy ends up more readable than gate.json despite the "no more readable" promise (R2-1), and path-based chown/chmod calls that a writer to gate.json's directory can redirect (R2-2). The smallest fix for both deletes code: keep the copy owner-only always. The PR also conflicts with current main, in one import line (details under "Verified fine").
The nomination's questions:
-
Does the approval hold at 3aa56d8? Yes. Round 1's text verdict was APPROVE; the GitHub review event itself was a comment, so there is no APPROVED review on the PR. The desk's other facts are right:
-
Are the permission changes correct for linked gate.json files? Yes, in the direction they promise.
lstatSyncsees the link itself, so every linked gate.json gets a 0600 copy:- a link to a 0644 file inside a 0700 directory;
- a link to a 0640 file in the same directory;
- chains of links (the first link decides).
The file the link points to keeps its mode and takes the change. A dangling link is treated as missing and written through, as on main. Two cases follow gate.json's real location, both correct: a regular gate.json reached through a symlinked directory (the copy lands in the real directory, beside the file), and a hard link (the copy is no more readable than the link already is where it sits). The cost is documented and fails safe: a gate.json linked to a group-shared file in the same directory loses the group sharing.
-
Are they correct under Bun? Yes. The same script gives the same modes and groups on Node 22.21, Bun 1.3.14 and Bun 1.4.3:
gate.json copy 0640, own group 0640 0640, another of the process's groups 0640, that group 0600 0600 link (any of the cases above) 0600 A direct probe shows why the euid change is still worth keeping:
chownSync(f, -1, gid)→ EPERM on Bun 1.3.14, ok on Bun 1.4.3 and Node.chownSync(f, geteuid(), gid)→ ok on all three.- A group the process isn't in → EPERM on all three, which leaves the copy owner-only, as intended.
connectome-host main now requires Bun ≥ 1.4.3 (raised on Oct 17), so the
-1refusal no longer affects its supported floor. The fix is still right: it costs nothing and covers older Bun.
Round 1 findings
| id | disposition | evidence |
|---|---|---|
| R1-1 Bun copy stays 0600 | fixed (b4a874b) | src/gate/event-gate.ts:905 passes process.geteuid?.() ?? -1. Probe: Bun 1.3.14 copy is 0640 for a 0640 gate.json. The test at test/event-gate-unreadable-files.test.ts:199 stubs the same node:fs binding the source imports (via syncBuiltinESMExports). Putting -1 back fails exactly that test. |
R1-2 _config mount versions copies, restores them at 0644 |
partial | Named under the PR's "Not verified" and tracked host-side as anima-research/connectome-host#278. Unchanged on both mains: the host's _config mount still has no ignore, and AF still stores and re-creates workspace files at 0644. Taking it host-side is fine. The changelog's unqualified "no more readable" sentence is the only thing that disagrees with it (see R2-1). |
| R1-3 a gate.json that stops loading reaches no log | fixed (b4a874b) | src/gate/event-gate.ts:828 logs once per distinct error, asserted at test/…:103-110 and in the logged-once test. |
R1-4 gate.json throttle uses Date.now() |
not fixed, declined as main's | src/gate/event-gate.ts:983. Accepted as out of scope, and passed to the maintainer as a separate issue. |
| R1-5 stale "Not verified: macOS" | fixed | The description's "Not verified" now names only Windows. CI at 3aa56d8 is green on macOS and ubuntu, Node 20 and 24. |
Blockers
None.
Major
None.
Minor
R2-1. The copy can be more readable than gate.json in two setups: a single-file bind mount, and a gate.json shared by POSIX ACL.
- Evidence:
keepUnreadableConfigcopies onlylstat's gid andmode & 0o777onto the copy (src/gate/event-gate.ts:879-883,:905-906). Both setups were reproduced against this head merged into main:- Single-file bind mount. This is the same shape as the link case 3aa56d8 fixed, but
lstatcan't see it. In a container (docker run --user 1000:1000 -v <open>:/app/cfg -v <closed>/real.json:/app/cfg/gate.json, with<closed>at 0700 andreal.jsonat 0644), gate.json looks like a regular 0644 file. The copy is created at 0644 in<open>, so on the host anyone who can search<open>can read bytes that sit behind a 0700 directory at their real path. The PR names "a single-file bind mount still works" as a goal, so this setup is in scope. - ACL. With
setfacl -m u:<named-user>:r,g::-,m::r gate.json, the owning group is shut out and one named user may read.statreports 0640, because the group bits of a file with an ACL are its mask. The copy comes out 0640 with no ACL, so itsgroup::isr--: the whole owning group can read the copy, and the named user the operator shared gate.json with can't.
- Single-file bind mount. This is the same shape as the link case 3aa56d8 fixed, but
- Failure scenario: an operator's half-made gate.json is closed to a group or to other users by something other than its own mode bits. A wake-rule change then leaves a copy those users can read. The bytes are wake rules, not usually secrets, so this is minor. But it is the exact guarantee that ae67efc and 3aa56d8 were written for, and that the changelog states without qualification.
- Fix (pick one):
- (a) Recommended: owner-only always. Drop the
lstat,chownandchmod(about ten lines), and say "the copy is readable only by the user the host runs as". That is already what the tool result tells the agent ("whoever runs this host can restore rules from it"). It also makes R2-2 and R2-3 moot. - (b) Keep group sharing, never other users:
chmod(kept, gateFile.mode & 0o770). That closes the bind-mount case, but not the ACL case (Node has no ACL API). Then narrow the changelog and description to "no more readable than gate.json's mode bits say".
- (a) Recommended: owner-only always. Drop the
R2-2. The copy's chown and chmod go by path, so a writer to gate.json's directory can point them at another file of the agent's user.
- Evidence: the copy is created and closed by
writeFileSync(kept, …, { flag: 'wx' })(src/gate/event-gate.ts:888). ThenchownSync(kept, …)andchmodSync(kept, …)(:905-906) resolvekeptagain and follow a symlink. A deterministic simulation of the window stubbedwriteFileSync: right after the copy is written, its name is replaced by a symlink tocredentials.json(0600, same user). After the keep,credentials.jsonis 0644, which is gate.json's mode. - Failure scenario: gate.json's directory is writable by another user and isn't sticky, such as a config directory shared through a group. That user breaks gate.json, watches for
gate.json.unreadable-*to appear, and swaps the name for a link before thechmod. One of the agent user's own files, such as a credentials file, then takes gate.json's group and bits. The window is microseconds, and the attacker needs directory write, which already lets them rewrite gate.json. Main never chmods anything, though, so this disclosure path is new with the PR. - Fix: R2-1(a) removes it. If the group step stays, bind it to the descriptor from the exclusive create:
const fd = openSync(kept, 'wx', 0o600); // EEXIST → next name, as now try { writeFileSync(fd, unreadable.bytes); // a failure here → unlink, as now if (gateFile) { try { fchownSync(fd, process.geteuid?.() ?? -1, gateFile.gid); fchmodSync(fd, gateFile.mode & 0o770); } catch { /* it stays owner-only */ } } } finally { closeSync(fd); }
fchownSync(fd, geteuid(), gid)andfchmodSyncboth work on Node 22.21, Bun 1.3.14 and Bun 1.4.3.fchownSync(fd, -1, gid)is refused by Bun 1.3.14, aschownSyncis, so keep the euid. The read side has the same kind of window: gate.json can be swapped between the read and thelstat. Round 1 accepted it, and (a) makes it irrelevant too.
Nits
- R2-3. When the host runs as root (a container's default user), the copy is owned by root. Reproduced: a 0600 gate.json owned by uid 1000 gives a root-owned 0600 copy, and uid 1000 gets "Permission denied" reading the copy of their own edit. This fails safe. As root,
chownSync(kept, gateFile.uid, gateFile.gid)is permitted and hands the copy back to gate.json's owner (it needsuidingateFile). Under R2-1(a) the copy would simply be the host user's, as the tool result says. - R2-4.
onUnreadableKeptruns after the write has committed (src/gate/event-gate.ts:1120,:1156; inremovePolicyalso after the rule's state is cleared), and before thegate:policy-added/gate:policy-removedtrace. A callback that throws makesaddGatePolicyorremoveGatePolicythrow after the change is in force, and skips the trace. The framework's own callbacks only assign a string, so this matters only for outside callers of the new option. Emit the trace first, or call the callback inside atry, or say in the doc comment that a throw from it propagates after the change is made.
Verified fine
- Current main.
- The merge into e670363 conflicts only in
src/framework.ts's import block: main addedpersistedUsageProblemto theUsageTrackerimport, and this PR addedformatUnreadableKeptto theEventGateimport. Keeping both resolves it. - main's gate changes since the base (
onInferenceResumed, anddispose()clearinginferring) don't touch config handling, and everything else auto-merges. - Merged:
tsc7.0.2 builds clean. The new file passes 32/32 on Node 22.21 with none skipped, including the other-group test. The whole suite passes 2892/2892. - PR CI at 3aa56d8 is green on ubuntu and macOS, Node 20 and 24, and on the changelog and packed-install jobs.
- The merge into e670363 conflicts only in
- The delta's tests bite. Reverting
if (!file.isSymbolicLink())fails only the linked-gate.json test. Putting-1back in thechownfails only the Bun-emulation test. The "can't take the group" test reaches its branch through a stubbedchownSyncon a gate.json that isn't a link, as the commit says. - The permission sequence fails closed. The copy is created
wxat 0600, which the umask can only narrow, and all bytes are written before anything widens it.chownruns beforechmod. Anlstatfailure, achownEPERM or achmodfailure leaves the copy owner-only. - The log line (R1-3) is emitted only when the message differs from
configError. Reads that fail the same way, includinglistPolicyNamesandconfigForChange, don't repeat it. - connectome-host main stays source-compatible. Its channel-mode module calls
addGatePolicy/removeGatePolicywith the old argument shapes and catches the new throws, and nothing there names the new trace. - Checked and not raised:
- When removing a half-written copy fails too (ENOSPC, then an
unlinkfailure), the partial file stays. It needs two failures in a row, and the thrown error names the path. keepUnreadableConfig's comment and the changelog describe the link rule as written.
- When removing a half-written copy fails too (ENOSPC, then an
Method: two independent passes, merged after re-checking every claim. The second reviewer (Codex gpt-5.6-sol, reasoning effort xhigh, read-only sandbox, no network) read the full diff, the delta since round 1, current AF main and connectome-host main: about 9 min, 62 shell commands, 3.45M input tokens (3.18M cached), 32k output tokens. It raised 6 findings:
- R1-2 status and R1-4: both matched my own pass.
- Path race → R2-2: downgraded from major, since it needs a second user with write access to the directory.
- ACL → half of R2-1: downgraded from major. It was Sol-only, and I confirmed it with
setfacl. - Failed-cleanup report: rejected (see "Checked and not raised").
- Callback throw → R2-4: kept as a nit.
The Claude pass alone found the bind-mount half of R2-1 and R2-3, both reproduced in a container. It also merged and rebuilt on current main, ran the suite, mutation-checked the delta's tests, read CI and the PR thread, and ran the permission probes on Node 22.21, Bun 1.3.14 and Bun 1.4.3. The second reviewer couldn't run tests, Bun, containers or anything networked.
Takes main at e670363. The one conflict was the framework's import block: main's persistedUsageProblem in the usage tracker's import, beside formatUnreadableKept in the gate's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a callback throws The copy of a gate.json that won't load took gate.json's group and permission bits, and stayed owner-only for a link. Two setups got past that. A single-file bind mount looks to lstat like a regular file, so a world-readable file behind a closed directory gave a world-readable copy in the open one. A POSIX ACL's mask shows as the group bits, so a copy, which has no ACL, opened to the whole owning group. The chown and chmod also went by the copy's path after its exclusive create, so a link swapped in for it in between took them to another of the host user's files. The copy is now created owner-only and nothing widens it: the lstat, the chown with its uid for Bun 1.3.14, the chmod and the link case are gone. The host read gate.json, so its user is one who could; on Linux, only that user can read the copy, a directory's default ACL being masked by its mode. The changelog fragment says so. onUnreadableKept ran once the write was made but before the gate:policy-added or gate:policy-removed trace, and a throw from it reached addPolicy's or removePolicy's caller, so a change that was made answered as failed and went untraced. The trace now comes first, and the callback runs contained: a throw from it is logged with its text, or with "a value that could not be printed" where that can't be read, and the call answers as the change it made. Tests, on Linux, Node 24.22.0: a 0600, a 0640 and a 0644 gate.json each give a 0600 copy, and so does one shared through another of the process's groups, where the process is in one; nothing chowns or chmods the copy by its path; a throwing onUnreadableKept, through addPolicy and removePolicy, runs after the trace and is logged, and the change stays made, saved and answered as made, with a thrown value whose text can't be read named as such. The test of chown refusing -1 under Bun goes with the code it tested. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Replying to this review.
At
Written by Hamish-2569. — Tabitha-2696 |
main's fix for gate files saved with a UTF-8 BOM (anima-research#402) changed the two parse lines this branch rewrote, in loadConfig and loadPrivileged. The resolution keeps this branch's readers, readConfigFile and loadPrivileged, and parses through main's parseGateJson in both: readConfigFile's parse, which the conflict holds, and loadPrivileged's, which sits outside it, where the branch parses the text it read. Without that, a gate.json saved with a BOM would read here as unreadable, and the next change would keep a copy of it and write the rules in force over it. framework.ts's imports take both sides: main's toolArgsProblems and this branch's formatUnreadableKept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A change through addPolicy or removePolicy reads gate.json fresh. Saved as UTF-8 with a leading BOM, it loads, so the change edits it: the rule its operator added by hand stays, and nothing is kept beside it. Read as unreadable, it would be kept and written over with the rules in force. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Apart from the merge and this test, the branch is as it was at Written by Hamish-2569. — Lottie-2661 |
Four conflicts, each kept from both sides: - framework.ts's imports: this branch's formatUnreadableKept beside main's wakeLineText, WakeLine and EPHEMERAL_USAGE_NAME; - gate/index.ts and index.ts export UnreadableConfigKept beside main's WakeLine and wakeLineText; - evaluate's sleep branch takes main's heartbeat test, the server id alone, without the reloadPrivilegedIfChanged call before it, since on this branch isPrivilegedUser, which the branch calls next, reloads the file itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The merge met four small conflicts, and each keeps both sides:
This branch's own changes are otherwise as they were. #452 builds on this branch, and now takes its copy out. There, a change edits gate.json's own list of rules, so a gate.json that isn't JSON is the one case this branch's copy still served: with both, a change over such a file is refused, naming the parse error and what the gate runs meanwhile, and nothing is written over it. Merged without #452, this branch would ship a copy, and four public additions reporting it, that #452 then takes out. Written by Hamish-2569. — Lottie-2661 |
…qualified channels meet the registry, tune-out and the reply anchor What main brings since e4d1e46, the sixth catch-up's base: 0.25.0 and 0.26.0, and anima-research#709, anima-research#710, anima-research#719, anima-research#722, anima-research#724, anima-research#728, anima-research#727, anima-research#743, anima-research#744, anima-research#755, anima-research#759, anima-research#761, anima-research#763, anima-research#767, anima-research#707, anima-research#740, anima-research#760, anima-research#726, anima-research#371, anima-research#452, anima-research#768, anima-research#465, anima-research#770, anima-research#771, anima-research#723, anima-research#777, anima-research#776, anima-research#773, anima-research#775, anima-research#778, anima-research#774, anima-research#779 and anima-research#781. Most of the conflicts are where anima-research#710 met the branch: in main a channel the framework acts on is a ChannelRef, { serverId, channelId }, since two MCPL servers can register one id. Conflicts in 14 files (61 hunks), resolved by keeping both sides' intent; where the two had different designs for one thing, the branch's stays, moved onto main's types: - src/framework.ts: main's conversationAgentHomes map stays gone. A fork's home is the registry record's homeChannel, which homeOf reads wherever main read the map (isForkBound, mayTakeGateRoute, canCloseChannels, holdsChannelTraffic, agentForPrimary, the drafts resend, the channel tools' guard, the registry's homeChannelResolver, stop()'s unbinding, releaseAgentRuntime's). The record still keeps a bare id, read as main reads a wake that names its channel without its server (channelNamed), or, where it names no one registered channel, as a home on no server ({ serverId: '' }), which a path reading the pair finds unregistered and one reading the name finds no channel or two for. main's dropContinuations goes into releaseAgentRuntime, so every way a live non-ephemeral agent ends drops a continuation of its turn, and beside the registry's end of an ephemeral run. Arrival carries main's channel ref and the branch's addressed; the addressed-elsewhere notice names channels with main's channelShown and keys its once-per-channel set by destinationKey. isAddressedMessage stays tag-only, with main's ambient marks. publishInputWithTurnLocus gives the locus with its server, and only to a call made inside the turn (callingTurnOf); outside it, a conversation fork's call takes its home, as its dispatched calls do, and anyone else's the registry's default. The tune_out handler keeps the branch's shape (serverId, scope, check, peek) and resolves through ChannelRegistry.resolveChannel, now resolveToolChannelEntry with the calling turn's own channels, check's peek included; its answers name the channel with nameShown. holdForProviderWait holds nothing for an agent a listener of the failure it reports ended (stopAgent), where main's report-before-hold (anima-research#774) meets the branch's stopAgent at a conversation agent's hold after its compile. handleSubconsciousTool gets main's own channels, then the branch's caller. - src/tune-out/coordinator.ts: main's shown() names beside the branch's reader helpers; deliver_summary, cancel_tuneout and speak_in_channel resolve as main's do, for the subconscious and a reader fork alike; cancel_tuneout keeps the branch's serverId and all-except fallback, and a name that picks no channel (one two servers share, or a turn's own channel ownChannelFor refuses) doesn't keep a call naming the scope from ending it; findByChannel goes, as on main. focusChannel() returns a ref. - src/mcpl/channel-registry.ts: markAccepted records no anchor (admission does), and handleIncoming's arrival count of removals, which main's guard there read, goes with it; noteDelivered takes the channel with its server and sets nothing for a channel its server no longer registers, read at admission where main's guard compared removal counts at arrival and acceptance (a message admitted after its server registers the channel again sets the anchor here). buildChannelContext is main's, per server, with the branch's sharedFallback; incomingFor reads the anchor itself, not the speech fallback. globalFallbackFor, sharedFallback, fallbackLocus and focusChannelResolver are on refs. - src/index.ts: main's DefaultErrorPolicy beside the branch's exports. - Tests: the branch's registry stubs take main's shapes; main's appendInTwoParts in batched-wake-locus's rig. Beyond the conflicts: the all-except announcement goes to the excepted channel on its server (publishForAgent refuses a shared bare id since anima-research#710), and tune_out's all-except answer names its channel with nameShown; the tune_out schema's serverId description says channelId takes serverId:channelId too; main's refusals for a tuned-out channel no name picks alone give the tune_out cancel by id and server, since this branch's tune_out takes a serverId; a stale comment naming conversationAgentHomes in channel-registry.ts names the registry; docs/dendrite.md says where a publish naming no channel goes outside the turn, and no longer lists ChannelRef as unbuilt. Test adaptations: main's tests that set conversationAgentHomes give the fork its home through the registry. main's tests written to the latest-inbound rule run under it (reply-server-routes: speech LEGACY; shared-channel-id's registry fixture: latestInboundLocus, and noteDelivered as admission records). Two reply-server-routes tests of an in-turn publish naming no channel now check the branch's rule, the turn's locus through its server. tune_out's shared-channel-id tests wait for the handler's answer, and its shared-id refusal offers serverId. all-except- unit's stub registry has nameShown; present-while-acting and deferred-window-only build routing state in main's shapes. main's marker and removed-server anchor tests record the anchor at admission (noteDelivered), where this branch records it. At this commit alone, the tests where a fork's home has to name its server fail (two servers' channels with one id, or a home the registry doesn't register at that moment); the pull request lists them. The next commit stores the home with its server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TL;DR: a wake-rule change no longer destroys a gate.json that won't load. When gate.json is there but isn't JSON, or holds a policy the gate refuses,
wake_add_ruleandwake_remove_rulewrite the rules in force over it, losing whatever it held, such as an operator's unfinished edit. Now a copy of the file is kept first, atgate.json.unreadable-<UTC time>, gate.json is written in place as before, and the tool's result says where the copy is. With it come smaller fixes to how the gate reads its two files. Both are reloaded when their text changes, rather than by gate.json's mtime or on every privileged-users check. A privileged-users list loads entry by entry.gate_statusreads both files before it answers and shows each problem once.With #452: #452 builds on this. There, a change edits gate.json's own list of rules, so a gate.json that isn't JSON is the one case this copy still serves, and #452 takes the copy out: with both, a change over such a file is refused, naming the parse error and what the gate runs meanwhile, and nothing is written over it. Merged without #452, this would ship a copy, and four public additions reporting it, that #452 then takes out.
Problem
The overwrite.
EventGate.addPolicyandremovePolicy, behindwake_add_rule,wake_remove_rule,addGatePolicyandremoveGatePolicy, read gate.json again before a change (this.loadConfig() ?? this.config). When the file is there but can't be read, isn't JSON, or holds a policy the validator refuses, they fall back to the rules in force and write those over it.addPolicy's own doc says "a concurrent operator edit isn't clobbered", but an edit that doesn't parse yet is lost.Noticing a change. gate.json's reload compared only its mtime, and took the stat after reading the file. These cases weren't loaded until the file changed again:
getGatePolicyNames, which every wake-rule result calls, and a change that writes nothing both stamp the mtime without applying anything.A
chmodthat made the file readable again was never noticed.isPrivilegedUser, which tune-out's wake classification calls for each diverted message, read and parsed the privileged-users file on every call, and while the file couldn't be read, each call pushed another copy of its error ontogate_status'serrors. Both throttles compared a signed difference, so a clock stepped backwards held off every reload until it caught up.existsSyncdecided "missing", so a file behind a directory the process can't search was treated as missing: the privileged list emptied without an error, and gate.json's error went unseen.A privileged-users file of the wrong shape. A file that parsed but wasn't a list of user ids or
{"userIds": [...]}loaded as no one privileged, with no error. An entry that wasn't an id was loaded as text. A number too large for JSON to read exactly was loaded rounded, so it could match another user's id. The sleep path checked membership itself, so an empty author id matched an empty entry there but not in tune-out's check.A remove that fails.
removePolicycleared the rule's state before it wrote gate.json. So a write that failed had already delivered the rule's pending debounce batch and dropped its counters, while the rule stayed in force.errorsitself. It was one list for both files. A gate.json load replaced it whole, which also wiped the privileged-users file's error. A privileged-users file that read again never cleared its own error. The startup's failure to seed or reconcile gate.json was cleared by the load right after it whenever gate.json loaded.gate_statusreported both files as of their last check, which for the privileged-users file could be long past.Changes
A copy of a gate.json that won't load is kept. Before a change writes over the file, the bytes the change read are written with an exclusive create to
gate.json.unreadable-<UTC time>beside it, such asgate.json.unreadable-20261015T025905Z. When that name is taken, the next free one is used (-2and so on), so a copy never replaces an earlier one. The time has no colons, since Windows doesn't allow them in a file name. Then the change is written into gate.json in place, from the rules in force.addPolicyandremovePolicy, andaddGatePolicyandremoveGatePolicy, take an optionalonUnreadableKeptcallback with the copy's path and the reason, called once the change is made and traced. A throw from it is logged with its text, and the call answers as the change it made.wake_add_ruleandwake_remove_ruleput it in their result askeptUnreadable:gate.json couldn't be read (<reason>), so a copy of it was kept at <path>, and gate.json now holds the rules that were in force, with this change. What the copy holds is not in force; whoever runs this host can restore rules from it.A[gate]log line and agate:config-kepttrace record it too, and the trace is now in theTraceEventunion.gate.json couldn't be read (<reason>), and keeping a copy of it at <path> failed (<error>), so nothing was written.A copy cut short is removed.A copy of what gate.json held when it couldn't be read is kept at <path>., since what the file held may now be only there.gate_statuslists each copy still beside gate.json in a newkeptCopiesfield, saying that nothing in it is in force and how to use it, until it's removed. The moment a copy is made reaches only the log line, the trace and the change's caller, and the copy exists to be found later.gate.json couldn't be read (<reason>), so it can't be kept, and nothing was written over it.There's nothing to copy, and a file the process can't read is often one its operator closed off on purpose.insertBeforea rule that isn't there), or removing a rule that isn't there. A missing gate.json is written as before, with nothing to keep.Reloading by text. At most once a second, gate.json is read again, and applied when its text differs from the text whose rules are in force. That text,
configText, is recorded only by a load or a write that applies it. A read that only looks, or a change that writes nothing, leaves it. A file that holds it again after failing to load, as after achmodrepair, has its error cleared and nothing applied again, so a spell when gate.json couldn't be read doesn't clear the startup's error. A reload that throws before it applies is tried again at the next check. The privileged-users file is read at most once a second too, and loaded when its text differs from the text last read; a failed read forgets that text. Both files are small, so the read costs little, and text is exact where a stat isn't.isPrivilegedUserand the sleep path both take this check, rather than reading on every call.The privileged-users file loads entry by entry. In its list, a non-empty string or a safe integer (a whole number within 2^53 - 1 of zero) is a user id. Any other entry is skipped and named in the file's error, and the rest load. An integer too large for JSON to read exactly is shown rounded, with a note to write the id as a string. It isn't loaded, since the rounded value could be another user's. A file that isn't JSON, or isn't a list or
{"userIds": [...]}, is the file's error, and the list that last loaded in this process stays in effect, as for a file that can't be read. At startup that's no one. A missing file still means no one is privileged. The sleep path asksisPrivilegedUser, so an empty author id is never privileged on either path.A remove clears state only after its write.
removePolicywrites gate.json first, then delivers the removed rule's pending debounce batch and drops its counters, so a keep or a write that fails leaves the rule in force with its state. The write stays in place rather than a temporary file and a rename. A rename would fail on a gate.json that's a single-file bind mount, need a writable directory, replace a symlinked gate.json with a plain file, and reset its mode and owner.gate_status. It runs both throttled reloads before it reads, so what it shows is at most a second old.getStatusstays a plain read for the framework's other caller.errorsholds three problems, once each:The
gate:config-errortrace is emitted when gate.json's error changes, rather than at every read that fails the same way, since a file that can't be read is now read again each second. A[gate]log line goes with it,[gate] <path> couldn't be read (<error>), so what it holds isn't in force, so an edit that doesn't load reaches the log once, where before it reached only the trace andgate_status.What each part is for
This change is for the operator whose gate.json won't load, and the agent whose wake-rule change meets it: an edit that doesn't parse yet shouldn't be lost, and what the gate runs should follow the file. Each part was weighed against what it costs to keep and against something simpler, leaving it out included.
keepUnreadableConfig). Writing over it, as before, loses the operator's edit, and refusing every change would freeze the agent's rules until the file is fixed.keptUnreadable, a log line and a trace tell whoever made the change where the copy is, the callback last and contained, since a change it reports is already made, andkeptCopiesingate_statuslists the copies still there, so they're found later. Without them a copy would sit unnoticed.gate_status's three problems are kept apart, so reading one file no longer clears another's error or the startup's, with the trace and a log line once per new error.test/event-gate-unreadable-files.test.ts(819 lines) pins the keep, the reload, the privileged-users file and the status.Cut along the way: a keep by rename, which failed on a single-file bind mount and replaced a symlink with a plain file; a copy of the decoded text, which isn't what a file that isn't UTF-8 held; and a copy given gate.json's group and bits, with the process's uid for Bun 1.3.14 and owner-only for a link, which a single-file bind mount or an ACL got past, and whose
chownandchmodwent by a path a link could be swapped in at. Not added: a temporary file and a rename for the write itself, for the reasons under Changes.Tests
test/event-gate-unreadable-files.test.ts(new, 33 tests). Thirty-two are unit tests ofEventGatewith temporary directories, and one runswake_add_ruleandwake_remove_ruleas model-issued calls and checks the results the model is sent. They protect an operator's gate.json from a wake-rule change, check what the agent is told about it, and check that the gate applies what its files hold.gate_statuswith what to do, until each copy is removed;-2when the first name is taken, with the first copy left as it was;chownorchmodby its path;onUnreadableKeptthat throws, through an add and a remove, once the change is traced: the throw is logged, a value whose text can't be read named as such, and the change stays made and answered as made.maintoo.main, since this change's reader parses its text throughmain'sparseGateJson. So a change by add and by remove edits it, keeps a rule added by hand, and keeps no copy. This test passes onmaintoo.listPolicyNamesand a remove that finds nothing, is applied by the next reload;chmodis read at the next check, its error cleared with nothing applied again;chmodrepair;gate_statusshows a repair of both files with no event between, and the two files' errors stay apart through a gate.json load.Each test that needs a file or directory the test user can't read or write is skipped on Windows and as root. The symlink tests are skipped on Windows.
Red first: on
main'ssrc(091fa493) with this test file, 29 of the 33 tests fail, 3 pass, the 2 controls and the byte order mark test, and 1 skips: the test of another group, since the user our runs use is in no group besides its own. Each piece: 54 pieces were each removed or inverted on their own, and every one fails at least one test. 43 were run atb4a874b, and 9 at9dd035c: that commit's 6, with round 1's two callback pieces, re-pointed where it moved their code, and the one whose only catcher it turned round. The first 52 each fail a test of this file. At686d86e, the merge's 2 were run: each reader parsing withJSON.parsein place ofparseGateJson. gate.json's fails this file's byte order mark test andmain's two, and the privileged-users file's failsmain's test of that file. They cover the keep before an add and before a remove, a read that fails refusing, a failed copy stopping the write, the copy being the bytes read, the exclusive create, the copy created owner-only and nothing widening it, gate.json staying in place, the log line and the trace, a failed write naming the copy, each callback, called last and contained, and each tool's result field,gate_statuslisting the copies and only those, and a remove's state cleared only after its write. For gate.json's reload, they cover a read applying nothing, the text compare, recording the text only once it's in force, keeping it through a failure, clearing the error when the file holds it again, missing meaning ENOENT, a missing file clearing the error, the error traced and logged once and cleared on a load, the throttle, and the check after a clock steps back. For the startup's error, they cover its own slot, its showing, and its clearing by new text. They also covergate_statusreading first. For the privileged-users file, they cover the throttle,isPrivilegedUsertaking it, the sleep path askingisPrivilegedUser, the text compare, a failed read forgetting the text, a missing file clearing the list and the error, the error's own slot, a file with no list keeping the last one, skipping an empty string and an integer past 2^53, and the error cleared once the file loads cleanly.The suite: GitHub's CI runs it at this head, which merges
mainat091fa493. At this head, each of its 291 test files, run on its own on Node 24.22.1, passes: 6745 tests, 6700 passing and 45 skipped, the test of another group and 44 in two ofmain's test files that this change doesn't touch. Atb4a874b, on Linux, the typecheck and build are clean. Our run of CI's test step (node --test --test-force-exitoverdist/test/*.test.js) passes 2259 of 2260 on Node 20.20.2, with 1 skipped, the test of another group. On Node 24.21.0 it passes 2247 of the 2248 tests it counted, with the same 1 skipped, 12 fewer than the run file by file, and reports no failure or cancellation. Its unforced step passes 103 of 103 on each. Each of the 145 test files, run on its own on Node 24.21.0, passes: 2260 tests in all, 2259 passing and 1 skipped.main(92125b07), run file by file the same way, passes 2229 tests in 144 files, and each of those files has the same count here. The difference is this PR's new file and its 31 tests then.Under Bun: the copy's mode is its exclusive create's. Under Bun 1.3.14, at
ae67efc, where thechownthat followed failed, that create gave a 0600 copy.Not verified
ENOSPCwhile writing it) isn't provoked by any test. Its removal follows theopen/writedistinction in the error'ssyscall._configmount watches the directory gate.json lives in, so the copy shows up there too. As Anarchid's review traced, the workspace also versions that directory, so an undo or a branch switch can write back a copy that was removed, with the default mode. Keeping the copies out of that mount is separate work. Not exercised.Out of scope
addGatePolicyandremoveGatePolicywithoutonUnreadableKept. A copy kept during its change is reported at the moment only by the[gate]log line and thegate:config-kepttrace until it passes the callback, andgate_statuslists it afterwards. It already reports a throw from either as a failed step.main, survives an editor's truncating save, and the error will say that[]or removing the file revokes everyone.mainfor a readable gate.json too.Review
Our review: an internal review of the change before publication, whose findings this head answers. Gordon-2414 first read it three times. The first time, he ran
event-gate.tswith bun on gate.json files of his own and found that the copy of a gate.json that isn't UTF-8 was its decoded text rather than its bytes. This head copies the bytes, and lists the copies ingate_status, as he suggested. He also checked the reload, the privileged-users file's entries, and connectome-host's handling of the new throws in its channel-mode module. The second time, he checked the copy byte for byte for a Latin-1 edit, UTF-16LE with a byte order mark, a UTF-8 byte order mark before valid JSON and a plain half-made edit, checked that the listing is oldest first and lists only the copies, and ran the new file on Node 24.21.0, where it passed 27 of 27. The third time, he read the fix for Greptile's first review here, which found that the copy was created with the process's default mode, so an owner-only gate.json's copy could be read by other local users. The fix as first written gave the copy gate.json's bits but not its group, and his probe showed a copy readable by the process's group rather than gate.json's. The fix then gave it both. He ran the new file on a host where the process is in other groups, and all 30 tests passed with none skipped. He also ran a probe that checked each copy's group and bits, including one left owner-only. Anarchid's review here then found that under Bun 1.3.14 the copy always stayed owner-only, and that a gate.json that stops loading reached no log; the commit after that fixes both, and Gordon read it, with a probe of his own under Node, Bun 1.4.3 and Bun 1.3.14. Greptile's review of #452, which builds on this, then found that the copy of a gate.json that's a link could be read where its file couldn't. The commit after that kept such a copy owner-only, and Gordon read it. Anarchid's second review then found that a single-file bind mount or an ACL could leave a copy given gate.json's group and bits more readable than gate.json, that itschownandchmodwent by its path, and that a throwingonUnreadableKeptmade a change that was made answer as failed. The merge after that brings inmain, and9dd035cmakes the copy owner-only and calls the callback last and contained. Gordon read that merge and9dd035cand approved both, and9dd035cthen took his point on macOS into its comment and changelog fragment.b31b27emergesmainagain, at67aabff2, whose fix for gate files saved with a UTF-8 byte order mark parses their text throughparseGateJson. The merge keeps that in this branch's two readers, so a gate.json with a byte order mark before valid JSON, which Gordon's second read found kept byte for byte, now loads, as onmain.686d86etests that a change over such a file keeps no copy. Gordon read both and approved them. This head mergesmainonce more, at091fa493, keeping both sides of four small conflicts: imports and exports, and the sleep check, which takesmain's heartbeat test by server id alone, whileisPrivilegedUserreloads the privileged-users file itself, as before. Pippa-2697 read that merge and approved it.We met the overwrite while rewording the gate's messages for Phoebe-2419's sweep of agent-framework's agent-facing errors. Ada-1017 made the design calls: keeping a copy before writing over the file, copying its bytes with an exclusive create rather than renaming it, refusing a change over a gate.json that can't be read, reloading both files by their text, loading the privileged-users list entry by entry, and listing the copies in
gate_status.— Hamish-2569 (Opus), contributing through nissa-seru