diff --git a/changelog.d/unreadable-gate-files.fixed.md b/changelog.d/unreadable-gate-files.fixed.md new file mode 100644 index 000000000..a610ba190 --- /dev/null +++ b/changelog.d/unreadable-gate-files.fixed.md @@ -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-` 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. diff --git a/src/framework.ts b/src/framework.ts index 1c1b29e97..ae94387d7 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -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'; @@ -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).'); @@ -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. */ @@ -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; } @@ -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); @@ -28987,6 +29007,7 @@ export class AgentFramework { success: true, data: { added: policy.name, + ...report, policies: this.getGatePolicyNames(), ...(shadowWarnings.length > 0 ? { shadowWarnings } : {}), probes, diff --git a/src/gate/event-gate.ts b/src/gate/event-gate.ts index e06bfd871..d1d4ed538 100644 --- a/src/gate/event-gate.ts +++ b/src/gate/event-gate.ts @@ -11,8 +11,8 @@ * inference tokens, not whether the agent should know about the event. */ -import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { existsSync, readdirSync, readFileSync, unlinkSync, writeFileSync, mkdirSync } from 'node:fs'; +import { basename, dirname, join } from 'node:path'; import { GateScript } from './gate-script.js'; import { MAX_TIMER_MS } from '../deadline-timer.js'; import { safeSlice } from '../safe-slice.js'; @@ -31,6 +31,7 @@ import type { GateStatus, ShadowWarning, GateProbeRow, + UnreadableConfigKept, } from './types.js'; // Re-export types consumers need @@ -62,6 +63,21 @@ const MIN_DEBOUNCE_MS = 100; /** Maximum debounce value (ms). */ const MAX_DEBOUNCE_MS = 300_000; +/** Whether a check made at `last` is still fresh at `now`, less than the + * reload throttle ago. A clock stepped backwards since makes it stale, so + * the next check runs rather than wait for the clock to catch up. */ +function checkedRecently(now: number, last: number): boolean { + const elapsed = now - last; + return elapsed >= 0 && elapsed < RELOAD_THROTTLE_MS; +} + +/** A read that failed because nothing is at the path. Any other failure + * (EACCES on a directory, ELOOP, ENOTDIR) is a file that's there but can't + * be read. */ +function isMissing(err: unknown): boolean { + return (err as NodeJS.ErrnoException | null)?.code === 'ENOENT'; +} + // --------------------------------------------------------------------------- // Default config // --------------------------------------------------------------------------- @@ -590,6 +606,52 @@ export function formatShadowWarning(w: ShadowWarning): string { + `(first match wins — every event "${w.later}" would match is consumed by "${w.earlier}" first)`; } +/** What a wake-rule tool tells the agent when its change kept a copy of an unreadable gate.json. */ +export function formatUnreadableKept(kept: UnreadableConfigKept): string { + return `gate.json couldn't be read (${kept.reason}), so a copy of it was kept at ${kept.keptPath}, 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.'; +} + +/** The text of a thrown value, for a log line. It never throws itself, whatever was thrown. */ +function errorText(err: unknown): string { + try { + return err instanceof Error ? err.message : String(err); + } catch { + return 'a value that could not be printed'; + } +} + +/** + * Run a caller's callback once the change it's told of is made and traced. A + * throw from it is logged, not thrown, so the call answers as the change it + * made: told the change had failed, an agent could make it a second time. + */ +function afterChange(name: string, callback: () => void): void { + try { + callback(); + } catch (err) { + console.error(`[gate] ${name} threw once the change was made, and the change stands: ${errorText(err)}`); + } +} + +/** The privileged-users file's error when some entries of its list aren't user ids. */ +function skippedPrivilegedIds(skipped: unknown[], loaded: number): string { + const one = skipped.length === 1; + const shown = skipped.slice(0, 5).map((x) => { + if (typeof x === 'number' && Number.isInteger(x)) { + return `${x} (a number this large can't be read exactly: write the id as a string, in quotes)`; + } + const json = JSON.stringify(x) ?? String(x); + return json.length > 40 ? `${json.slice(0, 39)}…` : json; + }); + const more = skipped.length > shown.length ? `, and ${skipped.length - shown.length} more` : ''; + const rest = loaded === 0 ? 'no id loaded, so no one is privileged' : `${loaded} ${loaded === 1 ? 'id' : 'ids'} did load`; + return `${skipped.length} ${one ? 'entry' : 'entries'} in the privileged-users file ${one ? "isn't a user id" : "aren't user ids"}, ` + + `so ${one ? 'it was' : 'they were'} skipped: ${shown.join(', ')}${more}. A user id is a non-empty string, or a safe integer ` + + `(a whole number within 2^53 - 1 of zero); ${rest}.`; +} + // --------------------------------------------------------------------------- // EventGate // --------------------------------------------------------------------------- @@ -600,11 +662,22 @@ export class EventGate { /** Optional agent-authored programmable gate (gate.js). */ private gateScript: GateScript; private compiledPolicies: CompiledPolicy[] = []; - private configMtime: number = 0; + /** The text of gate.json whose rules are in force, from a load or a write. + * A reload parses the file again only when its text differs; a file that + * holds it again after failing to load (a chmod that makes it readable, + * an edit undone) needs nothing applied, only its error cleared. */ + private configText: string | undefined; private lastReloadCheck: number = 0; private configSource: 'file' | 'initial' | 'default' = 'default'; private lastReloadTimestamp: number | null = null; - private configErrors: string[] = []; + /** The current problem with each file the gate reads, one each, replaced + * whenever that file is read again (gate_status `errors`). */ + private configError: string | null = null; + private privilegedError: string | null = null; + /** Why the startup couldn't seed or reconcile gate.json, kept apart from + * gate.json's own error so the load that follows can't clear it, until + * gate.json loads new text or is written. */ + private startupError: string | null = null; // Debounce state private debounceTimers = new Map(); @@ -664,7 +737,9 @@ export class EventGate { // privilegedUsersPath on change. private privilegedUsersPath: string | undefined; private privilegedUserIds = new Set(); - private privilegedMtime = 0; + /** The privileged-users file's text as last read, whether or not it loaded; + * undefined when it was missing or its last read failed. */ + private privilegedText: string | undefined; private lastPrivilegedCheck = 0; // Dependency-injected callbacks @@ -768,12 +843,20 @@ export class EventGate { } } catch (err) { const msg = err instanceof Error ? err.message : String(err); - this.configErrors.push(`Failed to seed/reconcile gate.json: ${msg}`); + // Its own slot: the load below would clear gate.json's error. + this.startupError = `Failed to seed/reconcile gate.json: ${msg}`; } } // Load config - this.config = this.loadConfig() ?? opts.initialConfig ?? { ...DEFAULT_CONFIG }; + const read = this.readConfigFile(); + if (read.state === 'loaded') { + this.config = read.config; + this.configText = read.text; + this.configSource = 'file'; + } else { + this.config = opts.initialConfig ?? { ...DEFAULT_CONFIG }; + } this.compiledPolicies = this.compilePolicies(this.config); this.refreshShadowWarnings('startup'); @@ -791,27 +874,167 @@ export class EventGate { // ========================================================================= private loadConfig(): GateConfig | null { - if (!existsSync(this.configPath)) { - return null; + const read = this.readConfigFile(); + return read.state === 'loaded' ? read.config : null; // Caller keeps previous config + } + + /** + * gate.json as it is on disk: absent, loaded, or there but unreadable (it + * can't be read, isn't JSON, or holds something validateConfig refuses), + * with its bytes when it could be read, since text decoded from a file that + * isn't UTF-8 isn't what it held. Reading applies nothing: whoever + * applies a loaded config records its text as `configText`. With + * `unlessUnchanged`, a file whose text is `configText` comes back + * `unchanged`, unparsed, since the rules in force came from it. + */ + private readConfigFile(opts?: { unlessUnchanged?: boolean }): + | { state: 'absent' } + | { state: 'unchanged' } + | { state: 'loaded'; config: GateConfig; text: string } + | { state: 'unreadable'; error: string; bytes?: Buffer } { + let bytes: Buffer; + try { + bytes = readFileSync(this.configPath); + } catch (err) { + if (!isMissing(err)) return this.configUnreadable(err); + // No file is no problem with it: the rules in force stay. + this.configError = null; + return { state: 'absent' }; + } + const raw = bytes.toString('utf-8'); + if (opts?.unlessUnchanged && raw === this.configText) { + // The file holds the text in force, which loaded, so it has no problem. + this.configError = null; + return { state: 'unchanged' }; } try { - const raw = readFileSync(this.configPath, 'utf-8'); - const stat = statSync(this.configPath); - this.configMtime = stat.mtimeMs; const config = validateConfig(parseGateJson(raw)); - this.configSource = 'file'; - this.configErrors = []; - return config; + this.configError = null; + return { state: 'loaded', config, text: raw }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - this.configErrors = [msg]; + return { ...this.configUnreadable(err), bytes }; + } + } + + /** + * gate.json's current error, replacing the last one, and traced and logged + * when it's new, so an edit that doesn't load is in the log once. + */ + private configUnreadable(err: unknown): { state: 'unreadable'; error: string } { + const msg = err instanceof Error ? err.message : String(err); + if (msg !== this.configError) { + console.error(`[gate] ${this.configPath} couldn't be read (${msg}), so what it holds isn't in force`); this.emitTrace({ type: 'gate:config-error', error: msg, configPath: this.configPath, timestamp: Date.now(), }); - return null; // Caller keeps previous config + } + this.configError = msg; + return { state: 'unreadable', error: msg }; + } + + /** + * The rules a policy change starts from: gate.json as it is on disk, or the + * rules in force when it's absent or unreadable. An unreadable file comes + * back with its error, and with its bytes when it could be read, for + * keepUnreadableConfig before anything is written over it. + */ + private configForChange(): { config: GateConfig; unreadable?: { error: string; bytes?: Buffer } } { + const read = this.readConfigFile(); + if (read.state === 'loaded') return { config: read.config }; + if (read.state === 'unreadable') return { config: this.config, unreadable: { error: read.error, bytes: read.bytes } }; + return { config: this.config }; + } + + /** + * Keep a copy of an unreadable gate.json, byte for byte as the change read it, at + * `gate.json.unreadable-` beside it, before the rules in force are + * written over it in place, so what it held (often an operator's + * unfinished edit) isn't lost. A copy, not a rename, so gate.json itself + * stays where it is: a symlink keeps its target, the file its mode and + * owner, and a single-file bind mount works. Returns where the copy is. + * Throws, so the caller writes nothing, when the file couldn't be read at + * all (there's nothing to copy, and a file the process can't read is often + * one its operator closed off on purpose) or when the copy can't be made. + */ + private keepUnreadableConfig(unreadable: { error: string; bytes?: Buffer }): string { + const reason = unreadable.error; + if (unreadable.bytes === undefined) { + throw new Error(`gate.json couldn't be read (${reason}), so it can't be kept, and nothing was written over it.`); + } + // No colons: Windows doesn't allow them in a file name. + const stamp = new Date(this.now()).toISOString().replace(/\.\d+Z$/, 'Z').replace(/[-:]/g, ''); + const base = `${this.configPath}.unreadable-${stamp}`; + // The copy is created with mode 0600, and nothing widens it, so on Linux + // only the user the host runs as can read it (a directory's default ACL is + // masked by that mode). What keeps others out of gate.json can be more than + // its own bits, such as 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 host read gate.json, so its user is one who could. + for (let n = 1; ; n++) { + const kept = n === 1 ? base : `${base}-${n}`; + try { + // An exclusive create, so a copy never replaces an earlier one. + writeFileSync(kept, unreadable.bytes, { flag: 'wx', mode: 0o600 }); + } catch (err) { + const e = err as NodeJS.ErrnoException; + if (e.code === 'EEXIST') continue; + // A copy cut short is this call's own file: remove it, so nothing is left. + if (e.syscall !== 'open') { + try { unlinkSync(kept); } catch { /* it's named below */ } + } + throw new Error( + `gate.json couldn't be read (${reason}), and keeping a copy of it at ${kept} failed (${e.message ?? String(err)}), ` + + 'so nothing was written.', + ); + } + console.error(`[gate] ${this.configPath} couldn't be read (${reason}); kept a copy at ${kept} before writing the rules in force`); + this.emitTrace({ + type: 'gate:config-kept', + configPath: this.configPath, + keptPath: kept, + error: reason, + timestamp: Date.now(), + }); + return kept; + } + } + + /** + * The copies keepUnreadableConfig made that are still beside gate.json, + * oldest first, each with what to do with it, for gate_status: the moment a + * copy is made reaches only the log, the trace and the change's caller, and + * the copy exists to be found later. + */ + private keptCopies(): string[] { + const dir = dirname(this.configPath); + const prefix = `${basename(this.configPath)}.unreadable-`; + let names: string[]; + try { + names = readdirSync(dir).filter((name) => name.startsWith(prefix)); + } catch { + return []; + } + return names + .sort((a, b) => a.localeCompare(b, 'en', { numeric: true })) + .map((name) => `${join(dir, name)}: a copy of gate.json, kept when it couldn't be read. Nothing in it is in force. ` + + "To use it, fix it and copy it over gate.json. Once it isn't needed, remove it."); + } + + /** + * writeConfig, after keepUnreadableConfig may have kept a copy of gate.json: + * then a write that fails names the copy, since what the file held may now + * be only there. + */ + private writeConfigAfterKeep(config: GateConfig, kept: UnreadableConfigKept | undefined): void { + try { + this.writeConfig(config); + } catch (err) { + if (!kept) throw err; + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`${msg} A copy of what gate.json held when it couldn't be read is kept at ${kept.keptPath}.`, { cause: err }); } } @@ -842,16 +1065,14 @@ export class EventGate { private reloadIfChanged(): void { const now = Date.now(); - if (now - this.lastReloadCheck < RELOAD_THROTTLE_MS) return; + if (checkedRecently(now, this.lastReloadCheck)) return; this.lastReloadCheck = now; try { - if (!existsSync(this.configPath)) return; - const stat = statSync(this.configPath); - if (stat.mtimeMs === this.configMtime) return; - - const newConfig = this.loadConfig(); - if (!newConfig) return; // Parse error — keep previous config + // Missing, unchanged or unreadable: the rules in force stay. + const read = this.readConfigFile({ unlessUnchanged: true }); + if (read.state !== 'loaded') return; + const newConfig = read.config; // Capture old policies' behavior fingerprints before any state // mutation — needed to detect kept policies whose parameters changed. @@ -888,6 +1109,12 @@ export class EventGate { this.config = newConfig; this.compiledPolicies = this.compilePolicies(newConfig); + // Recorded only now that its rules are in force, so a reload that + // throws before here is tried again at the next check. + this.configText = read.text; + this.configSource = 'file'; + // New text in gate.json now decides the rules, not the startup's. + this.startupError = null; this.lastReloadTimestamp = now; this.refreshShadowWarnings('reload'); @@ -901,7 +1128,7 @@ export class EventGate { timestamp: now, }); } catch { - // Ignore stat errors + // The rules in force stay, and the next check tries again. } } @@ -917,24 +1144,35 @@ export class EventGate { * surfaces it as a tool error. On success the policy is persisted to gate.json * (so it survives restart and is visible to `workspace--edit _config/gate.json`) * and the in-memory config is swapped in immediately, so the rule takes effect - * without waiting for the throttled mtime reload. + * without waiting for the throttled reload. * * The freshest on-disk config is re-read first, so a concurrent operator edit - * isn't clobbered. A policy with the same `name` is replaced IN PLACE when no - * `position` is given (order preserved); WITH a `position` it is moved there - * — so re-issuing a rule is also how you reposition it. New policies append - * by default; `'prepend'` puts one at the very front (ahead of EVERYTHING, - * including addressed-message wake rules — see the shadow lint), and the - * anchored forms `{before: name}` / `{after: name}` insert relative to an - * existing rule, which is almost always the safer way to order a sampler - * or debounce against a specific catch-all. + * isn't clobbered. When gate.json is there but won't load, the change starts + * from the rules in force, after a copy of the file is kept beside it + * (keepUnreadableConfig) and reported to `onUnreadableKept` once the change + * is written and traced; a throw from that callback is logged, and the change + * stands (afterChange). A policy with the same `name` is replaced IN PLACE + * when no `position` is given (order preserved); WITH a `position` it is + * moved there — so re-issuing a rule is also how you reposition it. New + * policies append by default; `'prepend'` puts one at the very front (ahead + * of EVERYTHING, including addressed-message wake rules — see the shadow + * lint), and the anchored forms `{before: name}` / `{after: name}` insert + * relative to an existing rule, which is almost always the safer way to order + * a sampler or debounce against a specific catch-all. + * + * Throws, writing nothing, for an invalid policy, a placement it can't make, + * or a gate.json that can't be read or kept; and throws when the write + * fails, naming any copy kept. */ addPolicy( rawPolicy: unknown, - options?: { position?: 'append' | 'prepend' | { before: string } | { after: string } }, + options?: { + position?: 'append' | 'prepend' | { before: string } | { after: string }; + onUnreadableKept?: (kept: UnreadableConfigKept) => void; + }, ): GatePolicy { const policy = validatePolicy(rawPolicy); - const current = this.loadConfig() ?? this.config; + const { config: current, unreadable } = this.configForChange(); const policies = [...current.policies]; const idx = policies.findIndex((p) => p.name === policy.name); const position = options?.position; @@ -960,7 +1198,10 @@ export class EventGate { } } - this.writeConfig({ ...current, policies }); + const kept = unreadable !== undefined + ? { keptPath: this.keepUnreadableConfig(unreadable), reason: unreadable.error } + : undefined; + this.writeConfigAfterKeep({ ...current, policies }, kept); this.emitTrace({ type: 'gate:policy-added', configPath: this.configPath, @@ -968,6 +1209,7 @@ export class EventGate { replaced: idx >= 0, timestamp: Date.now(), }); + if (kept) afterChange('onUnreadableKept', () => options?.onUnreadableKept?.(kept)); return policy; } @@ -975,25 +1217,34 @@ export class EventGate { * Remove a policy by name from gate.json and clear its live state. Any pending * debounce batch is delivered first (matching reloadIfChanged's removal path, * so buffered events aren't silently dropped). Returns false if no policy of - * that name exists (nothing written). Freshest on-disk config is re-read first - * (see addPolicy). + * that name exists (nothing written). Freshest on-disk config is re-read first, + * and a copy of a gate.json that won't load is kept before it's written over + * (see addPolicy). Throws when gate.json can't be read or kept, or when the + * write fails (naming any copy kept); the rule then stays in force, with its + * state. */ - removePolicy(name: string): boolean { - const current = this.loadConfig() ?? this.config; + removePolicy(name: string, options?: { onUnreadableKept?: (kept: UnreadableConfigKept) => void }): boolean { + const { config: current, unreadable } = this.configForChange(); if (!current.policies.some((p) => p.name === name)) { return false; } - // Deliver any pending debounce batch, then drop timers/buckets/counters. + const kept = unreadable !== undefined + ? { keptPath: this.keepUnreadableConfig(unreadable), reason: unreadable.error } + : undefined; + const policies = current.policies.filter((p) => p.name !== name); + this.writeConfigAfterKeep({ ...current, policies }, kept); + // Only once the write has succeeded, so a failed keep or write leaves the + // rule in force with its state: deliver any pending debounce batch, then + // drop timers, buckets and counters. this.clearPolicyState(name, { deliverPendingDebounce: true }); this.stats.delete(name); - const policies = current.policies.filter((p) => p.name !== name); - this.writeConfig({ ...current, policies }); this.emitTrace({ type: 'gate:policy-removed', configPath: this.configPath, policyName: name, timestamp: Date.now(), }); + if (kept) afterChange('onUnreadableKept', () => options?.onUnreadableKept?.(kept)); return true; } @@ -1008,24 +1259,22 @@ export class EventGate { /** * Persist a config to gate.json and apply it in memory together: write the - * file, swap in the compiled policies, and sync `configMtime` to the new file - * so `reloadIfChanged` doesn't redundantly re-parse identical content. Throws + * file, swap in the compiled policies, and keep the text written as + * `configText`, so `reloadIfChanged` doesn't parse it again. Throws * if the write fails (disk/permission) — callers surface it as a tool error. */ private writeConfig(config: GateConfig): void { mkdirSync(dirname(this.configPath), { recursive: true }); - writeFileSync(this.configPath, JSON.stringify(config, null, 2) + '\n'); + const text = JSON.stringify(config, null, 2) + '\n'; + writeFileSync(this.configPath, text); + this.configText = text; this.config = config; this.compiledPolicies = this.compilePolicies(config); this.refreshShadowWarnings('policy-mutation'); this.configSource = 'file'; - this.configErrors = []; + this.configError = null; + this.startupError = null; this.lastReloadTimestamp = Date.now(); - try { - this.configMtime = statSync(this.configPath).mtimeMs; - } catch { - // A failed stat only risks one redundant reload; keep the old mtime. - } } // ========================================================================= @@ -1119,10 +1368,9 @@ export class EventGate { // marker requires too: a `source` in an event's metadata is the server's // own word, which any server can give. if (this.sleepUntil > this.now()) { - this.reloadPrivilegedIfChanged(); const isHeartbeat = info.serverId === 'heartbeat'; const authorId = this.extractAuthorId(info.metadata); - const privileged = authorId !== null && this.privilegedUserIds.has(authorId); + const privileged = this.isPrivilegedUser(authorId); if (!isHeartbeat && !privileged) { this.sleepSuppressed++; this.totalEvaluations++; @@ -2023,7 +2271,6 @@ export class EventGate { return null; } - /** Load the privileged-users file (bare array or { userIds: [...] }). */ /** * Whether an author id is on the privileged-users list (hot-reloaded). * Shared by sleep bypass and tune-out wake classification (#77: wakes are @@ -2031,45 +2278,74 @@ export class EventGate { */ isPrivilegedUser(authorId: string | null | undefined): boolean { if (!authorId) return false; - this.loadPrivileged(); + this.reloadPrivilegedIfChanged(); return this.privilegedUserIds.has(authorId); } - private loadPrivileged(): void { + /** + * Read the privileged-users file: a list of user ids, or `{"userIds": [...]}`, + * each a non-empty string or a safe integer (a whole number within 2^53 - 1 of zero). An absent file + * means no one is privileged. A file that can't be read, isn't JSON, or + * holds no list leaves the list that last loaded in this process in effect + * (none, at startup), with its error in gate_status until the file is read + * again. In a list, an entry that isn't a user id is skipped, and named in + * the file's error, and the rest load. With `unlessUnchanged`, a file whose + * text is the text last read isn't parsed again. + */ + private loadPrivileged(opts?: { unlessUnchanged?: boolean }): void { if (!this.privilegedUsersPath) return; + const failed = (why: string): string => `Failed to load privileged-users file: ${why}`; + let raw: string; try { - if (!existsSync(this.privilegedUsersPath)) { + raw = readFileSync(this.privilegedUsersPath, 'utf8'); + } catch (err) { + // Read again next time: a file can become readable with its text + // unchanged, as a chmod makes it. + this.privilegedText = undefined; + if (isMissing(err)) { this.privilegedUserIds = new Set(); - this.privilegedMtime = 0; - return; + this.privilegedError = null; + } else { + this.privilegedError = failed(err instanceof Error ? err.message : String(err)); } - this.privilegedMtime = statSync(this.privilegedUsersPath).mtimeMs; - const raw: any = parseGateJson(readFileSync(this.privilegedUsersPath, 'utf8')); - const ids = Array.isArray(raw) ? raw : Array.isArray(raw?.userIds) ? raw.userIds : []; - this.privilegedUserIds = new Set(ids.map((x: unknown) => String(x))); + return; + } + if (opts?.unlessUnchanged && raw === this.privilegedText) return; + this.privilegedText = raw; + let ids: unknown; + try { + const parsed: unknown = parseGateJson(raw); + ids = Array.isArray(parsed) ? parsed : (parsed as { userIds?: unknown } | null)?.userIds; } catch (err) { - this.configErrors.push( - `Failed to load privileged-users file: ${err instanceof Error ? err.message : String(err)}`, - ); + this.privilegedError = failed(err instanceof Error ? err.message : String(err)); + return; + } + // A file that parses but holds no list hasn't loaded either: it used to + // mean no one is privileged, without a word. + if (!Array.isArray(ids)) { + this.privilegedError = failed('expected a list of user ids, or {"userIds": [...]}'); + return; + } + const loaded = new Set(); + const skipped: unknown[] = []; + for (const id of ids) { + if (typeof id === 'string' && id !== '') loaded.add(id); + // JSON.parse has already rounded an integer past 2^53, and the rounded + // value can be another user's id, so it's skipped rather than loaded. + else if (typeof id === 'number' && Number.isSafeInteger(id)) loaded.add(String(id)); + else skipped.push(id); } + this.privilegedUserIds = loaded; + this.privilegedError = skipped.length > 0 ? skippedPrivilegedIds(skipped, loaded.size) : null; } - /** Throttled mtime-based reload of the privileged-users file. */ + /** At most once a second, the privileged-users file read again, and loaded when its text has changed. */ private reloadPrivilegedIfChanged(): void { if (!this.privilegedUsersPath) return; const now = this.now(); - if (now - this.lastPrivilegedCheck < RELOAD_THROTTLE_MS) return; + if (checkedRecently(now, this.lastPrivilegedCheck)) return; this.lastPrivilegedCheck = now; - try { - if (!existsSync(this.privilegedUsersPath)) { - if (this.privilegedUserIds.size > 0) this.privilegedUserIds = new Set(); - return; - } - const mtime = statSync(this.privilegedUsersPath).mtimeMs; - if (mtime !== this.privilegedMtime) this.loadPrivileged(); - } catch { - /* leave the last-known set in place on a transient stat/read error */ - } + this.loadPrivileged({ unlessUnchanged: true }); } getToolDefinition(): ToolDefinition { @@ -2083,6 +2359,10 @@ export class EventGate { } async handleToolCall(): Promise<{ success: boolean; data?: GateStatus; error?: string }> { + // What's in force now: each file read again if a check is due, as the + // next event would. + this.reloadIfChanged(); + this.reloadPrivilegedIfChanged(); const status = this.getStatus(); return { success: true, data: status }; } @@ -2141,8 +2421,9 @@ export class EventGate { default: this.config.default ?? 'always', script: this.gateScript.status(), policies, - errors: [...this.configErrors], + errors: [this.configError, this.startupError, this.privilegedError].filter((e): e is string => e !== null), shadowWarnings: this.shadowWarningsCache.map(formatShadowWarning), + keptCopies: this.keptCopies(), totalEvaluations: this.totalEvaluations, defaultDecisions: { triggered: this.defaultTriggered, diff --git a/src/gate/index.ts b/src/gate/index.ts index c05ce6f32..afe107b3a 100644 --- a/src/gate/index.ts +++ b/src/gate/index.ts @@ -9,5 +9,6 @@ export type { GateEventInfo, GateStatus, GatePolicyStats, + UnreadableConfigKept, WakeLine, } from './types.js'; diff --git a/src/gate/types.ts b/src/gate/types.ts index ce2ce22e9..dd3b54399 100644 --- a/src/gate/types.ts +++ b/src/gate/types.ts @@ -294,6 +294,18 @@ export interface GatePolicyStats { }; } +/** + * What a policy change (addPolicy, removePolicy) reports when gate.json was + * there but wouldn't load: a copy of it was kept before the change was + * written over it, from the rules in force. + */ +export interface UnreadableConfigKept { + /** Where the copy is: `gate.json.unreadable-`, beside gate.json. */ + keptPath: string; + /** Why gate.json couldn't be loaded. */ + reason: string; +} + /** Full gate status returned by gate:status tool. */ export interface GateStatus { configPath: string; @@ -310,6 +322,12 @@ export interface GateStatus { * same hazards wake_add_rule warns about at install time. */ shadowWarnings: string[]; + /** + * The copies wake-rule changes kept of a gate.json that couldn't be read + * (gate.json.unreadable-, beside it), oldest first, each with + * what to do with it, until it's removed. Nothing in a copy is in force. + */ + keptCopies: string[]; /** Total events the gate has evaluated since startup. */ totalEvaluations: number; /** diff --git a/src/index.ts b/src/index.ts index 64ddae3a0..1fbba193e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -68,7 +68,7 @@ export type { SessionUsage, AgentUsage, SessionUsageSnapshot, UsageUpdatedEvent // EventGate export { EventGate, wakeLineText, type WakeProvenance } from './gate/index.js'; -export type { GateConfig, GateOptions, GatePolicy, GatePolicyMatch, GateBehavior, WakeLine } from './gate/index.js'; +export type { GateConfig, GateOptions, GatePolicy, GatePolicyMatch, GateBehavior, UnreadableConfigKept, WakeLine } from './gate/index.js'; // MCPL channel registry (exposed for modules that need channel-level operations) export { ChannelRegistry } from './mcpl/index.js'; diff --git a/src/types/trace.ts b/src/types/trace.ts index 1b48fb3f8..da3e28d6a 100644 --- a/src/types/trace.ts +++ b/src/types/trace.ts @@ -452,6 +452,14 @@ export type TraceEvent = error: string; configPath: string; }) + | (TraceEventBase & { + /** gate.json couldn't be loaded, and a copy of it was kept at `keptPath` + * before a wake-rule change wrote the rules in force over it. */ + type: 'gate:config-kept'; + error: string; + configPath: string; + keptPath: string; + }) | (TraceEventBase & { type: 'gate:debounce-delivered'; policyName: string; diff --git a/test/event-gate-unreadable-files.test.ts b/test/event-gate-unreadable-files.test.ts new file mode 100644 index 000000000..de3f129e9 --- /dev/null +++ b/test/event-gate-unreadable-files.test.ts @@ -0,0 +1,819 @@ +/** + * The gate's files when they can't be read. A wake-rule change starts from the + * rules in force when gate.json is there but won't load, and it used to write + * them straight over the file, so whatever the file held, such as an + * operator's unfinished edit, was lost. Now a copy of the file is kept first, + * at gate.json.unreadable-, gate.json is written in place, and the + * change says where the copy is. Both files are reloaded by their text, and + * each file's current problem shows once in gate_status. + */ +import { test, type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import fs, { + chmodSync, chownSync, lstatSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, + utimesSync, writeFileSync, +} from 'node:fs'; +import { syncBuiltinESMExports } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { AgentFramework } from '../src/index.js'; +import { EventGate } from '../src/gate/event-gate.js'; +import type { GateConfig, GateEventInfo, UnreadableConfigKept } from '../src/gate/types.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; + +/** 2026-10-15T02:59:05.250Z, so a kept copy is named gate.json.unreadable-20261015T025905Z. */ +const AT = Date.UTC(2026, 9, 15, 2, 59, 5, 250); +const BROKEN = '{"default": "defer", "policies": [{"name": "half-made",'; +const RULES: GateConfig = { + default: 'defer', + policies: [{ name: 'mentions', match: { tagsAny: ['chat:mention'] }, behavior: 'always' }], +}; +const QUIET: GateConfig = { default: 'always', policies: [{ name: 'quiet', match: {}, behavior: { debounce: 60000 } }] }; +const INCOMING: GateEventInfo = { content: 'x', eventType: 'mcpl:channel-incoming', serverId: 'discord', channelId: 'c1', tags: [] }; +const NO_CHMOD = process.platform === 'win32' || process.getuid?.() === 0; +/** A group this process is in besides its own, which a gate.json can be shared through. */ +const OTHER_GROUP = process.platform === 'win32' ? undefined : process.getgroups?.().find((g) => g !== process.getegid?.()); + +function tempDir(t: TestContext): string { + const dir = mkdtempSync(join(tmpdir(), 'gate-unreadable-')); + t.after(() => { + chmodSync(dir, 0o755); + rmSync(dir, { recursive: true, force: true }); + }); + return dir; +} + +function makeGate( + dir: string, + opts: { config?: GateConfig; privileged?: string; now?: () => number; traces?: Array<{ type: string; [k: string]: unknown }> } = {}, +) { + const configPath = join(dir, 'gate.json'); + if (opts.config) writeFileSync(configPath, JSON.stringify(opts.config)); + const privilegedUsersPath = join(dir, 'privileged.json'); + if (opts.privileged !== undefined) writeFileSync(privilegedUsersPath, opts.privileged); + const gate = new EventGate({ + configPath, + privilegedUsersPath, + emitTrace: (e) => { opts.traces?.push(e); }, + addMessage: () => '', + requestInference: () => {}, + getAgentNames: () => ['agent'], + now: opts.now ?? (() => AT), + }); + return { gate, configPath, privilegedUsersPath }; +} + +const onDisk = (path: string): GateConfig => JSON.parse(readFileSync(path, 'utf-8')) as GateConfig; +const keptFiles = (dir: string): string[] => readdirSync(dir).filter((f) => f.startsWith('gate.json.unreadable-')).sort(); +const escaped = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const renamedRules = (name: string): string => JSON.stringify({ ...RULES, policies: [{ ...RULES.policies[0]!, name }] }); +/** What a call threw, so a test can restore a mode before it asserts. */ +function thrownBy(fn: () => unknown): string { + try { + fn(); + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + return '(nothing was thrown)'; +} + +test('a change over a gate.json that will not load keeps a copy first, then writes the rules in force with the change', (t) => { + const dir = tempDir(t); + const traces: Array<{ type: string; [k: string]: unknown }> = []; + const { gate, configPath } = makeGate(dir, { config: RULES, traces }); + const logged = t.mock.method(console, 'error', () => {}); + writeFileSync(configPath, BROKEN); + assert.deepEqual(gate.getStatus().keptCopies, []); + + const kept: UnreadableConfigKept[] = []; + gate.addPolicy({ name: 'ambient', match: { tagsAny: ['chat:ambient'] }, behavior: 'defer' }, { onUnreadableKept: (k) => kept.push(k) }); + + const keptPath = join(dir, 'gate.json.unreadable-20261015T025905Z'); + assert.equal(readFileSync(keptPath, 'utf-8'), BROKEN, 'the unreadable file is kept, byte for byte'); + assert.deepEqual(onDisk(configPath).policies.map((p) => p.name), ['mentions', 'ambient']); + assert.equal(kept.length, 1); + assert.equal(kept[0]!.keptPath, keptPath); + assert.match(kept[0]!.reason, /JSON/, 'the reason is why it could not be loaded'); + assert.deepEqual(gate.getStatus().errors, [], 'gate.json loads again'); + const keptTraces = traces.filter((e) => e.type === 'gate:config-kept'); + assert.equal(keptTraces.length, 1); + assert.equal(keptTraces[0]!.keptPath, keptPath); + assert.equal(keptTraces[0]!.error, kept[0]!.reason); + assert.deepEqual( + logged.mock.calls.map((c) => c.arguments[0]), + [ + `[gate] ${configPath} couldn't be read (${kept[0]!.reason}), so what it holds isn't in force`, + `[gate] ${configPath} couldn't be read (${kept[0]!.reason}); kept a copy at ${keptPath} before writing the rules in force`, + ], + 'the failed load is logged once, then the copy', + ); + + // A second unreadable file in the same second takes the next free name, by removePolicy. + writeFileSync(configPath, `${BROKEN} "second"`); + assert.equal(gate.removePolicy('ambient', { onUnreadableKept: (k) => kept.push(k) }), true); + assert.deepEqual(keptFiles(dir), ['gate.json.unreadable-20261015T025905Z', 'gate.json.unreadable-20261015T025905Z-2']); + assert.equal(readFileSync(keptPath, 'utf-8'), BROKEN, 'the first copy is not replaced'); + assert.equal(readFileSync(`${keptPath}-2`, 'utf-8'), `${BROKEN} "second"`); + assert.equal(kept[1]!.keptPath, `${keptPath}-2`); + assert.deepEqual(onDisk(configPath).policies.map((p) => p.name), ['mentions']); + + // gate_status lists each copy, with what to do with it, until it's removed. + const told = ": a copy of gate.json, kept when it couldn't be read. Nothing in it is in force. " + + "To use it, fix it and copy it over gate.json. Once it isn't needed, remove it."; + assert.deepEqual(gate.getStatus().keptCopies, [`${keptPath}${told}`, `${keptPath}-2${told}`]); + rmSync(keptPath); + assert.deepEqual(gate.getStatus().keptCopies, [`${keptPath}-2${told}`]); +}); + +test('a policy the validator refuses also keeps gate.json from loading, and it is kept the same way', (t) => { + const dir = tempDir(t); + const { gate, configPath } = makeGate(dir, { config: RULES }); + const refused = JSON.stringify({ default: 'defer', policies: [{ name: 'quick', behavior: { debounce: 5 } }] }); + writeFileSync(configPath, refused); + + gate.addPolicy({ name: 'ambient', match: { tagsAny: ['chat:ambient'] }, behavior: 'defer' }); + + assert.equal(readFileSync(join(dir, 'gate.json.unreadable-20261015T025905Z'), 'utf-8'), refused); + assert.deepEqual(onDisk(configPath).policies.map((p) => p.name), ['mentions', 'ambient']); +}); + +test('a gate.json saved with a BOM loads, so a change edits it and keeps no copy', (t) => { + const dir = tempDir(t); + const { gate, configPath } = makeGate(dir, { config: RULES }); + // UTF-8 with a leading U+FEFF, as some Windows editors save it, holding a rule its operator added by hand. + const byHand = { name: 'by-hand', match: { tagsAny: ['chat:dm'] }, behavior: 'always' as const }; + writeFileSync(configPath, `\uFEFF${JSON.stringify({ ...RULES, policies: [...RULES.policies, byHand] }, null, 2)}`); + + const kept: UnreadableConfigKept[] = []; + gate.addPolicy({ name: 'ambient', match: { tagsAny: ['chat:ambient'] }, behavior: 'defer' }, { onUnreadableKept: (k) => kept.push(k) }); + assert.deepEqual(onDisk(configPath).policies.map((p) => p.name), ['mentions', 'by-hand', 'ambient'], 'the rule added by hand stays'); + writeFileSync(configPath, `\uFEFF${readFileSync(configPath, 'utf-8')}`); + assert.equal(gate.removePolicy('ambient', { onUnreadableKept: (k) => kept.push(k) }), true); + assert.deepEqual(onDisk(configPath).policies.map((p) => p.name), ['mentions', 'by-hand']); + + assert.deepEqual(kept, []); + assert.deepEqual(keptFiles(dir), []); + assert.deepEqual(gate.getStatus().errors, []); +}); + +test('a gate.json that is not UTF-8 is kept byte for byte', (t) => { + const dir = tempDir(t); + const { gate, configPath } = makeGate(dir, { config: RULES }); + // The keep's log line quotes JSON.parse's error, which quotes the decoded text: NULs, for UTF-16. + t.mock.method(console, 'error', () => {}); + // A half-made edit saved as Latin-1, then a file as Windows PowerShell 5's + // `>` writes one: UTF-16LE, with a byte order mark. + const latin1 = Buffer.from('{"default": "defer", "policies": [{"name": "caf\u00e9",', 'latin1'); + const utf16 = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(JSON.stringify(RULES), 'utf16le')]); + writeFileSync(configPath, latin1); + gate.addPolicy({ name: 'ambient', behavior: 'defer' }); + writeFileSync(configPath, utf16); + assert.equal(gate.removePolicy('ambient'), true); + + assert.deepEqual(keptFiles(dir), ['gate.json.unreadable-20261015T025905Z', 'gate.json.unreadable-20261015T025905Z-2']); + assert.deepEqual(readFileSync(join(dir, 'gate.json.unreadable-20261015T025905Z')), latin1); + assert.deepEqual(readFileSync(join(dir, 'gate.json.unreadable-20261015T025905Z-2')), utf16); + assert.deepEqual(onDisk(configPath).policies.map((p) => p.name), ['mentions']); +}); + +test("a copy is owner-only whatever gate.json's bits: an owner-only, a group-readable and a world-readable gate.json each get an owner-only copy", { + skip: process.platform === 'win32' ? 'needs POSIX permission bits' : false, +}, (t) => { + const dir = tempDir(t); + const { gate, configPath } = makeGate(dir, { config: RULES }); + t.mock.method(console, 'error', () => {}); + const kept = ['gate.json.unreadable-20261015T025905Z', 'gate.json.unreadable-20261015T025905Z-2', 'gate.json.unreadable-20261015T025905Z-3']; + for (const [i, mode] of [0o600, 0o640, 0o644].entries()) { + writeFileSync(configPath, BROKEN); + chmodSync(configPath, mode); + gate.addPolicy({ name: `ambient-${mode.toString(8)}`, behavior: 'defer' }); + assert.equal(statSync(join(dir, kept[i]!)).mode & 0o777, 0o600, `the copy of a ${mode.toString(8)} gate.json`); + } +}); + +test("a copy of a gate.json shared through another of this process's groups is owner-only, so that group can't read it", { + skip: OTHER_GROUP === undefined ? 'needs a group this process is in besides its own' : false, +}, (t) => { + const dir = tempDir(t); + const { gate, configPath } = makeGate(dir, { config: RULES }); + t.mock.method(console, 'error', () => {}); + writeFileSync(configPath, BROKEN); + chmodSync(configPath, 0o640); + try { + chownSync(configPath, -1, OTHER_GROUP!); + } catch (err) { + t.skip(`gate.json can't be given group ${OTHER_GROUP} here (${(err as Error).message})`); + return; + } + + gate.addPolicy({ name: 'ambient', behavior: 'defer' }); + + assert.equal(statSync(join(dir, 'gate.json.unreadable-20261015T025905Z')).mode & 0o777, 0o600, 'the copy is owner-only'); +}); + +test("nothing chowns or chmods the copy by its path once it's made, so a link swapped in for it can't redirect either", { + skip: process.platform === 'win32' ? 'needs POSIX permission bits' : false, +}, (t) => { + const dir = tempDir(t); + const { gate, configPath } = makeGate(dir, { config: RULES }); + t.mock.method(console, 'error', () => {}); + writeFileSync(configPath, BROKEN); + chmodSync(configPath, 0o640); + const chown = t.mock.method(fs, 'chownSync', () => { throw new Error('chownSync called'); }); + const chmod = t.mock.method(fs, 'chmodSync', () => { throw new Error('chmodSync called'); }); + syncBuiltinESMExports(); + try { + gate.addPolicy({ name: 'ambient', behavior: 'defer' }); + } finally { + chown.mock.restore(); + chmod.mock.restore(); + syncBuiltinESMExports(); + } + + assert.equal(chown.mock.callCount() + chmod.mock.callCount(), 0, 'the mode comes from the exclusive create alone'); + assert.equal(statSync(join(dir, 'gate.json.unreadable-20261015T025905Z')).mode & 0o777, 0o600, "and it's owner-only"); +}); + +test("a copy of a gate.json that's a link stays owner-only, since a directory on the way to its file may be what closes it", { + skip: process.platform === 'win32' ? 'needs a symlink and POSIX permission bits' : false, +}, (t) => { + const dir = tempDir(t); + // Its file is open to anyone (0644), inside a directory closed to all but its owner (0700). + const closed = join(dir, 'closed'); + fs.mkdirSync(closed, { mode: 0o700 }); + const target = join(closed, 'real-gate.json'); + writeFileSync(target, JSON.stringify(RULES)); + chmodSync(target, 0o644); + const configPath = join(dir, 'gate.json'); + symlinkSync(target, configPath); + const { gate } = makeGate(dir); + t.mock.method(console, 'error', () => {}); + writeFileSync(configPath, BROKEN); + + gate.addPolicy({ name: 'ambient', behavior: 'defer' }); + + const copy = statSync(join(dir, 'gate.json.unreadable-20261015T025905Z')); + assert.equal(copy.mode & 0o777, 0o600, 'the copy beside the link is owner-only'); + assert.equal(statSync(target).mode & 0o777, 0o644, 'while the file it points to keeps its mode'); + assert.equal(readFileSync(join(dir, 'gate.json.unreadable-20261015T025905Z'), 'utf-8'), BROKEN, 'and the copy holds what gate.json held'); +}); + +test('keeping a copy leaves a symlinked gate.json pointing at its file, which keeps its mode and takes the change', { + skip: process.platform === 'win32' ? 'needs a symlink' : false, +}, (t) => { + const dir = tempDir(t); + const target = join(dir, 'real-gate.json'); + writeFileSync(target, JSON.stringify(RULES)); + chmodSync(target, 0o640); + const configPath = join(dir, 'gate.json'); + symlinkSync(target, configPath); + const { gate } = makeGate(dir); + writeFileSync(configPath, BROKEN); + + gate.addPolicy({ name: 'ambient', behavior: 'defer' }); + + assert.ok(lstatSync(configPath).isSymbolicLink(), 'gate.json is still the link'); + assert.equal(statSync(target).mode & 0o777, 0o640, 'its file keeps its mode'); + assert.deepEqual(onDisk(target).policies.map((p) => p.name), ['mentions', 'ambient'], 'and holds the change'); + assert.equal(readFileSync(join(dir, 'gate.json.unreadable-20261015T025905Z'), 'utf-8'), BROKEN); +}); + +test("an onUnreadableKept that throws is logged once the change is made and traced, and the change answers as made", (t) => { + const dir = tempDir(t); + const traces: Array<{ type: string; [k: string]: unknown }> = []; + const { gate, configPath } = makeGate(dir, { config: RULES, traces }); + const logged = t.mock.method(console, 'error', () => {}); + const order: string[] = []; + const said = () => logged.mock.calls.map((c) => String(c.arguments[0])).filter((line) => line.includes('onUnreadableKept')); + + writeFileSync(configPath, BROKEN); + const added = gate.addPolicy({ name: 'ambient', behavior: 'defer' }, { + onUnreadableKept: () => { + order.push(traces.some((e) => e.type === 'gate:policy-added') ? 'after the trace' : 'before the trace'); + throw new Error('the host could not record it'); + }, + }); + assert.equal(added.name, 'ambient', 'addPolicy returns the rule it saved'); + assert.deepEqual(onDisk(configPath).policies.map((p) => p.name), ['mentions', 'ambient']); + assert.deepEqual(order, ['after the trace'], 'the callback runs once the change is traced'); + assert.deepEqual(said(), ['[gate] onUnreadableKept threw once the change was made, and the change stands: the host could not record it']); + + // So for a removal, and a thrown value whose text can't be read is named as such. + writeFileSync(configPath, BROKEN); + const unprintable = { toString(): string { throw new Error('no text'); } }; + const removed = gate.removePolicy('mentions', { + onUnreadableKept: () => { + order.push(traces.some((e) => e.type === 'gate:policy-removed') ? 'after the trace' : 'before the trace'); + throw unprintable; + }, + }); + assert.equal(removed, true, 'removePolicy answers that it removed the rule'); + assert.deepEqual(order, ['after the trace', 'after the trace'], 'once the removal is traced'); + assert.equal(said()[1], '[gate] onUnreadableKept threw once the change was made, and the change stands: a value that could not be printed'); +}); + +test('nothing is kept, and an unreadable gate.json stays as it was, when nothing is written over it', (t) => { + const dir = tempDir(t); + const { gate, configPath } = makeGate(dir, { config: RULES }); + writeFileSync(configPath, BROKEN); + + // A placement the change can't make refuses before anything is written. + assert.throws(() => gate.addPolicy({ name: 'ambient', behavior: 'defer' }, { position: { before: 'missing' } }), /no policy named "missing"/); + // Removing a rule that isn't there writes nothing. + assert.equal(gate.removePolicy('missing'), false); + + assert.deepEqual(keptFiles(dir), []); + assert.equal(readFileSync(configPath, 'utf-8'), BROKEN); +}); + +test('a missing gate.json is written with nothing kept', (t) => { + const dir = tempDir(t); + const { gate, configPath } = makeGate(dir); + gate.addPolicy({ name: 'ambient', behavior: 'defer' }); + assert.deepEqual(keptFiles(dir), []); + assert.deepEqual(onDisk(configPath).policies.map((p) => p.name), ['ambient']); +}); + +test('a gate.json the gate cannot read at all refuses a change, since there is nothing to keep', { + skip: NO_CHMOD ? 'needs a file the test user cannot read' : false, +}, (t) => { + const dir = tempDir(t); + const { gate, configPath } = makeGate(dir, { config: RULES }); + writeFileSync(configPath, BROKEN); + chmodSync(configPath, 0o000); + const thrown = thrownBy(() => gate.addPolicy({ name: 'ambient', behavior: 'defer' })); + chmodSync(configPath, 0o644); + + assert.match(thrown, /^gate\.json couldn't be read \(EACCES: .*\), so it can't be kept, and nothing was written over it\.$/); + assert.equal(readFileSync(configPath, 'utf-8'), BROKEN); + assert.deepEqual(keptFiles(dir), []); + assert.deepEqual(gate.getStatus().policies.map((p) => p.name), ['mentions'], 'the rules in force stay'); +}); + +test('when a copy of the unreadable gate.json cannot be kept, nothing is written, and the rule stays with its pending batch', { + skip: NO_CHMOD ? 'needs a directory the test user cannot write to' : false, +}, (t) => { + const dir = tempDir(t); + const configPath = join(dir, 'gate.json'); + writeFileSync(configPath, JSON.stringify(QUIET)); + const wakes: string[] = []; + const gate = new EventGate({ + configPath, + emitTrace: () => {}, + addMessage: () => '', + requestInference: (agent) => { wakes.push(agent); }, + getAgentNames: () => ['agent'], + now: () => AT, + }); + t.after(() => gate.dispose()); + assert.equal(gate.evaluate(INCOMING).trigger, false, 'the event waits in the debounce batch'); + writeFileSync(configPath, BROKEN); + chmodSync(dir, 0o555); + const thrown = thrownBy(() => gate.removePolicy('quiet')); + chmodSync(dir, 0o755); + + assert.match( + thrown, + /^gate\.json couldn't be read \(.*\), and keeping a copy of it at .*gate\.json\.unreadable-20261015T025905Z failed \(EACCES: .*\), so nothing was written\.$/, + ); + assert.equal(readFileSync(configPath, 'utf-8'), BROKEN); + assert.deepEqual(keptFiles(dir), []); + const [quiet] = gate.getStatus().policies; + assert.equal(quiet?.name, 'quiet', 'the rule stays in force'); + assert.equal(quiet?.debounceState?.pendingCount, 1, 'its batch is still pending'); + assert.deepEqual(wakes, [], 'and was not delivered'); +}); + +test('a write that fails after the copy is kept names the copy', { + skip: NO_CHMOD ? 'needs a file the test user cannot write' : false, +}, (t) => { + const dir = tempDir(t); + const { gate, configPath } = makeGate(dir, { config: RULES }); + writeFileSync(configPath, BROKEN); + chmodSync(configPath, 0o444); + const thrown = thrownBy(() => gate.addPolicy({ name: 'ambient', behavior: 'defer' })); + chmodSync(configPath, 0o644); + + const keptPath = join(dir, 'gate.json.unreadable-20261015T025905Z'); + assert.match(thrown, new RegExp(`EACCES.* A copy of what gate\\.json held when it couldn't be read is kept at ${escaped(keptPath)}\\.$`)); + assert.equal(readFileSync(keptPath, 'utf-8'), BROKEN); + assert.equal(readFileSync(configPath, 'utf-8'), BROKEN, 'the write failed opening gate.json, so it is as it was'); + assert.deepEqual(gate.getStatus().policies.map((p) => p.name), ['mentions'], 'the rules in force stay'); +}); + +test('a remove whose write fails changes nothing: the rule stays, with its pending debounce batch', { + skip: NO_CHMOD ? 'needs a file the test user cannot write' : false, +}, (t) => { + const dir = tempDir(t); + const configPath = join(dir, 'gate.json'); + writeFileSync(configPath, JSON.stringify(QUIET)); + const wakes: string[] = []; + const gate = new EventGate({ + configPath, + emitTrace: () => {}, + addMessage: () => '', + requestInference: (agent) => { wakes.push(agent); }, + getAgentNames: () => ['agent'], + now: () => AT, + }); + t.after(() => gate.dispose()); + assert.equal(gate.evaluate(INCOMING).trigger, false); + chmodSync(configPath, 0o444); + + const thrown = thrownBy(() => gate.removePolicy('quiet')); + chmodSync(configPath, 0o644); + assert.match(thrown, /EACCES/); + const [quiet] = gate.getStatus().policies; + assert.equal(quiet?.name, 'quiet', 'the rule stays in force'); + assert.equal(quiet?.debounceState?.pendingCount, 1, 'its batch is still pending'); + assert.deepEqual(wakes, [], 'and was not delivered'); +}); + +test('a new gate.json that a read only looked at, as listPolicyNames does, is still applied by the next reload', (t) => { + const dir = tempDir(t); + const { gate, configPath } = makeGate(dir, { config: RULES }); + writeFileSync(configPath, renamedRules('mentionz')); + + assert.deepEqual(gate.listPolicyNames(), ['mentionz'], 'the listing shows the file'); + assert.equal(gate.removePolicy('missing'), false, 'a remove that finds nothing writes nothing'); + assert.deepEqual(gate.getStatus().policies.map((p) => p.name), ['mentions'], 'neither applied it'); + gate.evaluate(INCOMING); + assert.deepEqual(gate.getStatus().policies.map((p) => p.name), ['mentionz'], 'the next reload does'); +}); + +test("gate.json's reload: unchanged text is not applied again, a check within a second is skipped, and a clock stepped back checks at once", (t) => { + const dir = tempDir(t); + const traces: Array<{ type: string }> = []; + const { gate, configPath } = makeGate(dir, { config: RULES, traces }); + const lastCheckAt = (at: number) => { (gate as unknown as { lastReloadCheck: number }).lastReloadCheck = at; }; + const names = () => gate.getStatus().policies.map((p) => p.name); + const reloads = () => traces.filter((e) => e.type === 'gate:config-reloaded').length; + + writeFileSync(configPath, renamedRules('one')); + gate.evaluate(INCOMING); + assert.deepEqual(names(), ['one']); + assert.equal(reloads(), 1); + + lastCheckAt(0); + gate.evaluate(INCOMING); + assert.equal(reloads(), 1, 'the text in force is not applied again'); + + lastCheckAt(Date.now()); + writeFileSync(configPath, renamedRules('two')); + gate.evaluate(INCOMING); + assert.deepEqual(names(), ['one'], 'within a second of the last check, the file is not read'); + + lastCheckAt(Date.now() + 600_000); + gate.evaluate(INCOMING); + assert.deepEqual(names(), ['two'], 'a clock stepped back since the last check checks at once'); +}); + +test('gate.json is reloaded when its text changes, whatever its mtime and size', (t) => { + const dir = tempDir(t); + const configPath = join(dir, 'gate.json'); + const second = Math.floor(AT / 1000); + writeFileSync(configPath, JSON.stringify(RULES)); + utimesSync(configPath, second, second); + const { gate } = makeGate(dir); + + // New text of the same length, mtime set back: as two writes in one clock tick leave it. + writeFileSync(configPath, renamedRules('mentionz')); + utimesSync(configPath, second, second); + gate.evaluate(INCOMING); + assert.deepEqual(gate.getStatus().policies.map((p) => p.name), ['mentionz']); +}); + +test('a gate.json that could not be read is read again once it can be, though its text and stat are unchanged', { + skip: NO_CHMOD ? 'needs a file the test user cannot read' : false, +}, (t) => { + const dir = tempDir(t); + const traces: Array<{ type: string }> = []; + const { gate, configPath } = makeGate(dir, { config: RULES, traces }); + chmodSync(configPath, 0o000); + const names = gate.listPolicyNames(); + const errors = gate.getStatus().errors; + chmodSync(configPath, 0o644); + assert.deepEqual(names, ['mentions'], 'the rules in force stay'); + assert.equal(errors.length, 1, JSON.stringify(errors)); + + gate.evaluate(INCOMING); + assert.deepEqual(gate.getStatus().errors, [], 'the reload read it again'); + assert.equal(traces.filter((e) => e.type === 'gate:config-reloaded').length, 0, 'it holds the text in force, so nothing was applied'); +}); + +test('a gate.json that is removed takes its error with it, and the rules in force stay', (t) => { + const dir = tempDir(t); + const { gate, configPath } = makeGate(dir, { config: RULES }); + writeFileSync(configPath, BROKEN); + assert.deepEqual(gate.listPolicyNames(), ['mentions']); + assert.equal(gate.getStatus().errors.length, 1); + + rmSync(configPath); + assert.deepEqual(gate.listPolicyNames(), ['mentions'], 'the rules in force stay'); + assert.deepEqual(gate.getStatus().errors, [], 'a missing gate.json is no problem with it'); +}); + +test('a file the gate cannot reach, its directory closed off, is an error rather than a missing file, and what is in force stays', { + skip: NO_CHMOD ? 'needs a directory the test user cannot search' : false, +}, (t) => { + const dir = tempDir(t); + let now = AT; + const { gate } = makeGate(dir, { config: RULES, privileged: '["u1"]', now: () => now }); + chmodSync(dir, 0o000); + now += 2_000; + const privileged = gate.isPrivilegedUser('u1'); + gate.evaluate(INCOMING); + const { policies, errors } = gate.getStatus(); + chmodSync(dir, 0o755); + + assert.equal(privileged, true, 'the privileged list that loaded stays'); + assert.deepEqual(policies.map((p) => p.name), ['mentions'], 'the rules in force stay'); + assert.equal(errors.length, 2, JSON.stringify(errors)); + assert.match(errors[0]!, /EACCES/); + assert.match(errors[1]!, /^Failed to load privileged-users file: EACCES/); +}); + +test("the startup's failure to reconcile gate.json shows beside a gate.json that loads, until gate.json loads new text", { + skip: NO_CHMOD ? 'needs a file the test user cannot write' : false, +}, (t) => { + const dir = tempDir(t); + const configPath = join(dir, 'gate.json'); + writeFileSync(configPath, JSON.stringify(RULES)); + chmodSync(configPath, 0o444); + const initialConfig: GateConfig = { + ...RULES, + policies: [...RULES.policies, { name: 'ambient', match: { tagsAny: ['chat:ambient'] }, behavior: 'defer' }], + }; + const gate = new EventGate({ + configPath, + initialConfig, + emitTrace: () => {}, + addMessage: () => '', + requestInference: () => {}, + getAgentNames: () => ['agent'], + now: () => AT, + }); + chmodSync(configPath, 0o644); + + const errors = gate.getStatus().errors; + assert.equal(errors.length, 1, JSON.stringify(errors)); + assert.match(errors[0]!, /^Failed to seed\/reconcile gate\.json: EACCES/); + assert.deepEqual(gate.getStatus().policies.map((p) => p.name), ['mentions'], 'gate.json loaded, without the starting rule it could not add'); + + // A spell when gate.json can't be read, then the same text again: nothing new decides the rules, so the error stays. + chmodSync(configPath, 0o000); + gate.evaluate(INCOMING); + const whileUnreadable = gate.getStatus().errors.length; + chmodSync(configPath, 0o644); + (gate as unknown as { lastReloadCheck: number }).lastReloadCheck = 0; + gate.evaluate(INCOMING); + assert.equal(whileUnreadable, 2, "gate.json's own error joins it"); + assert.deepEqual(gate.getStatus().errors, errors, 'the same text read again leaves only the startup\'s error'); + + writeFileSync(configPath, JSON.stringify({ ...RULES, default: 'skip' })); + (gate as unknown as { lastReloadCheck: number }).lastReloadCheck = 0; + gate.evaluate(INCOMING); + assert.deepEqual(gate.getStatus().errors, [], 'new text in gate.json decides the rules now'); +}); + +test('an unreadable gate.json read again unchanged is traced and logged once, and again once it fails another way', (t) => { + const dir = tempDir(t); + const traces: Array<{ type: string }> = []; + const { gate, configPath } = makeGate(dir, { config: RULES, traces }); + const logged = t.mock.method(console, 'error', () => {}); + const loadLines = () => logged.mock.calls.map((c) => String(c.arguments[0])).filter((line) => line.startsWith(`[gate] ${configPath} couldn't be read (`)); + writeFileSync(configPath, BROKEN); + for (let i = 0; i < 3; i++) gate.listPolicyNames(); + assert.equal(traces.filter((e) => e.type === 'gate:config-error').length, 1); + assert.equal(loadLines().length, 1); + assert.match(loadLines()[0]!, /JSON.*\), so what it holds isn't in force$/); + + writeFileSync(configPath, '[1,'); + for (let i = 0; i < 3; i++) gate.listPolicyNames(); + assert.equal(traces.filter((e) => e.type === 'gate:config-error').length, 2); + assert.equal(loadLines().length, 2, 'a new error is logged once too'); +}); + +test('a privileged-users file that parses but holds no list is its error, and the last list that loaded stays', (t) => { + const dir = tempDir(t); + let now = AT; + const { gate, privilegedUsersPath } = makeGate(dir, { config: RULES, privileged: '["u1"]', now: () => now }); + const check = (id: string) => { + now += 2_000; // past the reload throttle + return gate.isPrivilegedUser(id); + }; + for (const shape of ['{"users": ["u2"]}', '"u2"', '{"userIds": "u2"}', 'null']) { + writeFileSync(privilegedUsersPath, shape); + assert.equal(check('u1'), true, `the last list that loaded stays, with ${shape}`); + assert.equal(gate.isPrivilegedUser('u2'), false); + assert.deepEqual( + gate.getStatus().errors, + ['Failed to load privileged-users file: expected a list of user ids, or {"userIds": [...]}'], + shape, + ); + } + writeFileSync(privilegedUsersPath, '{"userIds": ["u2", 3]}'); + assert.equal(check('u2'), true); + assert.equal(gate.isPrivilegedUser('3'), true); + assert.deepEqual(gate.getStatus().errors, []); +}); + +test('a privileged-users list loads entry by entry: what is not a user id is skipped and named, and a number too large to read exactly is never loaded', (t) => { + const dir = tempDir(t); + let now = AT; + const { gate, privilegedUsersPath } = makeGate(dir, { config: RULES, privileged: '["u1"]', now: () => now }); + writeFileSync(privilegedUsersPath, '["u2", "", 12345678901234567890, {"id": "u3"}, 7]'); + now += 2_000; + + assert.equal(gate.isPrivilegedUser('u2'), true); + assert.equal(gate.isPrivilegedUser('7'), true); + assert.equal(gate.isPrivilegedUser('u1'), false, "the list is the file's"); + const rounded = String(JSON.parse('12345678901234567890')); + assert.equal(gate.isPrivilegedUser(rounded), false, 'the rounded number, which could be someone else, is not loaded'); + assert.deepEqual(gate.getStatus().errors, [ + `3 entries in the privileged-users file aren't user ids, so they were skipped: "", ${rounded} (a number this large can't ` + + 'be read exactly: write the id as a string, in quotes), {"id":"u3"}. A user id is a non-empty string, or a safe integer ' + + '(a whole number within 2^53 - 1 of zero); 2 ids did load.', + ]); +}); + +test("a privileged-users file that isn't JSON keeps the last list that loaded, with its error once, until it changes", (t) => { + const dir = tempDir(t); + let now = AT; + const { gate, privilegedUsersPath } = makeGate(dir, { config: RULES, privileged: '["u1"]', now: () => now }); + const failures = () => gate.getStatus().errors.filter((e) => e.startsWith('Failed to load privileged-users file')); + const check = (id: string) => { + now += 2_000; // past the reload throttle + return gate.isPrivilegedUser(id); + }; + + writeFileSync(privilegedUsersPath, '["u1", '); + for (let i = 0; i < 5; i++) assert.equal(check('u1'), true, 'the last list that loaded stays in effect'); + assert.equal(failures().length, 1, `one error, not one per call: ${JSON.stringify(gate.getStatus().errors)}`); + + writeFileSync(privilegedUsersPath, '["u1", "u2", '); + assert.equal(check('u1'), true); + assert.equal(failures().length, 1, 'a second failed load replaces the first error'); + + writeFileSync(privilegedUsersPath, '["u2"]'); + assert.equal(check('u2'), true, 'a changed file is read again'); + assert.equal(gate.isPrivilegedUser('u1'), false); + assert.deepEqual(failures(), [], 'its error goes once it loads'); + + writeFileSync(privilegedUsersPath, '["u2", '); + assert.equal(check('u2'), true); + rmSync(privilegedUsersPath); + assert.equal(check('u2'), false, 'with no file, no one is privileged'); + assert.deepEqual(failures(), [], 'and there is no error'); +}); + +test('the privileged-users file is read at most once a second, and at once after the clock steps back', (t) => { + const dir = tempDir(t); + let now = AT; + const { gate, privilegedUsersPath } = makeGate(dir, { config: RULES, privileged: '["u1"]', now: () => now }); + now += 2_000; + assert.equal(gate.isPrivilegedUser('u1'), true); + + writeFileSync(privilegedUsersPath, '["u2"]'); + now += 500; + assert.equal(gate.isPrivilegedUser('u2'), false, 'within a second of the last check, the change is not seen yet'); + now += 500; + assert.equal(gate.isPrivilegedUser('u2'), true, 'a second after it, it is'); + + writeFileSync(privilegedUsersPath, '["u3"]'); + now -= 600_000; + assert.equal(gate.isPrivilegedUser('u3'), true, 'a clock stepped back since the last check checks at once'); +}); + +test('a privileged-users file whose text changes is read again whatever its mtime and size, and a chmod that repairs it is noticed', { + skip: NO_CHMOD ? 'needs a file the test user cannot read' : false, +}, (t) => { + const dir = tempDir(t); + const privilegedUsersPath = join(dir, 'privileged.json'); + const second = Math.floor(AT / 1000); // a whole second, so the mtime set back is exactly the one first read + writeFileSync(privilegedUsersPath, '["u1"]'); + utimesSync(privilegedUsersPath, second, second); + let now = AT; + const { gate } = makeGate(dir, { config: RULES, now: () => now }); + const check = (id: string) => { + now += 2_000; // past the reload throttle + return gate.isPrivilegedUser(id); + }; + + // New text, same size, mtime set back: as two writes in one clock tick leave it. + writeFileSync(privilegedUsersPath, '["u2"]'); + utimesSync(privilegedUsersPath, second, second); + assert.equal(statSync(privilegedUsersPath).mtimeMs, second * 1000); + assert.equal(check('u2'), true); + + chmodSync(privilegedUsersPath, 0o000); + const whileUnreadable = check('u2'); + const errorsWhileUnreadable = gate.getStatus().errors; + chmodSync(privilegedUsersPath, 0o644); + assert.equal(whileUnreadable, true, 'the last list that loaded stays'); + assert.equal(errorsWhileUnreadable.length, 1, JSON.stringify(errorsWhileUnreadable)); + + // Readable again, with the same text and stat as before: noticed at the next check. + assert.equal(check('u2'), true); + assert.deepEqual(gate.getStatus().errors, []); +}); + +test('while the agent sleeps, a user added to the privileged-users file wakes it once the file is read again, and no one else does', (t) => { + const dir = tempDir(t); + let now = AT; + const { gate, privilegedUsersPath } = makeGate(dir, { config: RULES, privileged: '["u1", ""]', now: () => now }); + t.after(() => { + gate.clearSleep(); + gate.dispose(); + }); + gate.setSleep(600); + const from = (id: string) => gate.evaluate({ ...INCOMING, tags: ['chat:mention'], metadata: { author: { id } } }).policyName; + + assert.equal(from('u2'), 'asleep'); + assert.equal(from(''), 'asleep', 'an empty id is no one'); + assert.equal(from('u1'), 'mentions', 'a privileged user is let through to the rules'); + + writeFileSync(privilegedUsersPath, '["u2"]'); + now += 2_000; + assert.equal(from('u2'), 'mentions'); + assert.equal(from('u1'), 'asleep'); +}); + +test('gate_status reads both files again before it answers, so a repair shows without any event', async (t) => { + const dir = tempDir(t); + let now = AT; + const { gate, configPath, privilegedUsersPath } = makeGate(dir, { config: RULES, privileged: '["u1", ', now: () => now }); + writeFileSync(configPath, BROKEN); + const status = async () => (await gate.handleToolCall()).data!; + + assert.equal((await status()).errors.length, 2, 'both files are read, and neither loads'); + + writeFileSync(configPath, renamedRules('mentionz')); + writeFileSync(privilegedUsersPath, '["u1"]'); + (gate as unknown as { lastReloadCheck: number }).lastReloadCheck = 0; + now += 2_000; + const repaired = await status(); + assert.deepEqual(repaired.errors, []); + assert.deepEqual(repaired.policies.map((p) => p.name), ['mentionz']); +}); + +test("a gate.json that won't load and a privileged-users file that won't load each show their own error", (t) => { + const dir = tempDir(t); + const { gate, configPath } = makeGate(dir, { config: RULES, privileged: '{"userIds": [' }); + writeFileSync(configPath, BROKEN); + assert.deepEqual(gate.listPolicyNames(), ['mentions'], 'the rules in force stay'); + + const errors = gate.getStatus().errors; + assert.equal(errors.length, 2, JSON.stringify(errors)); + assert.ok(errors[1]!.startsWith('Failed to load privileged-users file'), 'the gate.json load kept the privileged-users error'); + + writeFileSync(configPath, JSON.stringify(RULES)); + assert.deepEqual(gate.listPolicyNames(), ['mentions'], 'gate.json loads again'); + assert.deepEqual(gate.getStatus().errors, [errors[1]], 'and that clears only its own error'); +}); + +test('wake_add_rule and wake_remove_rule tell the agent where the copy of an unreadable gate.json is', async (t) => { + const dir = mkdtempSync(join(tmpdir(), 'gate-unreadable-')); + const configPath = join(dir, 'gate.json'); + const membrane = new MockMembrane(); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.', proseRouting: 'disabled' }], + modules: [], + gate: { configPath, config: RULES }, + }); + t.after(async () => { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + }); + + /** One turn making `call`, over a gate.json that won't load; the result the model is sent. */ + const callOverUnreadable = async (call: { id: string; name: string; input: Record }, broken: string) => { + writeFileSync(configPath, broken); + membrane.pushResponse(createMockResponse([{ type: 'tool_use', ...call }], 'tool_use')); + membrane.pushResponse(createMockResponse([])); + assert.equal(framework.nudgeAgent('scout').ok, true); + await framework.runUntilIdle(); + const [round] = membrane.lastStream!.receivedToolResults as Array>; + return round![0]!; + }; + const added = await callOverUnreadable({ id: 'add', name: 'wake_add_rule', input: { name: 'ambient', behavior: 'defer' } }, BROKEN); + const removed = await callOverUnreadable({ id: 'remove', name: 'wake_remove_rule', input: { name: 'ambient' } }, `${BROKEN} "again"`); + + const [first, second] = keptFiles(dir); + assert.ok(first && second, `two kept copies: ${JSON.stringify(keptFiles(dir))}`); + assert.equal(readFileSync(join(dir, first), 'utf-8'), BROKEN); + assert.equal(readFileSync(join(dir, second), 'utf-8'), `${BROKEN} "again"`); + const note = (kept: string) => new RegExp( + `^gate\\.json couldn't be read \\(.*\\), so a copy of it was kept at ${escaped(join(dir, kept))}, 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\\.$', + ); + assert.equal(added.isError, false); + assert.match(JSON.parse(added.content).keptUnreadable, note(first)); + assert.equal(removed.isError, false); + assert.match(JSON.parse(removed.content).keptUnreadable, note(second)); + assert.deepEqual(onDisk(configPath).policies.map((p) => p.name), ['mentions']); +});