From a665a3f3b6aa5dd46134e4734b1ea9399f315c54 Mon Sep 17 00:00:00 2001 From: Anton Dziatkovskii <194927794+tonydzi@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:11:01 +0100 Subject: [PATCH 1/2] fix(gate): load gate.json and privileged-users files saved with a UTF-8 BOM (#395) readFileSync(..., 'utf-8') keeps a leading U+FEFF and JSON.parse rejects it, so a valid file written by a Windows editor never loaded: the gate kept its previous config with an error. Both readers now go through parseGateJson(), which drops exactly one leading U+FEFF before parsing. The raw text is left unchanged for anything that compares it. Assisted-by: Claude Code / claude-opus-5-5 Machine: A-2022BAYAREA Account: tonydzi Operator: Anton Co-Authored-By: Claude Opus 5.5 --- changelog.d/395-gate-bom.fixed.md | 3 ++ src/gate/event-gate.ts | 13 +++++- test/event-gate-bom.test.ts | 71 +++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 changelog.d/395-gate-bom.fixed.md create mode 100644 test/event-gate-bom.test.ts diff --git a/changelog.d/395-gate-bom.fixed.md b/changelog.d/395-gate-bom.fixed.md new file mode 100644 index 000000000..52d2e4f17 --- /dev/null +++ b/changelog.d/395-gate-bom.fixed.md @@ -0,0 +1,3 @@ +- A `gate.json` or privileged-users file saved as UTF-8 with a leading BOM + now loads; before, `JSON.parse` rejected the invisible U+FEFF and the gate + kept its previous config with an error (#395). diff --git a/src/gate/event-gate.ts b/src/gate/event-gate.ts index bb8bd3d1a..cfcfaa369 100644 --- a/src/gate/event-gate.ts +++ b/src/gate/event-gate.ts @@ -44,6 +44,15 @@ const MAX_TRACE_SNIPPET = 80; const MAX_INFERENCE_BUFFER = 100; /** Minimum interval between filesystem checks for config changes (ms). */ const RELOAD_THROTTLE_MS = 1000; + +/** + * Parse a gate file's text as JSON. Some Windows editors save UTF-8 with a + * leading U+FEFF (BOM), which `readFileSync(…, 'utf-8')` keeps and + * `JSON.parse` rejects; drop exactly one before parsing (#395). + */ +function parseGateJson(text: string): unknown { + return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text); +} /** Minimum debounce value (ms). */ const MIN_DEBOUNCE_MS = 100; /** Maximum debounce value (ms). */ @@ -722,7 +731,7 @@ export class EventGate { const raw = readFileSync(this.configPath, 'utf-8'); const stat = statSync(this.configPath); this.configMtime = stat.mtimeMs; - const config = validateConfig(JSON.parse(raw)); + const config = validateConfig(parseGateJson(raw)); this.configSource = 'file'; this.configErrors = []; return config; @@ -1856,7 +1865,7 @@ export class EventGate { return; } this.privilegedMtime = statSync(this.privilegedUsersPath).mtimeMs; - const raw = JSON.parse(readFileSync(this.privilegedUsersPath, 'utf8')); + 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))); } catch (err) { diff --git a/test/event-gate-bom.test.ts b/test/event-gate-bom.test.ts new file mode 100644 index 000000000..e765e3067 --- /dev/null +++ b/test/event-gate-bom.test.ts @@ -0,0 +1,71 @@ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert'; +import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { EventGate } from '../src/gate/event-gate.js'; +import type { GateConfig } from '../src/gate/types.js'; + +// #395: a gate file saved as UTF-8 with a BOM (some Windows editors do this) +// must load like the same file without one. + +const TMP_DIR = join(import.meta.dirname, '../.test-tmp-gate-bom'); +const BOM = ''; + +function writeWithBom(name: string, value: unknown): string { + const path = join(TMP_DIR, name); + writeFileSync(path, BOM + JSON.stringify(value, null, 2), 'utf8'); + return path; +} + +function makeGate(configPath: string, privilegedUsersPath?: string) { + return new EventGate({ + configPath, + ...(privilegedUsersPath ? { privilegedUsersPath } : {}), + emitTrace: () => {}, + addMessage: () => '', + requestInference: () => {}, + getAgentNames: () => ['agent'], + }); +} + +const config: GateConfig = { + policies: [{ name: 'from-bom-file', match: {}, behavior: 'always' }], + default: 'skip', +}; + +beforeEach(() => { + mkdirSync(TMP_DIR, { recursive: true }); +}); + +afterEach(() => { + if (existsSync(TMP_DIR)) rmSync(TMP_DIR, { recursive: true, force: true }); +}); + +describe('EventGate: UTF-8 BOM in gate files (#395)', () => { + it('loads a gate.json that starts with a BOM', async () => { + const gate = makeGate(writeWithBom('gate.json', config)); + const status = (await gate.handleToolCall()).data!; + assert.deepStrictEqual(status.errors, []); + assert.strictEqual(status.configSource, 'file'); + assert.deepStrictEqual(status.policies.map(p => p.name), ['from-bom-file']); + }); + + it('loads a privileged-users file that starts with a BOM', async () => { + const gatePath = writeWithBom('gate.json', config); + const privPath = writeWithBom('privileged.json', { userIds: ['u-1', 42] }); + const gate = makeGate(gatePath, privPath); + assert.strictEqual(gate.isPrivilegedUser('u-1'), true); + assert.strictEqual(gate.isPrivilegedUser('42'), true); + assert.strictEqual(gate.isPrivilegedUser('u-2'), false); + const status = (await gate.handleToolCall()).data!; + assert.deepStrictEqual(status.errors, []); + }); + + it('still rejects text that is not JSON after the BOM', async () => { + const path = join(TMP_DIR, 'broken.json'); + writeFileSync(path, BOM + BOM + '{}', 'utf8'); + const gate = makeGate(path); + const status = (await gate.handleToolCall()).data!; + assert.strictEqual(status.errors.length, 1); + }); +}); From 9a4178f49278b460cd00609e0bb2a6844aab50b3 Mon Sep 17 00:00:00 2001 From: Anton Dziatkovskii <194927794+tonydzi@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:40:18 -0700 Subject: [PATCH 2/2] test: write the BOM as an escape; changelog: fix the privileged-users "before" Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Anton Dziatkovskii <194927794+tonydzi@users.noreply.github.com> --- changelog.d/395-gate-bom.fixed.md | 4 ++-- test/event-gate-bom.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/changelog.d/395-gate-bom.fixed.md b/changelog.d/395-gate-bom.fixed.md index 52d2e4f17..0d69ecdd4 100644 --- a/changelog.d/395-gate-bom.fixed.md +++ b/changelog.d/395-gate-bom.fixed.md @@ -1,3 +1,3 @@ - A `gate.json` or privileged-users file saved as UTF-8 with a leading BOM - now loads; before, `JSON.parse` rejected the invisible U+FEFF and the gate - kept its previous config with an error (#395). + now loads. Before, `JSON.parse` rejected the invisible U+FEFF, and the gate + reported an error and didn't apply the file (#395). diff --git a/test/event-gate-bom.test.ts b/test/event-gate-bom.test.ts index e765e3067..6c0928de3 100644 --- a/test/event-gate-bom.test.ts +++ b/test/event-gate-bom.test.ts @@ -9,7 +9,7 @@ import type { GateConfig } from '../src/gate/types.js'; // must load like the same file without one. const TMP_DIR = join(import.meta.dirname, '../.test-tmp-gate-bom'); -const BOM = ''; +const BOM = '\uFEFF'; function writeWithBom(name: string, value: unknown): string { const path = join(TMP_DIR, name);