diff --git a/changelog.d/395-gate-bom.fixed.md b/changelog.d/395-gate-bom.fixed.md new file mode 100644 index 000000000..0d69ecdd4 --- /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 + reported an error and didn't apply the file (#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..6c0928de3 --- /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 = '\uFEFF'; + +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); + }); +});