Skip to content
5 changes: 5 additions & 0 deletions changelog.d/unreadable-gate-files.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- A wake-rule change no longer destroys a gate.json that won't load. `wake_add_rule` and `wake_remove_rule` (and `addGatePolicy` and `removeGatePolicy`) start from the rules in force when gate.json is there but isn't JSON or holds a policy the gate refuses, and they wrote those rules straight over the file, so whatever it held, such as an operator's unfinished edit, was lost. Now a copy of the file, byte for byte, is kept first, at `gate.json.unreadable-<UTC time>` beside it (such as `gate.json.unreadable-20261015T025905Z`, with `-2` and so on if that name is taken), and then the change is written into gate.json in place, as before, so a symlinked gate.json keeps its link, the file keeps its mode and owner, and a single-file bind mount still works. The copy is created with mode 0600, and nothing widens it, so on Linux only the user the host runs as can read it, whatever gate.json's own mode, group or ACL: what keeps others out of gate.json can be a directory on the way to the file a link or a single-file bind mount puts there, or an ACL, and the copy, beside it, has none of those. The tool's result says where the copy is, in `keptUnreadable`, and so do a `[gate]` log line and a `gate:config-kept` trace. When the copy can't be made, nothing is written and the tool says why, and a write that fails after the copy is made names the copy. A gate.json the gate can't read at all, such as one it lacks permission for, refuses the change, since there's nothing to keep. A change that writes nothing, such as removing a rule that isn't there, leaves the file as it was. `addGatePolicy` and `removeGatePolicy` take an optional `onUnreadableKept` callback that's told the copy's path and the reason once the change is made; a throw from it is logged, and the change stands. `gate_status` lists each copy still beside gate.json in `keptCopies`, saying that nothing in it is in force and how to use it, until it's removed.
- The gate's files are reloaded when their text changes. At most once a second, gate.json is read again and applied when its text differs from the text whose rules are in force, and the privileged-users file is read again and loaded when its text differs from the text last read. A gate.json that holds the text in force again after failing to load, as after a `chmod` that makes it readable, has its error cleared, and nothing is applied again. gate.json's reload compared only its mtime, so two writes in one clock tick, a write between its read and its stat, or a read that only looked (`getGatePolicyNames`, or a change that wrote nothing) left a new gate.json unapplied until it next changed, and a `chmod` that made it readable again wasn't 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 it couldn't be read each call added another copy of its error to `gate_status`'s `errors`. A clock stepped backwards now makes the next check run, rather than holding off both reloads until the clock catches up. A missing file is told apart from one that's there but can't be reached, such as behind a directory the process can't search: only a missing gate.json keeps the rules in force without an error, and only a missing privileged-users file means no one is privileged.
- A privileged-users file loads entry by entry. An entry of its list that's 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, 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 stays in effect, as for a file that can't be read (at startup, that's no one). Such a file used to load as no one privileged, without a word, and an entry that wasn't an id was loaded as text. An empty author id is never privileged, while the agent sleeps as for tune-out.
- `gate_status` reads both files again, at most once a second, before it answers, and its `errors` holds each problem once: one for gate.json, one for the startup's seeding or reconciling of gate.json, and one for the privileged-users file. A gate.json load no longer clears the startup's error (it stays until gate.json loads new text or is written) or the privileged-users file's, and a privileged-users file that loads again, or is removed, clears its own. When gate.json's error changes, a `[gate]` log line says it couldn't be read and why, and the `gate:config-error` trace is emitted, not at every read that fails the same way. A gate.json that stopped loading used to reach only the trace.
- `wake_remove_rule` and `removeGatePolicy` clear the removed rule's state (delivering its pending debounce batch, and dropping its rate-limit and sampling counters) only once gate.json is written. A write that failed used to leave the rule in force with its state already cleared.
47 changes: 34 additions & 13 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -682,7 +682,7 @@ const utilityOneLiner = (description: string): string => {
import { CheckpointManager } from './mcpl/checkpoint-manager.js';
import { isToolAllowed } from './mcpl/tool-policy.js';
import { toolArgsProblems } from './tool-args.js';
import { EventGate, formatShadowWarning, wakeLineText } from './gate/event-gate.js';
import { EventGate, formatShadowWarning, formatUnreadableKept, wakeLineText } from './gate/event-gate.js';
import type { GateEntryRef, WakeLine } from './gate/types.js';
import { EPHEMERAL_USAGE_NAME, UsageTracker, persistedUsageProblem, type PersistedUsageState } from './usage/usage-tracker.js';
import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js';
Expand Down Expand Up @@ -8101,15 +8101,23 @@ export class AgentFramework {

/**
* Add or replace (upsert) a gate policy at runtime. Validated and persisted
* to gate.json; hot-applied in memory. Throws if no gate is configured or the
* policy is invalid. Placement: `'prepend'` puts a rule ahead of EVERYTHING
* (including addressed-message wake rules — see EventGate.lintShadows);
* `{before: name}` / `{after: name}` anchor it against a specific rule,
* which is usually what a sampler or debounce actually wants.
* to gate.json; hot-applied in memory. Placement: `'prepend'` puts a rule
* ahead of EVERYTHING (including addressed-message wake rules — see
* EventGate.lintShadows); `{before: name}` / `{after: name}` anchor it
* against a specific rule, which is usually what a sampler or debounce
* actually wants. When gate.json is there but won't load, a copy of it is
* kept beside it before the change is written, and `onUnreadableKept` says
* where, once the change is made; a throw from it is logged, not thrown.
* Throws if no gate is configured, the policy is invalid, the
* placement can't be made, or gate.json can't be read, kept or written
* (EventGate.addPolicy).
*/
addGatePolicy(
rawPolicy: unknown,
options?: { position?: 'append' | 'prepend' | { before: string } | { after: string } },
options?: {
position?: 'append' | 'prepend' | { before: string } | { after: string };
onUnreadableKept?: (kept: import('./gate/types.js').UnreadableConfigKept) => void;
},
): import('./gate/types.js').GatePolicy {
if (!this.eventGate) {
throw new Error('No EventGate configured (FrameworkConfig.gate is unset).');
Expand All @@ -8119,13 +8127,18 @@ export class AgentFramework {

/**
* Remove a gate policy by name at runtime. Returns false if it didn't exist.
* Throws if no gate is configured.
* A copy of a gate.json that won't load is kept first, as for addGatePolicy.
* Throws if no gate is configured, or gate.json can't be read, kept or
* written (EventGate.removePolicy); the rule then stays in force.
*/
removeGatePolicy(name: string): boolean {
removeGatePolicy(
name: string,
options?: { onUnreadableKept?: (kept: import('./gate/types.js').UnreadableConfigKept) => void },
): boolean {
if (!this.eventGate) {
throw new Error('No EventGate configured (FrameworkConfig.gate is unset).');
}
return this.eventGate.removePolicy(name);
return this.eventGate.removePolicy(name, options);
}

/** Current gate policy names (freshest on-disk view). Empty when no gate. */
Expand Down Expand Up @@ -28884,10 +28897,13 @@ export class AgentFramework {
finish({ success: false, error: 'wake_remove_rule: `name` (string) is required', isError: true });
return;
}
const removed = this.removeGatePolicy(input.name);
const report: { keptUnreadable?: string } = {};
const removed = this.removeGatePolicy(input.name, {
onUnreadableKept: (k) => { report.keptUnreadable = formatUnreadableKept(k); },
});
finish({
success: true,
data: { removed, name: input.name, policies: this.getGatePolicyNames() },
data: { removed, name: input.name, ...report, policies: this.getGatePolicyNames() },
});
return;
}
Expand Down Expand Up @@ -28968,7 +28984,11 @@ export class AgentFramework {
const before = new Map(
this.eventGate!.probeTable().map((r) => [r.probe, r]),
);
const policy = this.addGatePolicy(rawPolicy, position ? { position } : undefined);
const report: { keptUnreadable?: string } = {};
const policy = this.addGatePolicy(rawPolicy, {
...(position ? { position } : {}),
onUnreadableKept: (k) => { report.keptUnreadable = formatUnreadableKept(k); },
});
const after = this.eventGate!.probeTable();
const probes = after.map((r) => {
const prev = before.get(r.probe);
Expand All @@ -28987,6 +29007,7 @@ export class AgentFramework {
success: true,
data: {
added: policy.name,
...report,
policies: this.getGatePolicyNames(),
...(shadowWarnings.length > 0 ? { shadowWarnings } : {}),
probes,
Expand Down
Loading
Loading