Testing
Hook
Hooks
What accessibility-agents runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
Install
> /plugin marketplace add Community-Access/accessibility-agents > /plugin install accessibility-agents@community-access
Ships with accessibility-agents. Installing the plugin gets these hooks.
Where it lives
- hooks/guard.mjsGitHub
Read the script
#!/usr/bin/env node /** * guard.mjs - the accessibility enforcement gate, for every client. * * One script, four behaviours, four client manifests that map their own event * names onto it. Previously each client had its own script in its own language * with its own subtly different marker logic, which is why a lead dispatch * recognised on one client was missed on another. * * ## The behaviours * * detect Inject the delegation reminder. Once per session, not per prompt. * gate Refuse an edit to a user-facing file until the lead has completed. * mark Record that the lead finished, which opens the gate. * persist Save phase and findings path before compaction; restore after. * finalize Refuse to end a turn that edited UI without a completed review. * * ## Why once per session * * The old hook injected roughly 570 tokens of reminder on every prompt. Over a * forty-turn session that is 22,000 tokens spent repeating something the model * read the first time. It now injects once, capped at sixty words, and the * marker file records that it did. * * ## Why mark on completion, not on launch * * The gate used to open when the lead was dispatched. A cancelled or failed * lead therefore unlocked every UI edit for the rest of the session. It now * opens only when the lead reports completion. * * Exit codes follow the common hook convention: 0 proceed, 2 block. * Structured decisions are emitted as JSON on stdout where the client reads it. * * Usage (from a client manifest): * node hooks/guard.mjs --behaviour detect|gate|mark|persist|finalize --client claude|codex|copilot|gemini */ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; // --------------------------------------------------------------- parameters /** Files whose change a person sees and operates. */ const UI_PATTERN = /\.(html?|jsx|tsx|vue|svelte|astro|css|scss|sass|less|ejs|hbs|handlebars|erb|leaf|jinja2?|twig|blade\.php|razor|cshtml)$/i; /** * Paths that look like UI but are not a user's interface: dependencies, build * output, coverage, tests and stories. * * Each directory pattern matches at the start of the path as well as after a * separator. Requiring a leading separator let `dist/main.css` through, which * is the common shape of a relative path from a repository root. */ const SEP = '(?:^|[\\\\/])'; const EXEMPT = [ new RegExp(`${SEP}node_modules[\\\\/]`), new RegExp(`${SEP}dist[\\\\/]`), new RegExp(`${SEP}build[\\\\/]`), new RegExp(`${SEP}out[\\\\/]`), new RegExp(`${SEP}coverage[\\\\/]`), new RegExp(`${SEP}vendor[\\\\/]`), new RegExp(`${SEP}\\.git[\\\\/]`), new RegExp(`${SEP}__(?:tests?|mocks?|snapshots?)__[\\\\/]`), /\.(test|spec|stories)\.[jt]sx?$/i, /\.min\.(css|js)$/i, ]; /** Sixty words. Anything longer is repeating what AGENTS.md already says. */ export const REMINDER = 'This project enforces WCAG 2.2 AA. Before editing HTML, JSX, TSX, Vue, Svelte, CSS or a template, ' + 'dispatch the accessibility-lead skill and let it finish. Office and PDF files go to ' + 'document-accessibility-wizard, markdown to markdown-a11y-assistant. Edits to user-facing files are ' + 'blocked until the lead completes. The full contract is in AGENTS.md.'; /** Forty words. A refusal has to say what to do next, and nothing else. */ export const REFUSAL = 'Accessibility review required before editing this file. Dispatch the accessibility-lead skill, let ' + 'it finish, then retry the edit. This gate is described in AGENTS.md.'; const LEAD_SKILLS = new Set([ 'accessibility-lead', 'accessibility-agents:accessibility-lead', 'web-accessibility-wizard', 'accessibility-agents:web-accessibility-wizard', ]); // ------------------------------------------------------------------- session /** * Markers live in one directory per session so a parallel session cannot open * another session's gate. Codex reports child sessions and parent threads under * different identifiers, so every identifier a payload carries is treated as an * alias of the same session; that mismatch is what previously made a valid lead * dispatch invisible to the edit gate. */ function sessionIds(payload) { const candidates = [ payload.session_id, payload.sessionId, payload.parent_session_id, payload.parentSessionId, payload.thread_id, payload.threadId, payload.conversation_id, payload.conversationId, process.env.CLAUDE_SESSION_ID, process.env.CODEX_SESSION_ID, process.env.COPILOT_SESSION_ID, ].filter((v) => typeof v === 'string' && v.length); return candidates.length ? [...new Set(candidates)] : ['default']; } function markerDir() { const base = process.env.A11Y_GUARD_DIR || path.join(os.tmpdir(), 'a11y-agents-guard'); fs.mkdirSync(base, { recursive: true }); return base; } function markerPath(id, name) { return path.join(markerDir(), `${sanitise(id)}.${name}`); } function sanitise(id) { return String(id).replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 96); } function hasMarker(ids, name) { return ids.some((id) => fs.existsSync(markerPath(id, name))); } function setMarker(ids, name, contents = '') { for (const id of ids) { try { fs.writeFileSync(markerPath(id, name), contents || new Date().toISOString(), 'utf8'); } catch { // A guard that cannot write a marker must not take the session down with // it. The gate stays closed, which is the safe direction. } } } function readMarker(ids, name) { for (const id of ids) { try { return fs.readFileSync(markerPath(id, name), 'utf8'); } catch { /* try the next alias */ } } return null; } // --------------------------------------------------------------------- input async function readPayload() { const chunks = []; for await (const chunk of process.stdin) chunks.push(chunk); const text = Buffer.concat(chunks).toString('utf8').trim(); - hooks/guard.test.mjsGitHub
Read the script
/** * guard.test.mjs - the enforcement gate, tested. * * This script is the only thing standing between a model and an unreviewed * edit to a user's interface. Every test below is a way it could fail open, * fail closed on the wrong thing, or cost tokens it should not. * * Run: node --test hooks/guard.test.mjs */ import { test, describe, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const GUARD = path.join(HERE, 'guard.mjs'); let scratch; function run(behaviour, payload) { const result = spawnSync(process.execPath, [GUARD, '--behaviour', behaviour, '--client', 'claude'], { input: JSON.stringify(payload), encoding: 'utf8', env: { ...process.env, A11Y_GUARD_DIR: scratch }, }); let json = null; try { json = result.stdout.trim() ? JSON.parse(result.stdout) : null; } catch { json = null; } return { code: result.status, stdout: result.stdout, json }; } const SESSION = { session_id: 'test-session' }; const edit = (file, extra = {}) => ({ ...SESSION, ...extra, tool_input: { file_path: file } }); beforeEach(() => { scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-test-')); }); describe('detect', () => { test('injects the contract once, then stays quiet', () => { const first = run('detect', SESSION); assert.equal(first.code, 0); assert.ok(first.json, 'first prompt should inject the contract'); assert.match(first.json.additionalContext, /WCAG 2\.2 AA/); const second = run('detect', SESSION); assert.equal(second.stdout.trim(), '', 'a second prompt must not repeat the contract'); }); test('the reminder stays short enough to be worth repeating never', () => { const { json } = run('detect', SESSION); const words = json.additionalContext.trim().split(/\s+/).length; assert.ok(words <= 70, `reminder is ${words} words; it is injected into every session and must stay brief`); }); }); describe('gate', () => { test('blocks an edit to a user-facing file before review', () => { const result = run('gate', edit('src/components/Modal.tsx')); assert.equal(result.code, 2, 'an unreviewed UI edit must be refused'); assert.equal(result.json.hookSpecificOutput.permissionDecision, 'deny'); assert.match(result.json.hookSpecificOutput.permissionDecisionReason, /accessibility-lead/i); }); test('the refusal says what to do next and nothing else', () => { const { json } = run('gate', edit('src/App.vue')); const words = json.hookSpecificOutput.permissionDecisionReason.trim().split(/\s+/).length; assert.ok(words <= 45, `refusal is ${words} words; it must name the next action and stop`); }); test('allows the edit once the lead has completed', () => { run('mark', { ...SESSION, subagent_type: 'accessibility-lead' }); const result = run('gate', edit('src/components/Modal.tsx')); assert.equal(result.code, 0, 'the gate must open after a completed review'); }); test('ignores files that are not user-facing', () => { for (const file of ['server/db.ts', 'scripts/build.mjs', 'README.md', 'package.json']) { assert.equal(run('gate', edit(file)).code, 0, `${file} is not an interface and must not be gated`); } }); test('ignores build output, tests and dependencies that merely look like UI', () => { for (const file of [ 'node_modules/react/index.css', 'dist/main.css', 'build/app.html', 'src/Modal.test.tsx', 'src/Button.stories.tsx', 'coverage/index.html', ]) { assert.equal(run('gate', edit(file)).code, 0, `${file} is not a user's interface and must not be gated`); } }); test('gates every user-facing extension, not only the common ones', () => { for (const file of [ 'a.html', 'a.jsx', 'a.tsx', 'a.vue', 'a.svelte', 'a.astro', 'a.css', 'a.scss', 'a.ejs', 'a.hbs', 'a.erb', 'a.leaf', 'a.twig', 'a.cshtml', ]) { assert.equal(run('gate', edit(file)).code, 2, `${file} is user-facing and must be gated`); } }); test('a client that names the path differently is still gated', () => { for (const key of ['file_path', 'filePath', 'path', 'target_file']) { const payload = { ...SESSION, tool_input: { [key]: 'src/Page.tsx' } }; assert.equal(run('gate', payload).code, 2, `a payload using ${key} must still be gated`); } }); }); describe('mark', () => { test('a completed lead opens the gate', () => { run('mark', { ...SESSION, subagent_type: 'accessibility-lead' }); assert.equal(run('gate', edit('src/X.tsx')).code, 0); }); test('a plugin-qualified name is recognised', () => { run('mark', { ...SESSION, subagent_type: 'accessibility-agents:accessibility-lead' }); assert.equal(run('gate', edit('src/X.tsx')).code, 0); }); test('an unrelated subagent does not open the gate', () => { run('mark', { ...SESSION, subagent_type: 'python-specialist' }); assert.equal(run('gate', edit('src/X.tsx')).code, 2, 'only the lead may open the gate'); }); test('a session id alias still opens the gate for the same session', () => { // Codex reports child sessions and parent threads under different ids. // Treating them as separate sessions is what previously lost a valid review. run('mark', { session_id: 'child-1', parent_session_id: 'parent-1', subagent_type: 'accessibility-lead' }); const result = run('gate', { session_id: 'parent-1', tool_input: { file_path: 'src/X.tsx' } }); assert.equal(result.code, 0, 'a review recorded under a child session must count for its parent'); }); test('one session cannot open the gate of another', () => { run('mark', { session_id: 'session-a', subagent_type: 'accessibility-lead' }); const other = run('gate', { session_id: 'session-b', tool_
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Ships withaccessibility-agents
WCAG 2.2 AA enforcement for agentic coding, as a set of Agent Skills. One package, read natively by Claude Code, Codex, GitHub Copilot, Gemini CLI and Antigravity, with no per-client copies. Models forget accessibility while generating code.
Get the whole plugin

