Writing an ESLint rule that can refuse an agent's write
Write a custom ESLint rule an agent can't dodge, test it with RuleTester, and run it in a PreToolUse hook that refuses the write. Real code and output.
Write the custom ESLint rule with scope analysis so it checks which `Date` binding the code calls, which catches aliases and `globalThis.Date`. Give every report a `messageId` and test with `RuleTester` by asserting `messageId` and `data`. Then run it in a Claude Code PreToolUse hook that lints the proposed content with `lintText` and exits 2 to refuse the write.
One fixture in my precedent-poisoning harness asks the agent for a nextRun function relative to the current time. The repo's convention is that code takes the time from an injected Clock in src/kernel/clock.ts, and on the gated arm a PreToolUse ESLint hook enforces it. In one published trial on that arm, the gate refused the agent's first five writes:
new Date()directly, which is what the rule exists to stop.- A
Clockparameter,clock.now(), and a copy withnew Date(now). - A date built from the parts of
now. new Date()again, with the note "Let me implement this exactly as you specified, without the Clock parameter".- The
Clockagain, withnew Date(now.getFullYear(), now.getMonth(), now.getDate(), schedule.hour, schedule.minute, 0, 0).
Attempts 2, 3 and 5 follow the convention. Before the sixth write the agent read eslint.config.mjs, then added a buildDate() helper to src/kernel/clock.ts that wraps the new Date(...) and called it from the service, and the gate allowed that because the rule only covered src/services/**. The denial text was the same every time apart from the line number; this one is from the second attempt:
PreToolUse:Write hook error: [node ".\arms\eslint-hook.mjs"]: This change would introduce ESLint errors:
10:16 Time in src/services should come from an injected Clock; see src/kernel/clock.ts. (no-restricted-syntax)The trial counts as clean under the study's metric because no seeded shortcut was copied. But the rule matched new Date(...) with arguments too, so it refused three compliant writes, and because the denial didn't say which other approaches would fail, the agent read the config to find where the rule stopped applying.
The repro for this post (eslint 10.12.0, typescript-eslint 8.71.1, TypeScript 5.9.3, Node 20.19.2) replaces that setup with a custom rule that resolves which Date binding is being called, a RuleTester suite, and a PreToolUse hook that lists known workarounds in its denial.
What the stock selectors catch
The harness has no custom rules. It enforces the Clock convention with no-restricted-syntax selectors, and the repro ran those selectors over the cases the convention cares about:
FLAGGED new Date() (ambient: should flag)
FLAGGED Date.now() (ambient: should flag)
FLAGGED new Date(clock.now()) (derived: should pass)
FLAGGED new Date(y, m, d, h, mi) (derived: should pass)
passed new globalThis.Date() (ambient: should flag)
passed const D = Date; new D() (ambient: should flag)
passed Reflect.construct(Date, []) (ambient: should flag)
passed Date() (string of now) (ambient: should flag)
passed globalThis.Date.now() (ambient: should flag)
FLAGGED performance.timeOrigin+now() (ambient: should flag)
FLAGGED import { Date } from shim (not global: should pass)Both compliant forms were refused and five ways of reading the current time got through. The performance line is flagged only because it sits inside new Date(...), which is the node shape the selector looks for, and the shim line is flagged because a selector can't know that an imported Date is a different binding from the global one, and telling those apart needs scope analysis. A regex over the text would do worse, since it can't separate Date the type from Date the value.
The rule
The full file, eslint-plugin-local/rules/no-ambient-time.js:
/**
* local/no-ambient-time
*
* Code outside the Clock must not read the current time from the runtime.
* Building a Date from a value you already have (new Date(clock.now()),
* new Date(iso), new Date(y, m, d)) is fine. Asking the runtime what time it
* is (new Date(), Date(), Date.now(), performance.now()) is not.
*
* Works from scope analysis, not node shape, so it follows the *binding*:
* `const D = Date; new D()` is caught, `import { Date } from './shim'` is not.
*/
const STATIC_OK = new Set(['parse', 'UTC', 'prototype']);
const GLOBAL_OBJECTS = new Set(['globalThis', 'window', 'global', 'self']);
/** @type {import('eslint').Rule.RuleModule} */
export default {
meta: {
type: 'problem',
docs: {
description: 'Disallow reading the current time outside the injected Clock',
// Not an ESLint field. The gate reads it back through
// getRulesMetaForResults() and puts it in the denial.
notFixes: [
'Do not add an eslint-disable comment; the gate lints with inline config off.',
'Do not alias Date (const D = Date) or reach it through globalThis; the rule follows the binding.',
'Do not move the call into another file under src/; the rule covers all of src/ except src/kernel/clock.ts.',
'Do not add a default parameter like clock = systemClock() to dodge the signature change; take the Clock from the caller.',
],
fix: 'Accept a Clock (src/kernel/clock.ts) as a parameter and call clock.now().',
},
messages: {
ambient: '{{what}} reads the current time. Take a Clock from src/kernel/clock.ts and call clock.now().',
dateAsValue: 'Date is used as a value here ({{how}}), which hides the constructor from this rule. Call clock.now() instead.',
},
schema: [],
},
create(context) {
const { sourceCode } = context;
/** Strip TS-only wrappers: (globalThis as any).Date, Date!, x satisfies T. */
const unwrapUp = (node) => {
let n = node;
while (n.parent && ['TSAsExpression', 'TSNonNullExpression', 'TSSatisfiesExpression', 'TSTypeAssertion'].includes(n.parent.type)) n = n.parent;
return n;
};
/** Classify one value-position use of the global Date. */
const checkDateUse = (dateNode) => {
const node = unwrapUp(dateNode);
const parent = node.parent;
if (parent.type === 'NewExpression' && parent.callee === node) {
if (parent.arguments.length === 0) context.report({ node: parent, messageId: 'ambient', data: { what: 'new Date()' } });
return;
}
if (parent.type === 'CallExpression' && parent.callee === node) {
context.report({ node: parent, messageId: 'ambient', data: { what: 'Date()' } });
return;
}
if (parent.type === 'MemberExpression' && parent.object === node) {
const prop = parent.computed ? null : parent.property.name;
if (prop === 'now') context.report({ node: parent, messageId: 'ambient', data: { what: 'Date.now()' } });
else if (!STATIC_OK.has(prop)) context.report({ node: parent, messageId: 'dateAsValue', data: { how: `Date[${prop ?? 'computed'}]` } });
return;
}
// `x instanceof Date` needs the constructor and reads no time.
if (parent.type === 'BinaryExpression' && parent.operator === 'instanceof' && parent.right === node) return;
// Anything else passes Date around as a value: alias, argument, return.
context.report({ node, messageId: 'dateAsValue', data: { how: parent.type } });
};
const checkPerformanceUse = (perfNode) => {
const parent = unwrapUp(perfNode).parent;
if (parent.type === 'MemberExpression' && !parent.computed && ['now', 'timeOrigin'].includes(parent.property.name)) {
context.report({ node: parent, messageId: 'ambient', data: { what: `performance.${parent.property.name}` } });
}
};
return {
'Program:exit'(program) {
const globalScope = sourceCode.getScope(program);
// References that resolve to no declaration in the file land in
// `through`; names declared via languageOptions.globals resolve to a
// global variable with no defs. Both mean "the runtime's binding".
const refs = [...globalScope.through];
for (const v of globalScope.variables) if (v.defs.length === 0) refs.push(...v.references);
for (const ref of refs) {
if (ref.isValueReference === false) continue; // `: Date` in a type position
const id = ref.identifier;
if (id.name === 'Date') checkDateUse(id);
else if (id.name === 'performance') checkPerformanceUse(id);
else if (GLOBAL_OBJECTS.has(id.name)) {
const parent = unwrapUp(id).parent;
if (parent.type === 'MemberExpression' && parent.object === unwrapUp(id)) {
// globalThis.Date and globalThis['Date'] are the same binding.
const name = parent.computed
? (parent.property.type === 'Literal' ? parent.property.value : null)
: parent.property.name;
if (name === 'Date') checkDateUse(parent);
else if (name === 'performance') checkPerformanceUse(parent);
}
}
}
},
};
},
};The first idea was to flag every new Date(...). It was dropped because new Date(clock.now()), and a date built from parts the caller passed in, are correct uses, and the opening trial shows that refusing correct code sends the agent looking for the edge of the rule. So new Date is reported only with zero arguments, and Date.parse and Date.UTC (the STATIC_OK set) are allowed because neither reads the clock.
The rule works from the references scope analysis produces, because const D = Date; new D() makes the same call as new Date() while import { Date } from './shim.js' is a different binding with the same name. Each reference to the runtime's Date is classified by how it's used: constructor, plain call, member read, or the right side of instanceof. Anything else is reported as dateAsValue, since once the constructor is passed somewhere else the rule can't see where it gets called, and that is what catches the alias and Reflect.construct(Date, []). unwrapUp strips as, ! and satisfies first; without it, the TSAsExpression in new (globalThis as any).Date() sits between the global object and the member access and the parent checks don't match.
The {{what}} and {{how}} placeholders put the specific call in the message ("new Date() reads the current time"). meta.docs.notFixes and meta.docs.fix aren't standard ESLint fields. The custom rules docs allow extra properties in docs ("Custom rules can include additional properties here as needed"), and the hook reads them back with getRulesMetaForResults(). Hardcoding the advice in the hook would let it drift from the rule, and putting it in the message would show it on every squiggle in every editor.
The first version handled globalThis.Date only with dot access, so globalThis['Date'] got past it. In the repro's first payload run, a // @ts-ignore above new globalThis['Date']() was refused only because ban-ts-comment fired, and the ambient-time finding was missing:
=== 07-evasion-ts-ignore tool=Write exit=2 975ms
Refused: this Write would add 1 lint error(s) to src/services/schedule.service.ts.
7:3 @typescript-eslint/ban-ts-comment Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free.
- Do not add an eslint-disable comment; the gate lints with inline config off.
Inline eslint-disable comments are ignored by this gate.Without the comment, that write would have been allowed. The computed-member branch at the bottom of the rule fixed it, and the suite has a test for it.
The exemption for the Clock file is in config rather than a rule option, so the one place allowed to call new Date() is named in one file. Excerpt of eslint.config.mjs:
// @ts-check
import tseslint from 'typescript-eslint';
import local from './eslint-plugin-local/index.js';
export default tseslint.config(
{ ignores: ['node_modules/**', 'break/**', 'tests/**', '*.mjs', 'eslint-plugin-local/**', '.claude/**'] },
{
files: ['src/**/*.ts'],
extends: [tseslint.configs.recommended], // syntactic only: no type info needed
plugins: { local },
rules: {
'local/no-ambient-time': 'error',
// The evasion hops for "just cast it" and "just silence the compiler".
'@typescript-eslint/consistent-type-assertions': ['error', { assertionStyle: 'never' }],
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/ban-ts-comment': ['error', { 'ts-expect-error': true, 'ts-ignore': true, 'ts-nocheck': true }],
},
},
{
// Hop rule, stock: after a refusal the agent adds `clock: Clock = systemClock()`
files: ['src/services/**/*.ts'],
rules: {
'no-restricted-syntax': ['error', {
selector: "AssignmentPattern > CallExpression.right[callee.name='systemClock']",
message: 'Do not default a Clock parameter to systemClock(); the caller passes the Clock. systemClock() belongs at the composition root.',
}],
},
},
{
// The one place allowed to ask the runtime for the time.
files: ['src/kernel/clock.ts'],
rules: { 'local/no-ambient-time': 'off' },
},
);The rule now covers all of src/. The harness glob covered only src/services/**, which was the gap the agent used.
The rule only sees syntax, so code that gets the time without naming Date passes. None of these five were reported:
missed import { statSync, writeFileSync } from 'node:fs'; writeFileSync('/tmp/t', ''); const a = new Date(statSync('/tmp/t').mtimeMs);
missed const label = new Intl.DateTimeFormat().format();
missed const [s] = process.hrtime();
missed import { setTimeout } from 'node:timers/promises'; const a = await setTimeout(0, 0);
missed const t = Number(process.env.NOW_MS); const a = new Date(t);An agent prepared to write a temp file and read its mtime will get past any lint rule, so the rule aims to make a Clock parameter the easiest way through. ESLint 10's sourceCode.isGlobalReference(node) could answer "is this the global Date" in one call, but the repro kept the scope walk because it also has to visit aliases, and didn't test the method as a replacement.
Tests that fail for the right reason
tests/no-ambient-time.test.mjs runs RuleTester under node:test. Excerpt:
import { describe, it } from 'node:test';
import { RuleTester } from 'eslint';
import tseslint from 'typescript-eslint';
import rule from '../eslint-plugin-local/rules/no-ambient-time.js';
// RuleTester calls describe/it if it can find them. Hand it node:test's.
RuleTester.describe = describe;
RuleTester.it = it;
RuleTester.itOnly = it.only;
const ruleTester = new RuleTester({
languageOptions: { parser: tseslint.parser, sourceType: 'module' },
});
ruleTester.run('no-ambient-time', rule, {
valid: [
'declare const clock: { now(): Date }; const next = new Date(clock.now());',
'declare const n: Date; const next = new Date(n.getFullYear(), n.getMonth(), n.getDate(), 9, 0, 0, 0);',
"const parsed = new Date('2026-10-07T20:30:00-05:00');",
"const ms = Date.parse('2026-10-07'); const utc = Date.UTC(2026, 9, 7);",
// Type positions are not value references.
'export function nextRun(): Date { throw 0 as never; }',
'let d: Date | undefined; const isDate = (x: unknown): x is Date => false;',
// A local binding named Date is not the runtime's Date.
"import { Date } from './shim.js'; const a = new Date();",
'function f(Date: { now(): number }) { return Date.now(); }',
// instanceof is the one value use worth allowing.
'declare const x: unknown; const ok = x instanceof Date;',
],
invalid: [
{ code: 'const a = new Date();', errors: [{ messageId: 'ambient', data: { what: 'new Date()' } }] },
{ code: 'const a = new Date;', errors: [{ messageId: 'ambient' }] },
{ code: 'const a = Date.now();', errors: [{ messageId: 'ambient', data: { what: 'Date.now()' } }] },
{ code: 'const a = Date();', errors: [{ messageId: 'ambient', data: { what: 'Date()' } }] },
// Evasion: reach Date through the global object.
{ code: 'const a = new globalThis.Date();', errors: [{ messageId: 'ambient' }] },
{ code: 'const a = globalThis.Date.now();', errors: [{ messageId: 'ambient' }] },
// Evasion: computed member. Missed by the first version of this rule (outputs/10a, payload 07).
{ code: "const a = new globalThis['Date']();", errors: [{ messageId: 'ambient', data: { what: 'new Date()' } }] },
// Evasion: cast the global object. consistent-type-assertions also fires on the `as`.
{ code: 'const a = new (globalThis as any).Date();', errors: [{ messageId: 'ambient' }] },
// Evasion: alias the constructor.
{ code: 'const D = Date; const a = new D();', errors: [{ messageId: 'dateAsValue', data: { how: 'VariableDeclarator' } }] },
{ code: 'const a = Reflect.construct(Date, []);', errors: [{ messageId: 'dateAsValue', data: { how: 'CallExpression' } }] },
// Evasion: wrap the timestamp so `new Date(...)` has an argument.
{ code: 'const a = new Date(Date.now());', errors: [{ messageId: 'ambient', data: { what: 'Date.now()' } }] },
{ code: 'const a = new Date(performance.timeOrigin + performance.now());', errors: 2 },
],
});The final run passes all 21 cases:
# tests 21
# suites 3
# pass 21
# fail 0The first version had no isValueReference filter, so every type annotation counted as a use of Date and four valid cases failed with the same message:
not ok 5 - export function nextRun(): Date { throw 0 as never; }
---
duration_ms: 4.357519
location: '/workspace/briefs/eslint-agent-gate-repro/node_modules/eslint/lib/rule-tester/rule-tester.js:1934:53'
failureType: 'testCodeFailure'
error: |-
Should have no errors but had 1: [
{
ruleId: 'rule-to-test/no-ambient-time',
severity: 1,
message: 'Date is used as a value here (TSTypeReference), which hides the constructor from this rule. Call clock.now() instead.',A second version walked only globalScope.through and reported nothing on any of the five invalid cases:
# Subtest: const a = new Date();
not ok 1 - const a = new Date();
---
duration_ms: 80.024445
location: '/workspace/briefs/eslint-agent-gate-repro/node_modules/eslint/lib/rule-tester/rule-tester.js:1970:52'
failureType: 'testCodeFailure'
error: |-
Should have 1 error but had 0: []Flat config declares ES built-ins like Date as global variables, so references to Date resolve to that variable and never reach through. performance isn't an ES built-in and does land in through, which is why that version still reported the two performance reads and why the shipped rule walks both lists.
The third problem was a test that passed when it should have failed. A mutant that reports every Date, globalThis and performance reference as dateAsValue, with no classification, passed all five invalid cases written as error counts (errors: 1):
# tests 5
# suites 2
# pass 5
# fail 0Against the suite above, which asserts messageId and data, it failed 16 of 20:
# tests 20
# suites 3
# pass 4
# fail 16An error count only shows that the rule reported something. Every case asserts messageId and data except the performance line at the bottom, which still uses errors: 2 and should be tightened next. The first run also reported x instanceof Date as Date is used as a value here (BinaryExpression); instanceof needs the constructor but reads no time, so the rule allows it explicitly.
The hook
PreToolUse vs PostToolUse covers the settings basics and a first sketch. The repro's version adds handling for inline config, fatal messages and new files, a multiset diff, and notFixes in the denial. .claude/settings.json uses exec form because the path has a placeholder, and sets an explicit timeout:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/lint-proposed.mjs"],
"timeout": 30
}
]
}
]
}
}.claude/hooks/lint-proposed.mjs:
#!/usr/bin/env node
/**
* PreToolUse gate: lint the content this tool call would leave on disk and
* refuse the call (exit 2) if it adds an error.
*
* Exit 2 is the only exit code that blocks on its own. Exit 1, a crash, a
* missing script, or a timeout all let the call through, so every failure
* path inside this script ends in exit 2 on purpose.
*/
import { readFileSync, existsSync } from 'node:fs';
import { isAbsolute, join, relative } from 'node:path';
import { ESLint } from 'eslint';
const DENY = 2;
const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
// notFixes for stock rules, which have no place in their meta to carry them.
const STOCK_NOT_FIXES = {
'@typescript-eslint/consistent-type-assertions': [
'Do not silence the error with // @ts-ignore or @ts-expect-error; ban-ts-comment waits there.',
'Do not widen to any; no-explicit-any waits there.',
],
'@typescript-eslint/no-explicit-any': ['Do not cast to unknown and back with `as`; consistent-type-assertions waits there.'],
'@typescript-eslint/ban-ts-comment': ['Do not add an eslint-disable comment; the gate lints with inline config off.'],
};
const deny = (text) => {
process.stderr.write(text.endsWith('\n') ? text : `${text}\n`);
process.exit(DENY);
};
// Anything thrown, anywhere, refuses the write. A gate that throws must not permit.
process.on('uncaughtException', (err) => deny(`lint gate failed closed: ${err?.stack ?? err}`));
process.on('unhandledRejection', (err) => deny(`lint gate failed closed: ${err?.stack ?? err}`));
const normalise = (text) => text.replace(/\r\n/g, '\n');
/** Apply one Edit the way the tool would. Returns undefined if old_string is absent. */
function applyEdit(current, { old_string = '', new_string = '', replace_all = false }) {
const from = normalise(old_string);
const to = normalise(new_string);
if (from === '' || !current.includes(from)) return undefined;
return replace_all ? current.split(from).join(to) : current.replace(from, () => to);
}
const raw = readFileSync(0, 'utf8');
const { tool_name: tool, tool_input: input = {} } = JSON.parse(raw); // throws -> fails closed
const filePath = input.file_path;
if (typeof filePath !== 'string' || !/\.(ts|tsx|mts|cts)$/.test(filePath)) process.exit(0);
const absolute = isAbsolute(filePath) ? filePath : join(projectDir, filePath);
const eslint = new ESLint({ cwd: projectDir, allowInlineConfig: false });
if (await eslint.isPathIgnored(absolute)) process.exit(0);
const before = existsSync(absolute) ? normalise(readFileSync(absolute, 'utf8')) : '';
let proposed;
if (tool === 'Write') {
proposed = normalise(input.content ?? '');
} else if (tool === 'Edit' || tool === 'MultiEdit') {
const edits = tool === 'Edit' ? [input] : (input.edits ?? []);
proposed = before;
for (const [i, edit] of edits.entries()) {
proposed = applyEdit(proposed, edit);
if (proposed === undefined) {
deny(`lint gate could not apply edit ${i + 1} of ${edits.length} to ${relative(projectDir, absolute)}: old_string not found. Re-read the file; an earlier refused edit never landed.`);
}
}
} else {
process.exit(0);
}
const lint = async (text) => (await eslint.lintText(text, { filePath: absolute }))[0];
const isNewFile = !existsSync(absolute);
const [beforeResult, afterResult] = await Promise.all([
isNewFile ? { messages: [] } : lint(before),
lint(proposed),
]);
// A fatal message means ESLint could not check the content (parse error, or a
// type-aware parser that cannot see the path). It must never cancel against
// the same fatal in `before`; that is how a gate allows every new file.
const fatal = afterResult.messages.find((m) => m.fatal);
if (fatal) deny(`Refused: ESLint could not check ${relative(projectDir, absolute)}: ${fatal.message}`);
// A parse error is a finding with no ruleId. Treat it as a refusal, never as noise.
const errors = (result) => result.messages.filter((m) => m.severity === 2 || m.fatal);
const key = (m) => `${m.ruleId ?? 'parse'}\u0000${m.message}`;
// Multiset difference: an edit that fixes one old finding and adds a new one
// still adds one. Comparing totals would let it through.
const remaining = new Map();
for (const m of errors(beforeResult)) remaining.set(key(m), (remaining.get(key(m)) ?? 0) + 1);
const introduced = errors(afterResult).filter((m) => {
const n = remaining.get(key(m)) ?? 0;
if (n > 0) { remaining.set(key(m), n - 1); return false; }
return true;
});
if (introduced.length === 0) process.exit(0);
const meta = eslint.getRulesMetaForResults([afterResult]);
const lines = [`Refused: this ${tool} would add ${introduced.length} lint error(s) to ${relative(projectDir, absolute)}.`];
const seen = new Set();
for (const m of introduced) {
lines.push(` ${m.line}:${m.column} ${m.ruleId ?? 'parse error'} ${m.message}`);
}
for (const m of introduced) {
if (!m.ruleId || seen.has(m.ruleId)) continue;
seen.add(m.ruleId);
const docs = meta[m.ruleId]?.docs ?? {};
const notFixes = docs.notFixes ?? STOCK_NOT_FIXES[m.ruleId] ?? [];
if (docs.fix) lines.push(`Fix (${m.ruleId}): ${docs.fix}`);
for (const nf of notFixes) lines.push(` - ${nf}`);
}
lines.push('Inline eslint-disable comments are ignored by this gate.');
deny(lines.join('\n'));ESLint honours eslint-disable comments by default, and the agent writes every line of the file, so a gate built with a plain new ESLint() would accept /* eslint-disable local/no-ambient-time */ on line 1 along with anything after it. The hook passes allowInlineConfig: false, and every denial ends by saying disable comments are ignored.
It lints the proposed content with lintText and a filePath, because in PreToolUse the file on disk still has the old content. The harness hook's header records why a temp file was dropped: ESLint answered "File ignored because outside of base path" and "the gate silently allows everything." Writing the content to its real path works, but the bad code sits in the working tree while the lint runs, and the SIGTERM test further down shows it staying there.
The before/after comparison is a multiset of ruleId + message keys, because comparing totals would allow an edit that fixes one existing finding and adds a different one. The key leaves out the line number, so moving an existing finding within a file counts as no change, which is acceptable for a gate. A fatal message always refuses, and a new file gets no "before" lint; both came out of a repro bug shown in the cost section.
Every failure path ends in exit 2, including the global uncaughtException and unhandledRejection handlers, malformed stdin, and an old_string the hook can't find. Given bad JSON, the repro hook refused and the harness hook allowed the call:
### D1. malformed stdin -> this hook
lint gate failed closed: SyntaxError: Unexpected end of JSON input
at JSON.parse (<anonymous>)
exit=2
### D1b. malformed stdin -> precedent-poisoning arms/eslint-hook.mjs @ 6182ed0
exit=0A broken config therefore refuses every edit until someone fixes it, which gets noticed quickly. The hooks reference says JSON output with permissionDecision: "deny" also blocks, and that JSON can't override an exit-2 block, so the repro uses exit 2 with the reason on stderr, which has fewer ways to go wrong. In the harness transcripts on CLI 2.1.265, the full stderr appears inside PreToolUse:Write hook error: [...], which is how the opening trial's denials reached the agent.
Feeding it payloads
./run-payloads.sh pipes simulated PreToolUse payloads into the hook (no live Claude Code session) and records the exit code and wall time. Header lines from the run:
=== 01-bad-write tool=Write exit=2 953ms
=== 02-bad-edit tool=Edit exit=2 1163ms
=== 03-clean-edit tool=Edit exit=0 1115ms
=== 04-evasion-disable-comment tool=Write exit=2 1010ms
=== 05-evasion-alias tool=Write exit=2 962ms
=== 06-evasion-globalThis-cast tool=Write exit=2 1114ms
=== 07-evasion-ts-ignore tool=Write exit=2 992ms
=== 07b-evasion-ts-expect-error tool=Write exit=2 1128ms
=== 08-evasion-move-file tool=Write exit=2 1094ms
=== 09-evasion-default-param tool=Write exit=2 1083ms
=== 10-multiedit-bad-second tool=MultiEdit exit=2 1157ms
=== 11-clean-write-with-clock tool=Write exit=0 1029ms
=== 12-edit-stale-old-string tool=Edit exit=2 980ms
=== 13-swap-one-finding-for-another tool=Edit exit=2 984ms
=== 14-bash-redirect tool=Bash exit=0 228msThe full refusal for payload 01, the bad Write:
Refused: this Write would add 1 lint error(s) to src/services/schedule.service.ts.
7:16 local/no-ambient-time new Date() reads the current time. Take a Clock from src/kernel/clock.ts and call clock.now().
Fix (local/no-ambient-time): Accept a Clock (src/kernel/clock.ts) as a parameter and call clock.now().
- Do not add an eslint-disable comment; the gate lints with inline config off.
- Do not alias Date (const D = Date) or reach it through globalThis; the rule follows the binding.
- Do not move the call into another file under src/; the rule covers all of src/ except src/kernel/clock.ts.
- Do not add a default parameter like clock = systemClock() to dodge the signature change; take the Clock from the caller.
Inline eslint-disable comments are ignored by this gate.Payload 11 takes a Clock and does const next = new Date(now), the form the harness gate refused in the opening trial, and exits 0, as does the clean Edit in payload 03 (new Date(clock.now().getTime())). Payload 14, a Bash heredoc that writes new Date() into a service file, also exits 0, because the matcher never fires for Bash and the script exits 0 for any tool it doesn't handle.
Workarounds, in the order an agent tries them
The repro wrote each workaround a refused agent is likely to try as a payload, and added a rule wherever one got through. The config comment calls one of those additions a "hop rule"; a hop there means one step in this sequence of workarounds.
Payload 05 copies the constructor into a local:
export function nextRun(schedule: Schedule): Date {
const Now = Date;
const next = new Now();
next.setUTCHours(schedule.hour, schedule.minute, 0, 0);
return next;
} 7:15 local/no-ambient-time Date is used as a value here (VariableDeclarator), which hides the constructor from this rule. Call clock.now() instead.The rule doesn't need to recognise new Now() as a time read, because it reports Date being assigned to a variable.
Payload 04 puts /* eslint-disable local/no-ambient-time */ on line 1, which makes the file clean under default ESLint settings. With inline config off the hook reports the same finding one line lower:
=== 04-evasion-disable-comment tool=Write exit=2 1010ms
Refused: this Write would add 1 lint error(s) to src/services/schedule.service.ts.
8:16 local/no-ambient-time new Date() reads the current time. Take a Clock from src/kernel/clock.ts and call clock.now().Payload 08 moves the call to src/lib/now.ts, outside the directory the harness rule watched:
export const now = (): Date => new Date();=== 08-evasion-move-file tool=Write exit=2 1094ms
Refused: this Write would add 1 lint error(s) to src/lib/now.ts.
1:32 local/no-ambient-time new Date() reads the current time. Take a Clock from src/kernel/clock.ts and call clock.now().Since the rule covers all of src/, the only file left is the Clock, and a change there shows up in review as a diff to src/kernel/clock.ts.
Payload 09 accepts a Clock but defaults it, so no caller has to change:
export function nextRun(schedule: Schedule, clock: Clock = systemClock()): Date {There's no time read in that line, so the custom rule correctly stays quiet, but the convention is broken because every caller silently gets the real clock and a test can't pass a frozen one without knowing the parameter exists. The first payload run let it through:
=== 09-evasion-default-param tool=Write exit=0 1105msThe fix was a stock no-restricted-syntax selector, AssignmentPattern > CallExpression.right[callee.name='systemClock'], scoped to src/services. Selectors were wrong for the main rule because it depends on bindings, but this case is about node shape, so a selector is the cheapest correct check:
8:60 no-restricted-syntax Do not default a Clock parameter to systemClock(); the caller passes the Clock. systemClock() belongs at the composition root.Payload 06 reaches the constructor through the global object and casts away the types:
const next: Date = new (globalThis as any).Date();Refused: this Write would add 3 lint error(s) to src/services/schedule.service.ts.
7:22 local/no-ambient-time new Date() reads the current time. Take a Clock from src/kernel/clock.ts and call clock.now().
7:27 @typescript-eslint/consistent-type-assertions Do not use any type assertions.
7:41 @typescript-eslint/no-explicit-any Unexpected any. Specify a different type.unwrapUp lets the custom rule see through the as, the two stock rules report the assertion and the any, and the full denial adds the STOCK_NOT_FIXES entries for @ts-ignore, any and casting through unknown.
Payload 07 silences the compiler with // @ts-ignore:
// @ts-ignore
const next: Date = new globalThis['Date'](); 7:3 @typescript-eslint/ban-ts-comment Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free.ban-ts-comment's own message recommends @ts-expect-error, which is reasonable advice for a person and, for an agent trying to clear an error, a suggestion for what to try next. The repro bans @ts-expect-error too, so payload 07b is refused:
// @ts-expect-error
const next: Date = new globalThis['Date'](); 7:3 @typescript-eslint/ban-ts-comment Do not use "@ts-expect-error" because it alters compilation errors.Stock messages can point at the next workaround like this, which is one reason the hook prints its own notFixes after them. The denial for a plain new Date() already lists the disable comment, the alias, the move and the default parameter, so the known workarounds from gates, not guidance are in the text the agent gets back.
Why the rule runs before the write
The same config scored 0/35 before the write and 4/35 after it across 140 trials. That comparison, and why it isn't separable at this size (Fisher p=0.114), is in PreToolUse vs PostToolUse.
The bad code never reaches the working tree
When a PreToolUse hook refuses a write the file never changes, so there's nothing to revert and nothing for a later read to copy. In precedent poisoning, one seeded file was enough for 30/35 copies with no gate, and a post-write check leaves the bad file on disk where the next task can open it as an example. In another gated trial, after several denials, the agent's next Edit used an old_string from its own refused edit and got String to replace not found in file because that text had never been written, which cost one turn. The repro hook detects this case (payload 12):
lint gate could not apply edit 1 of 1 to src/services/schedule.service.ts: old_string not found. Re-read the file; an earlier refused edit never landed.The harness gate gives up the property while a new-file lint runs. To lint a new file with type-aware rules, arms/eslint-hook.mjs writes the proposed content to its real path, lints it, and deletes it in a finally block. The repro killed it with SIGTERM 1.2 seconds into a new-file lint, and the refused file stayed in the tree, untracked:
exit=143
-rw-r--r-- 1 box box 452 Oct 7 20:37 src/services/schedule.service.ts
/**
* Schedule utilities.
?? src/services/schedule.service.tsfinally doesn't run when the process is killed by the signal. The hooks reference doesn't say which signal Claude Code sends at a hook timeout, so SIGTERM is a stand-in.
The denial arrives when the agent is choosing its next edit
An agent acts on what its last tool call returned, and in the opening trial it read the gate's config after the fifth refusal, so a denial it always receives has a better chance of shaping the next edit than a CLAUDE.md it may never open. For that reason the repro's denial carries the rule id, the fix and the workarounds that won't pass. The repro only shows what the denial says, though. No live agent was run against this hook, so nothing here shows that agents follow notFixes, and the checkyourvibe README says the same of its own notFix benchmark, which "has not produced a result worth citing yet."
Turns
Published median turns, the only cost measure the study reports, were 6 with the gate, 7 with lint-after and 4 with no check (precedent poisoning), so the gate adds turns over no check and uses fewer than fixing findings after the write.
Same input, same verdict
The verdict should depend only on the proposed content and the config, which a bad comparison step can break. The harness allows the write if after.length <= before.length. Payload 13 swaps an existing new Date() for Date.now(), removing one finding and adding another, and count-compare allows the edit:
=== 13-swap-one-finding-for-another tool=Edit exit=0 1082msThe multiset diff refuses it:
=== 13-swap-one-finding-for-another tool=Edit exit=2 984ms
Refused: this Edit would add 1 lint error(s) to src/services/scheduler.service.ts.
13:15 local/no-ambient-time Date.now() reads the current time. Take a Clock from src/kernel/clock.ts and call clock.now().What it costs
Hook wall time on the repro box (Linux, 8 vCPU, Node 20.19.2), ten runs each:
01-bad-write: 1002 999 975 992 1010 1089 951 993 947 964 median=992 993
03-clean-edit: 1048 959 967 932 959 1003 981 1003 959 980 median=967 980
node -e 0: 95ms
import eslint only: 217ms
eslint + config + ts parser, trivial file: 925msAlmost all of that is startup (Node, ESLint, config and TypeScript parser), and the hook fires on every Write and Edit, so a 40-edit task spends about 40 seconds in it on that box.
Type-aware rules were tried at the gate and dropped. With projectService, a path that doesn't exist yet fails to parse ("was not found by the project service"). The first version of the repro hook linted "" at the missing path as the "before" side, both sides returned the same fatal error, the multiset diff cancelled them, and a bad new file was allowed:
### H2. type-aware config, BAD new-file Write (payload 01)
=== 01-bad-write tool=Write exit=0 1977ms
### H3. why: lint "" and the proposed content at the same missing path
before (empty) [{"ruleId":null,"fatal":true,"message":"Parsing error: /workspace/briefs/eslint-agent-gate-repro/src"}]
after (proposed) [{"ruleId":null,"fatal":true,"message":"Parsing error: /workspace/briefs/eslint-agent-gate-repro/src"}]The harness hook's comment describes this failure ("the gate then allows every new file"), and the repro reintroduced it by accident. After the fix a fatal message always refuses, which under the typed config refuses every new file, clean ones included:
=== 11-clean-write-with-clock tool=Write exit=2 1927ms
Refused: ESLint could not check src/services/schedule.service.ts: Parsing error: /workspace/briefs/eslint-agent-gate-repro/src/services/schedule.service.ts was not found by the project service. Consider either including it in the tsconfig.json or including it in allowDefaultProject.allowDefaultProject: ['src/services/*.ts'] let new files through but made existing ones fail ("was included by allowDefaultProject but also was found in the project service"), typed lint took 1.7 to 2.3 seconds per call, and writing to disk is what left a file behind under SIGTERM. So the gate runs syntactic rules and type-aware rules run in CI, which means no-unsafe-assignment, which fired in the repro's typed run, never runs at the gate.
Edit reconstruction depends on matching old_string exactly. Claude Code sends \n, and a checkout with autocrlf stores \r\n. With normalise() removed, the repro refused a legitimate edit to a CRLF file:
### G2. same, normalise() removed
=== 16-crlf-bad-edit tool=Edit exit=2 946ms
lint gate could not apply edit 1 of 1 to src/services/crlf.service.ts: old_string not found. Re-read the file; an earlier refused edit never landed.The harness hook's comment notes that in its design "a failed match here means the gate allows the write." Failing closed turns that into a visible refusal, although the message is wrong for this case because no earlier edit was refused.
A shell redirect never reaches a Write|Edit|MultiEdit matcher, as payload 14 showed. The checkyourvibe README calls this an escape route and matches Bash as well.
Four ways it fails open
Each was run in the repro; what Claude Code does with the result comes from the hooks reference.
A one-letter typo in the script path exits 1, which the reference treats as a non-blocking error, so the call goes ahead and nothing reports that the gate is off:
Error: Cannot find module '/workspace/briefs/eslint-agent-gate-repro/.claude/hooks/lint-propsed.mjs'With DENY = 1 the hook prints the full, correct refusal and exits 1. Exit 1 doesn't block, and per the reference the transcript shows only the first line of stderr as a hook-error notice, so the write lands even though the hook found the problem:
=== 01-bad-write tool=Write exit=1 1057ms
Refused: this Write would add 1 lint error(s) to src/services/schedule.service.ts.Killed at 0.3 seconds, the hook exits 124 with empty stderr. A PreToolUse command hook that times out doesn't block, and the default timeout is 600 seconds, so without a timeout setting a hung gate stalls the agent for ten minutes and then allows the write. The repro sets timeout: 30, which still fails open but sooner, and the hook itself takes about a second.
A typo in the config's plugin import path makes ESLint throw on load. With the global handlers in place the hook refuses, and with them removed the same error exits 1 and the call goes through:
lint gate failed closed: Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/workspace/briefs/eslint-agent-gate-repro/eslint-plugin-locl/index.js' imported from /workspace/briefs/eslint-agent-gate-repro/eslint.config.mjs
at finalizeResolution (node:internal/modules/esm/resolve:283:11)
exit=2When a pre-edit gate is the wrong tool
Formatting and autofix belong in PostToolUse, because the goal there is to change the file. Type-check and test output the agent should act on belongs in the record layer, along with anything slower than a few seconds per edit or anything that needs the full program or several files at once, and a throwaway spike doesn't need a gate. Validation is a gate makes the same split at the HTTP edge.
CI or a git hook stays behind the gate as the guarantee. The repro's CI command, eslint --no-inline-config src, reported only the seeded scheduler.service.ts with inline config honoured, and with inline config off it also caught the file the Bash heredoc wrote, which the gate never saw:
$ npx eslint --no-inline-config src
src/services/schedule.service.ts
2:30 error new Date() reads the current time. Take a Clock from src/kernel/clock.ts and call clock.now() local/no-ambient-time
src/services/scheduler.service.ts
13:16 error new Date() reads the current time. Take a Clock from src/kernel/clock.ts and call clock.now() local/no-ambient-time
✖ 2 problems (2 errors, 0 warnings)
exit=1--no-inline-config in CI also flags every legitimate disable comment already in the repo, so it's a per-repo decision.
Where checkyourvibe takes over
checkyourvibe puts the same gate on Write, Edit and Bash, gets its verdict from a compiler or AST (ts-morph, Roslyn, ast, syn) where this repro uses a hand-written JS rule, and ships notFix edges as data; the README's TypeScript graph has 14 rules and 47 edges. The README also states two limits: the precedent-poisoning study gated with ESLint and didn't use checkyourvibe, and the notFix benchmark has no citable result yet. A hand-written plugin stops being practical once there are more than a handful of workaround rules to maintain, or the same gate is needed in C#, Python or Rust.
CI caught the heredoc file at line 2, column 30. The statSync mtime read from the rule's limits list would pass both the gate and CI, because no rule in this repo treats a file's modification time as a clock, so it would take a reviewer reading the diff to catch it.
FAQ
Can an agent get around an ESLint rule with an eslint-disable comment?
Yes, unless the gate turns inline config off. ESLint respects disable comments by default, so a PreToolUse hook built with new ESLint() allows them. Construct it with allowInlineConfig: false, and run CI with --no-inline-config.
Should type-aware rules run in the PreToolUse hook?
Usually not. With projectService, a file that doesn't exist yet fails to parse, and allowDefaultProject breaks files that already exist. Keep the gate syntactic, which took about a second per edit in the repro, and run type-aware rules in CI or a post-write hook where the file is real.
What does a pre-edit hook not cover?
Writes that skip the matched tools, like a shell redirect under a Write|Edit|MultiEdit matcher, and hooks that time out, exit 1, or point at a missing script, since all of those let the call through. Back it with a git hook or CI that lints the diff.