From 504261d0697952519c74b21da2a554db0d134abe Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sun, 26 Jul 2026 05:07:34 -0400 Subject: [PATCH] fix: wire Hookify runtime enforcement Load bounded project-local Hookify rules, evaluate untrusted regexes in a resource-limited worker, and emit event-correct structured warn/block outputs for PreToolUse, PostToolUse, UserPromptSubmit, and Stop. Register bounded entrypoints, prevent recursive Stop loops, document the runtime contract, and package the implementation.\n\nCloses #2561 --- commands/hookify-configure.md | 13 +- commands/hookify-help.md | 101 +++- commands/hookify-list.md | 16 +- commands/hookify.md | 39 +- hooks/hooks.json | 38 +- scripts/hooks/hookify-engine.js | 243 ++++++++ scripts/hooks/hookify-loader.js | 613 +++++++++++++++++++++ scripts/hooks/hookify-regex-worker.js | 109 ++++ scripts/hooks/hookify-runner.js | 417 ++++++++++++++ scripts/hooks/posttooluse-dispatcher.js | 61 +- skills/hookify-rules/SKILL.md | 50 +- tests/docs/hookify-runtime-docs.test.js | 89 +++ tests/hooks/hookify-engine.test.js | 357 ++++++++++++ tests/hooks/hookify-loader.test.js | 452 +++++++++++++++ tests/hooks/hookify-runner.test.js | 605 ++++++++++++++++++++ tests/hooks/hooks.test.js | 14 +- tests/hooks/posttooluse-dispatcher.test.js | 38 +- tests/hooks/stop-hooks-stdout.test.js | 13 +- tests/scripts/npm-publish-surface.test.js | 4 + 19 files changed, 3224 insertions(+), 48 deletions(-) create mode 100644 scripts/hooks/hookify-engine.js create mode 100644 scripts/hooks/hookify-loader.js create mode 100644 scripts/hooks/hookify-regex-worker.js create mode 100644 scripts/hooks/hookify-runner.js create mode 100644 tests/docs/hookify-runtime-docs.test.js create mode 100644 tests/hooks/hookify-engine.test.js create mode 100644 tests/hooks/hookify-loader.test.js create mode 100644 tests/hooks/hookify-runner.test.js diff --git a/commands/hookify-configure.md b/commands/hookify-configure.md index 929a85794..174f1d761 100644 --- a/commands/hookify-configure.md +++ b/commands/hookify-configure.md @@ -2,13 +2,18 @@ description: Enable or disable hookify rules interactively --- -Interactively enable or disable existing hookify rules. +Interactively enable or disable existing Hookify rule files. ## Steps -1. Find all `.claude/hookify.*.local.md` files -2. Read the current state of each rule -3. Present the list with current enabled / disabled status +1. Inspect direct, regular `.claude/hookify.*.local.md` files in the current + project. Do not follow symlinks. +2. Read the current state of each rule and check it against the runtime's + strict schema. +3. Present valid rules with their enabled / disabled status. Report malformed + files separately because the runtime skips them. 4. Ask which rules to toggle 5. Update the `enabled:` field in the selected rule files 6. Confirm the changes + +Do not silently repair other fields while toggling a rule. diff --git a/commands/hookify-help.md b/commands/hookify-help.md index f30ae0048..11be8ba24 100644 --- a/commands/hookify-help.md +++ b/commands/hookify-help.md @@ -6,15 +6,21 @@ Display comprehensive hookify documentation. ## Hook System Overview -Hookify creates rule files that integrate with Claude Code's hook system to prevent unwanted behaviors. +ECC ships a built-in Node.js runtime that reads project-local +`.claude/hookify.*.local.md` files. The plugin registers that runtime for +PreToolUse, PostToolUse, UserPromptSubmit, and Stop. ### Event Types -- `bash`: triggers on Bash tool use and matches command patterns -- `file`: triggers on Write/Edit tool use and matches file paths -- `stop`: triggers when a session ends -- `prompt`: triggers on user message submission and matches input patterns -- `all`: triggers on all events +- `bash`: runs on Bash tool use; a simple `pattern` matches `command` +- `file`: runs on Write/Edit/MultiEdit/NotebookEdit; a simple `pattern` + matches changed content +- `stop`: runs when Claude finishes a response; a simple `pattern` matches + the last assistant message +- `prompt`: runs on user message submission; a simple `pattern` matches the + submitted `prompt` +- `all`: is eligible on all events; conditions whose fields are unavailable + for the current event do not match ### Rule File Format @@ -27,11 +33,87 @@ enabled: true event: bash|file|stop|prompt|all action: block|warn pattern: "regex pattern to match" +tool_matcher: Bash|Write --- Message to display when rule triggers. Supports multiple lines. ``` +`action` is optional and defaults to `warn`. `tool_matcher` is optional and +uses exact, pipe-separated tool names or `*`. + +Use either `pattern` or a non-empty `conditions` list, never both: + +```yaml +--- +name: block-production-publish +enabled: true +event: bash +action: block +conditions: + - field: command + operator: contains + pattern: npm publish + - field: command + operator: not_contains + pattern: --dry-run +--- +Use the release workflow instead. +``` + +All conditions must match. Supported operators are `regex_match`, `contains`, +`equals`, `not_contains`, `starts_with`, and `ends_with`. +`regex_match` is case-insensitive; the five literal string operators are +case-sensitive. + +Condition fields: + +- `bash`: `command` +- `file`: `file_path`, `new_text`, `old_text`, `content` +- `prompt`: `user_prompt` (read from Claude Code's `prompt` input) +- `stop`: `content` (the last assistant message) +- `all`: any field above when it exists for the current event + +### Enforcement Semantics + +- PreToolUse `block` denies the pending tool call. +- UserPromptSubmit `block` rejects the submitted prompt. +- Stop `block` prevents the current stop and gives Claude the reason to + continue. +- PostToolUse `block` feeds corrective context to Claude. PostToolUse cannot undo + a tool that already completed. +- PreToolUse, PostToolUse, and UserPromptSubmit `warn` messages reach Claude as + structured `additionalContext`. +- A Stop warning is a non-blocking `systemMessage` shown to the user. A Stop warning does not make Claude continue; use `action: block` for a corrective + completion rule. +- Hookify skips recursive Stop evaluation when Claude Code reports + `stop_hook_active: true`, preventing an always-matching block from creating + an infinite continuation loop. + +Hookify never rewrites or returns modified tool input. + +### Safety and Limits + +The runtime only inspects direct rule files inside the current project's real +`.claude/` directory. It rejects symlinked directories/files, traversal, +non-regular files, unsupported YAML structures, unknown fields, invalid +operators, and invalid event/field combinations. It does not read `transcript_path`; +Stop rules use the bounded last assistant message. + +Limits per invocation: + +- 256 directory entries inspected and 64 rule files evaluated +- 64 KiB per rule and 512 KiB total rule bytes +- 512 characters per pattern, 16 conditions, and 4 KiB per message +- 256 KiB hook input and 8 KiB structured output +- one 250 ms total regular-expression deadline + +Regular expressions run case-insensitively in a resource-limited worker. A +malformed rule, unsafe file, invalid input, invalid regex, or worker timeout +causes the affected evaluation to fail open. The hook exits successfully and +returns a bounded, event-correct Hookify diagnostic instead of silently writing +the warning to stderr. + ### Commands - `/hookify [description]` creates new rules and auto-analyzes the conversation when no description is given @@ -40,7 +122,8 @@ Supports multiple lines. ### Pattern Tips -- use regex syntax +- use JavaScript regex syntax; matching is case-insensitive - for `bash`, match against the full command string -- for `file`, match against the file path -- test patterns before deploying +- for a file path, use a `file_path` condition +- keep regexes narrow even though worker isolation enforces a hard deadline +- test patterns before enabling a blocking rule diff --git a/commands/hookify-list.md b/commands/hookify-list.md index b912ce1da..a081bf6e6 100644 --- a/commands/hookify-list.md +++ b/commands/hookify-list.md @@ -2,20 +2,26 @@ description: List all configured hookify rules --- -Find and display all hookify rules in a formatted table. +Find and display project-local Hookify rule files and their runtime status. ## Steps -1. Find all `.claude/hookify.*.local.md` files +1. Find direct, regular `.claude/hookify.*.local.md` files in the current + project's `.claude/` directory. Do not follow symlinks. 2. Read each file's frontmatter: - `name` - `enabled` - `event` - `action` - `pattern` + - `conditions` + - `tool_matcher` 3. Display them as a table: -| Rule | Enabled | Event | Pattern | File | -|------|---------|-------|---------|------| +| Rule | Enabled | Event | Action | Matcher | File | +|------|---------|-------|--------|---------|------| -4. Show the rule count and remind the user that `/hookify-configure` can change state later. +4. Report malformed or unsafe files separately. They are skipped by the + runtime and are not enforced. +5. Show the valid/enabled count and remind the user that + `/hookify-configure` can change state later. diff --git a/commands/hookify.md b/commands/hookify.md index 80969d8cd..3d99cc3e9 100644 --- a/commands/hookify.md +++ b/commands/hookify.md @@ -2,7 +2,7 @@ description: Create hooks to prevent unwanted behaviors from conversation analysis or explicit instructions --- -Create hook rules to prevent unwanted Claude Code behaviors by analyzing conversation patterns or explicit user instructions. +Create project-local rules for ECC's built-in Node.js Hookify runtime by analyzing conversation patterns or explicit user instructions. ## Usage @@ -45,6 +45,41 @@ pattern: "regex pattern" Message shown when rule triggers. ``` +Use exactly one of `pattern` or `conditions`. For precise matching, use the +condition form: + +```yaml +--- +name: warn-env-secret +enabled: true +event: file +action: warn +tool_matcher: Write|Edit +conditions: + - field: file_path + operator: ends_with + pattern: .env + - field: content + operator: contains + pattern: API_KEY +--- +Keep credentials out of source control. +``` + +Supported condition operators are `regex_match`, `contains`, `equals`, +`not_contains`, `starts_with`, and `ends_with`. All conditions must match. +The complete schema and event-specific fields are in `/hookify-help`. + ### Step 4: Confirm -Report created rules and how to manage them with `/hookify-list` and `/hookify-configure`. +Report: + +- the files created +- whether each rule warns or blocks +- which lifecycle event enforces it +- how to manage it with `/hookify-list` and `/hookify-configure` + +Be precise about enforcement. A PreToolUse block prevents the tool call. +A UserPromptSubmit block rejects the prompt. A Stop block makes Claude +continue. A PostToolUse block only supplies corrective feedback because the +tool has already completed. diff --git a/hooks/hooks.json b/hooks/hooks.json index 00ab4d0aa..6760ec777 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -94,6 +94,31 @@ ], "description": "Fact-forcing gate: block first Edit/Write/MultiEdit per file and demand investigation (importers, data schemas, user instruction) before allowing", "id": "pre:edit-write:gateguard-fact-force" + }, + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i{process.exitCode=0;process.stdout.write('{}')})}catch(_){process.stdout.write('{}')}\" PreToolUse pre:hookify minimal,standard,strict", + "timeout": 5 + } + ], + "description": "Evaluate project-local Hookify rules before tool execution", + "id": "pre:hookify" + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i{process.exitCode=0;process.stdout.write('{}')})}catch(_){process.stdout.write('{}')}\" UserPromptSubmit prompt:hookify minimal,standard,strict", + "timeout": 5 + } + ], + "description": "Evaluate project-local Hookify rules when a prompt is submitted", + "id": "prompt:hookify" } ], "PreCompact": [ @@ -143,7 +168,7 @@ "timeout": 30 } ], - "description": "Run synchronous PostToolUse hooks in one process while preserving per-hook controls", + "description": "Run synchronous PostToolUse hooks, including Hookify, in one process while preserving per-hook controls", "id": "post:dispatcher:sync" }, { @@ -174,6 +199,17 @@ } ], "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i{process.exitCode=0;process.stdout.write('{}')})}catch(_){process.stdout.write('{}')}\" Stop stop:hookify minimal,standard,strict", + "timeout": 5 + } + ], + "description": "Evaluate project-local Hookify completion rules without reading transcripts", + "id": "stop:hookify" + }, { "matcher": "*", "hooks": [ diff --git a/scripts/hooks/hookify-engine.js b/scripts/hooks/hookify-engine.js new file mode 100644 index 000000000..0fadbdd35 --- /dev/null +++ b/scripts/hooks/hookify-engine.js @@ -0,0 +1,243 @@ +#!/usr/bin/env node +/** + * Hookify field extraction and bounded rule evaluation. + */ + +'use strict'; + +const path = require('path'); +const { Worker } = require('worker_threads'); + +const WORKER_PATH = path.join(__dirname, 'hookify-regex-worker.js'); +const WORKER_RESULT_BYTES = 64 * 1024; +const HEADER_BYTES = Int32Array.BYTES_PER_ELEMENT * 2; +const DEFAULT_TIMEOUT_MS = 250; +const MAX_FIELD_BYTES = 64 * 1024; +const MAX_EDIT_ITEMS = 256; + +function truncateUtf8(value, maxBytes = MAX_FIELD_BYTES) { + const input = String(value); + const encoded = Buffer.from(input, 'utf8'); + if (encoded.length <= maxBytes) return input; + + let end = maxBytes; + while (end > 0 && (encoded[end] & 0xc0) === 0x80) end -= 1; + return encoded.subarray(0, end).toString('utf8'); +} + +function stringField(value) { + return typeof value === 'string' ? truncateUtf8(value) : null; +} + +function editValues(toolInput, field) { + if (!Array.isArray(toolInput.edits)) return null; + const values = []; + for (const edit of toolInput.edits.slice(0, MAX_EDIT_ITEMS)) { + if (!edit || typeof edit !== 'object' || Array.isArray(edit)) continue; + const value = stringField(edit[field]); + if (value !== null) values.push(value); + } + return truncateUtf8(values.join('\n')); +} + +function fileContent(toolName, toolInput) { + if (toolName === 'MultiEdit') { + return editValues(toolInput, 'new_string') || ''; + } + if (toolName === 'NotebookEdit') { + return stringField(toolInput.new_source) ?? ''; + } + return ( + stringField(toolInput.content) ?? + stringField(toolInput.new_text) ?? + stringField(toolInput.new_string) ?? + '' + ); +} + +function extractConditionValue(field, input) { + if (!input || typeof input !== 'object' || Array.isArray(input)) return null; + const toolName = typeof input.tool_name === 'string' ? input.tool_name : ''; + const toolInput = input.tool_input && + typeof input.tool_input === 'object' && + !Array.isArray(input.tool_input) + ? input.tool_input + : {}; + + switch (field) { + case 'command': + return toolName === 'Bash' ? stringField(toolInput.command) : null; + case 'file_path': + return ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(toolName) + ? stringField(toolInput.file_path) ?? stringField(toolInput.notebook_path) + : null; + case 'new_text': + if (toolName === 'MultiEdit') return editValues(toolInput, 'new_string'); + if (!['Edit', 'Write', 'NotebookEdit'].includes(toolName)) return null; + return ( + stringField(toolInput.new_text) ?? + stringField(toolInput.new_string) ?? + stringField(toolInput.new_source) ?? + stringField(toolInput.content) + ); + case 'old_text': + if (toolName === 'MultiEdit') return editValues(toolInput, 'old_string'); + if (!['Edit', 'Write', 'NotebookEdit'].includes(toolName)) return null; + return stringField(toolInput.old_text) ?? stringField(toolInput.old_string); + case 'user_prompt': + return input.hook_event_name === 'UserPromptSubmit' + ? stringField(input.prompt) + : null; + case 'content': + if (input.hook_event_name === 'Stop') { + return stringField(input.last_assistant_message) ?? ''; + } + if (input.hook_event_name === 'UserPromptSubmit') { + return stringField(input.prompt) ?? ''; + } + if (toolName === 'Bash') return stringField(toolInput.command) ?? ''; + if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(toolName)) { + return fileContent(toolName, toolInput); + } + return null; + default: + return null; + } +} + +function matchesTool(matcher, toolName) { + if (!matcher || matcher === '*') return true; + return matcher.split('|').includes(toolName); +} + +function safeWorkerResult() { + return { + matchedIndexes: [], + diagnostics: [{ + code: 'HOOKIFY_REGEX_WORKER_FAILED', + message: 'Hookify skipped rule evaluation: isolated worker failed.', + }], + }; +} + +function runWorker(tasks, values, timeoutMs) { + const deadline = Date.now() + timeoutMs; + const sharedBuffer = new SharedArrayBuffer(WORKER_RESULT_BYTES); + const header = new Int32Array(sharedBuffer, 0, 2); + let worker; + try { + worker = new Worker(WORKER_PATH, { + workerData: { tasks, values, sharedBuffer }, + resourceLimits: { + maxOldGenerationSizeMb: 32, + maxYoungGenerationSizeMb: 8, + codeRangeSizeMb: 8, + stackSizeMb: 2, + }, + }); + worker.on('error', () => {}); + worker.unref(); + } catch { + return safeWorkerResult(); + } + + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + worker.terminate().catch(() => {}); + return { + matchedIndexes: [], + diagnostics: [{ + code: 'HOOKIFY_REGEX_TIMEOUT', + message: 'Hookify skipped rule evaluation: regular-expression deadline exceeded.', + }], + }; + } + const waitResult = Atomics.wait(header, 0, 0, remainingMs); + if (waitResult === 'timed-out') { + worker.terminate().catch(() => {}); + return { + matchedIndexes: [], + diagnostics: [{ + code: 'HOOKIFY_REGEX_TIMEOUT', + message: 'Hookify skipped rule evaluation: regular-expression deadline exceeded.', + }], + }; + } + + const state = Atomics.load(header, 0); + const outputLength = Atomics.load(header, 1); + worker.terminate().catch(() => {}); + if ( + state < 1 || + outputLength < 1 || + outputLength > WORKER_RESULT_BYTES - HEADER_BYTES + ) { + return safeWorkerResult(); + } + + try { + const bytes = new Uint8Array(sharedBuffer, HEADER_BYTES, outputLength); + const parsed = JSON.parse(Buffer.from(bytes).toString('utf8')); + if ( + !Array.isArray(parsed.matchedIndexes) || + !Array.isArray(parsed.diagnostics) + ) { + return safeWorkerResult(); + } + return parsed; + } catch { + return safeWorkerResult(); + } +} + +function evaluateRules(rules, input, options = {}) { + if (!Array.isArray(rules) || rules.length === 0) { + return { matches: [], diagnostics: [] }; + } + + const toolName = typeof input?.tool_name === 'string' ? input.tool_name : ''; + const tasks = []; + const fields = new Set(); + for (let index = 0; index < rules.length; index += 1) { + const rule = rules[index]; + if (!matchesTool(rule.toolMatcher, toolName)) continue; + tasks.push({ + index, + source: rule.source, + conditions: rule.conditions.map(condition => ({ + field: condition.field, + operator: condition.operator, + pattern: condition.pattern, + })), + }); + for (const condition of rule.conditions) fields.add(condition.field); + } + if (tasks.length === 0) return { matches: [], diagnostics: [] }; + const values = {}; + for (const field of fields) { + values[field] = extractConditionValue(field, input); + } + + const requestedTimeout = Number(options.timeoutMs); + const timeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout > 0 + ? Math.min(Math.floor(requestedTimeout), 1000) + : DEFAULT_TIMEOUT_MS; + const result = runWorker(tasks, values, timeoutMs); + const matched = new Set( + result.matchedIndexes.filter( + index => Number.isInteger(index) && index >= 0 && index < rules.length + ) + ); + + return { + matches: rules.filter((_rule, index) => matched.has(index)), + diagnostics: result.diagnostics, + }; +} + +module.exports = { + evaluateRules, + extractConditionValue, + matchesTool, + truncateUtf8, +}; diff --git a/scripts/hooks/hookify-loader.js b/scripts/hooks/hookify-loader.js new file mode 100644 index 000000000..451ed967d --- /dev/null +++ b/scripts/hooks/hookify-loader.js @@ -0,0 +1,613 @@ +#!/usr/bin/env node +/** + * Bounded, project-local loader for `.claude/hookify.*.local.md` rules. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { TextDecoder } = require('util'); + +const LIMITS = Object.freeze({ + maxConditionCount: 16, + maxDirectoryEntries: 256, + maxFileBytes: 64 * 1024, + maxMessageBytes: 4096, + maxPatternLength: 512, + maxRuleFiles: 64, + maxToolMatcherLength: 256, + maxTotalBytes: 512 * 1024, +}); + +const FILE_NAME_PATTERN = /^hookify\.[A-Za-z0-9][A-Za-z0-9._-]{0,95}\.local\.md$/; +const RULE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const TOOL_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/; +const TOP_LEVEL_FIELDS = new Set([ + 'name', + 'enabled', + 'event', + 'pattern', + 'conditions', + 'action', + 'tool_matcher', +]); +const CONDITION_FIELDS = new Set(['field', 'operator', 'pattern']); +const EVENTS = new Set(['bash', 'file', 'stop', 'prompt', 'all']); +const ACTIONS = new Set(['warn', 'block']); +const OPERATORS = new Set([ + 'regex_match', + 'contains', + 'equals', + 'not_contains', + 'starts_with', + 'ends_with', +]); +const EVENT_FIELDS = Object.freeze({ + bash: new Set(['command']), + file: new Set(['file_path', 'new_text', 'old_text', 'content']), + prompt: new Set(['user_prompt']), + stop: new Set(['content']), + all: new Set(['command', 'file_path', 'new_text', 'old_text', 'content', 'user_prompt']), +}); + +function diagnostic(code, fileName, detail) { + const label = fileName ? ` ${fileName}` : ''; + const suffix = detail ? `: ${detail}` : '.'; + return { + code, + message: `Hookify skipped${label}${suffix}`, + }; +} + +function hasUnsafeControlCharacters(value, allowNewlines = false) { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code === 0x7f) return true; + if (code >= 0x20) continue; + if (allowNewlines && (code === 0x09 || code === 0x0a || code === 0x0d)) { + continue; + } + return true; + } + return false; +} + +function parseQuotedScalar(rawValue) { + if (rawValue.startsWith('"')) { + if (!rawValue.endsWith('"')) { + throw new Error('unterminated double-quoted value'); + } + try { + return JSON.parse(rawValue); + } catch { + throw new Error('invalid double-quoted escape'); + } + } + + if (rawValue.startsWith("'")) { + if (!rawValue.endsWith("'")) { + throw new Error('unterminated single-quoted value'); + } + return rawValue.slice(1, -1).replace(/''/g, "'"); + } + + return rawValue; +} + +function parseScalar(rawValue) { + const value = rawValue.trim(); + if (!value) throw new Error('empty scalar'); + if (value === 'true') return true; + if (value === 'false') return false; + if (/^[&*!][A-Za-z0-9_-]+(?:\s|$)/.test(value)) { + throw new Error('YAML tags, anchors, and aliases are not supported'); + } + return parseQuotedScalar(value); +} + +function setUnique(target, key, value) { + if (Object.prototype.hasOwnProperty.call(target, key)) { + throw new Error(`duplicate field ${key}`); + } + target[key] = value; +} + +function parseFrontmatter(frontmatterText) { + const result = {}; + const lines = frontmatterText.split('\n'); + let conditions = null; + let currentCondition = null; + + for (const originalLine of lines) { + const line = originalLine.endsWith('\r') + ? originalLine.slice(0, -1) + : originalLine; + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const topLevel = line.match(/^([a-z_]+):(?:[ \t]*(.*))?$/); + if (topLevel) { + const [, key, rawValue = ''] = topLevel; + if (!TOP_LEVEL_FIELDS.has(key)) throw new Error(`unknown field ${key}`); + if (key === 'conditions') { + if (rawValue.trim()) throw new Error('conditions must be a list'); + if (conditions !== null) throw new Error('duplicate field conditions'); + conditions = []; + result.conditions = conditions; + currentCondition = null; + } else { + setUnique(result, key, parseScalar(rawValue)); + currentCondition = null; + } + continue; + } + + const listStart = line.match(/^ {2}- ([a-z_]+):(?:[ \t]*(.*))?$/); + if (listStart && conditions) { + const [, key, rawValue = ''] = listStart; + if (!CONDITION_FIELDS.has(key)) throw new Error(`unknown condition field ${key}`); + currentCondition = {}; + conditions.push(currentCondition); + setUnique(currentCondition, key, parseScalar(rawValue)); + continue; + } + + const continuation = line.match(/^ {4}([a-z_]+):(?:[ \t]*(.*))?$/); + if (continuation && currentCondition) { + const [, key, rawValue = ''] = continuation; + if (!CONDITION_FIELDS.has(key)) throw new Error(`unknown condition field ${key}`); + setUnique(currentCondition, key, parseScalar(rawValue)); + continue; + } + + throw new Error('unsupported YAML structure'); + } + + return result; +} + +function extractDocument(source) { + const normalized = source.replace(/\r\n/g, '\n'); + const lines = normalized.split('\n'); + if (lines[0] !== '---') throw new Error('missing opening frontmatter delimiter'); + const closingIndex = lines.indexOf('---', 1); + if (closingIndex < 0) throw new Error('missing closing frontmatter delimiter'); + + return { + frontmatter: parseFrontmatter(lines.slice(1, closingIndex).join('\n')), + message: lines.slice(closingIndex + 1).join('\n').trim(), + }; +} + +function validateString(value, field, options = {}) { + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`${field} must be a non-empty string`); + } + if (hasUnsafeControlCharacters(value, options.allowNewlines === true)) { + throw new Error(`${field} contains control characters`); + } + if (options.maxLength && value.length > options.maxLength) { + throw new Error(`${field} exceeds its length limit`); + } + return value; +} + +function validateToolMatcher(value) { + if (value === undefined) return null; + validateString(value, 'tool_matcher', { maxLength: LIMITS.maxToolMatcherLength }); + if (value === '*') return value; + + const tools = value.split('|'); + if ( + tools.length === 0 || + tools.some(tool => tool !== tool.trim() || !TOOL_NAME_PATTERN.test(tool)) + ) { + throw new Error('tool_matcher must contain exact pipe-separated tool names'); + } + return value; +} + +function inferredField(event) { + if (event === 'bash') return 'command'; + if (event === 'prompt') return 'user_prompt'; + return 'content'; +} + +function validateCondition(condition, event) { + if ( + !condition || + typeof condition !== 'object' || + Array.isArray(condition) || + Object.keys(condition).length !== CONDITION_FIELDS.size || + ![...CONDITION_FIELDS].every(field => Object.prototype.hasOwnProperty.call(condition, field)) + ) { + throw new Error('each condition requires only field, operator, and pattern'); + } + + const field = validateString(condition.field, 'condition field'); + const operator = validateString(condition.operator, 'condition operator'); + const pattern = validateString(condition.pattern, 'condition pattern', { + maxLength: LIMITS.maxPatternLength, + }); + if (!EVENT_FIELDS[event].has(field)) { + throw new Error(`condition field ${field} is not valid for event ${event}`); + } + if (!OPERATORS.has(operator)) throw new Error(`unsupported condition operator ${operator}`); + return { field, operator, pattern }; +} + +function validateRule(frontmatter, message, source) { + const keys = Object.keys(frontmatter); + for (const field of ['name', 'enabled', 'event']) { + if (!keys.includes(field)) throw new Error(`missing required field ${field}`); + } + + const name = validateString(frontmatter.name, 'name', { maxLength: 80 }); + if (!RULE_NAME_PATTERN.test(name)) { + throw new Error('name must be lower-case kebab-case'); + } + if (typeof frontmatter.enabled !== 'boolean') { + throw new Error('enabled must be true or false'); + } + + const event = validateString(frontmatter.event, 'event'); + if (!EVENTS.has(event)) throw new Error(`unsupported event ${event}`); + const action = frontmatter.action === undefined + ? 'warn' + : validateString(frontmatter.action, 'action'); + if (!ACTIONS.has(action)) throw new Error(`unsupported action ${action}`); + + const hasPattern = Object.prototype.hasOwnProperty.call(frontmatter, 'pattern'); + const hasConditions = Object.prototype.hasOwnProperty.call(frontmatter, 'conditions'); + if (hasPattern === hasConditions) { + throw new Error('define exactly one of pattern or conditions'); + } + + let conditions; + let pattern = null; + if (hasPattern) { + pattern = validateString(frontmatter.pattern, 'pattern', { + maxLength: LIMITS.maxPatternLength, + }); + conditions = [{ + field: inferredField(event), + operator: 'regex_match', + pattern, + }]; + } else { + if ( + !Array.isArray(frontmatter.conditions) || + frontmatter.conditions.length === 0 || + frontmatter.conditions.length > LIMITS.maxConditionCount + ) { + throw new Error(`conditions must contain 1-${LIMITS.maxConditionCount} items`); + } + conditions = frontmatter.conditions.map(condition => validateCondition(condition, event)); + } + + validateString(message, 'message', { allowNewlines: true }); + if (Buffer.byteLength(message, 'utf8') > LIMITS.maxMessageBytes) { + throw new Error('message exceeds its byte limit'); + } + + return Object.freeze({ + name, + enabled: frontmatter.enabled, + event, + action, + pattern, + conditions: Object.freeze(conditions.map(condition => Object.freeze(condition))), + toolMatcher: validateToolMatcher(frontmatter.tool_matcher), + message, + source, + }); +} + +function readFileBounded(fileDescriptor, maxBytes) { + const buffer = Buffer.alloc(maxBytes + 1); + let offset = 0; + while (offset < buffer.length) { + const bytesRead = fs.readSync( + fileDescriptor, + buffer, + offset, + buffer.length - offset, + null + ); + if (bytesRead === 0) break; + offset += bytesRead; + } + return { + buffer: buffer.subarray(0, Math.min(offset, maxBytes)), + exceeded: offset > maxBytes, + }; +} + +function loadRuleFile({ + claudeDir, + fileName, + remainingTotalBytes, + expectedRealDirectory, +}) { + const remainingBytes = + Number.isInteger(remainingTotalBytes) && remainingTotalBytes >= 0 + ? Math.min(remainingTotalBytes, LIMITS.maxTotalBytes) + : LIMITS.maxTotalBytes; + let consumedBytes = 0; + if ( + typeof claudeDir !== 'string' || + typeof fileName !== 'string' || + path.basename(fileName) !== fileName || + !FILE_NAME_PATTERN.test(fileName) + ) { + return { + rule: null, + diagnostic: diagnostic( + 'HOOKIFY_RULE_FILE_UNSAFE', + FILE_NAME_PATTERN.test(String(fileName || '')) ? fileName : null, + 'unsafe rule path rejected' + ), + bytesRead: 0, + }; + } + + const filePath = path.join(claudeDir, fileName); + let fileDescriptor; + try { + const linkStat = fs.lstatSync(filePath); + if (linkStat.isSymbolicLink() || !linkStat.isFile()) { + return { + rule: null, + diagnostic: diagnostic('HOOKIFY_RULE_FILE_UNSAFE', fileName, 'not a regular file'), + bytesRead: 0, + }; + } + + const realDirectory = fs.realpathSync(claudeDir); + if (expectedRealDirectory && realDirectory !== expectedRealDirectory) { + return { + rule: null, + diagnostic: diagnostic( + 'HOOKIFY_RULE_FILE_UNSAFE', + fileName, + 'project .claude changed during evaluation' + ), + bytesRead: 0, + }; + } + const realFile = fs.realpathSync(filePath); + if ( + path.dirname(realFile) !== realDirectory || + realFile !== path.join(realDirectory, fileName) + ) { + return { + rule: null, + diagnostic: diagnostic('HOOKIFY_RULE_FILE_UNSAFE', fileName, 'resolved outside project .claude'), + bytesRead: 0, + }; + } + + const noFollow = fs.constants.O_NOFOLLOW || 0; + fileDescriptor = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow); + const fileStat = fs.fstatSync(fileDescriptor); + if ( + fileStat.dev !== linkStat.dev || + fileStat.ino !== linkStat.ino || + fileStat.mode !== linkStat.mode || + fileStat.size !== linkStat.size || + fileStat.mtimeMs !== linkStat.mtimeMs + ) { + return { + rule: null, + diagnostic: diagnostic( + 'HOOKIFY_RULE_FILE_UNSAFE', + fileName, + 'rule identity changed during evaluation' + ), + bytesRead: 0, + }; + } + if ( + fs.realpathSync(claudeDir) !== realDirectory || + fs.realpathSync(filePath) !== realFile + ) { + return { + rule: null, + diagnostic: diagnostic( + 'HOOKIFY_RULE_FILE_UNSAFE', + fileName, + 'rule path changed during evaluation' + ), + bytesRead: 0, + }; + } + if (!fileStat.isFile()) { + return { + rule: null, + diagnostic: diagnostic('HOOKIFY_RULE_FILE_UNSAFE', fileName, 'not a regular file'), + bytesRead: 0, + }; + } + if (fileStat.size > LIMITS.maxFileBytes) { + return { + rule: null, + diagnostic: diagnostic('HOOKIFY_RULE_LIMIT', fileName, 'file exceeds byte limit'), + bytesRead: 0, + }; + } + if (fileStat.size > remainingBytes) { + return { + rule: null, + diagnostic: diagnostic('HOOKIFY_RULE_LIMIT', fileName, 'total rule byte limit reached'), + bytesRead: 0, + }; + } + + const read = readFileBounded( + fileDescriptor, + Math.min(LIMITS.maxFileBytes, remainingBytes) + ); + consumedBytes = read.buffer.length; + if (read.exceeded) { + return { + rule: null, + diagnostic: diagnostic('HOOKIFY_RULE_LIMIT', fileName, 'rule byte limit reached'), + bytesRead: consumedBytes, + }; + } + + const source = new TextDecoder('utf-8', { fatal: true }).decode(read.buffer); + const document = extractDocument(source); + return { + rule: validateRule(document.frontmatter, document.message, fileName), + diagnostic: null, + bytesRead: consumedBytes, + }; + } catch { + return { + rule: null, + diagnostic: diagnostic('HOOKIFY_RULE_INVALID', fileName, 'invalid rule schema or encoding'), + bytesRead: consumedBytes, + }; + } finally { + if (fileDescriptor !== undefined) { + try { + fs.closeSync(fileDescriptor); + } catch { + // The descriptor is already unusable; the rule still fails open. + } + } + } +} + +function listRuleNames(claudeDir) { + const names = []; + let scanned = 0; + let exceededDirectoryLimit = false; + const directory = fs.opendirSync(claudeDir); + try { + let entry; + while ((entry = directory.readSync()) !== null) { + scanned += 1; + if (scanned > LIMITS.maxDirectoryEntries) { + exceededDirectoryLimit = true; + break; + } + if (FILE_NAME_PATTERN.test(entry.name)) names.push(entry.name); + } + } finally { + directory.closeSync(); + } + + names.sort(); + return { + names: names.slice(0, LIMITS.maxRuleFiles), + exceeded: exceededDirectoryLimit || names.length > LIMITS.maxRuleFiles, + }; +} + +function loadRules(options = {}) { + const projectRoot = path.resolve(options.projectRoot || process.cwd()); + const claudeDir = path.join(projectRoot, '.claude'); + const diagnostics = []; + const rules = []; + let totalBytes = 0; + let realClaudeDir; + + try { + const directoryStat = fs.lstatSync(claudeDir); + if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) { + return { + rules, + diagnostics: [ + diagnostic( + 'HOOKIFY_RULE_DIRECTORY_UNSAFE', + null, + 'project .claude must be a real directory' + ), + ], + totalBytes, + }; + } + // Parent project paths can themselves have platform aliases (for example + // `/var` -> `/private/var` on macOS). Compare real paths while still + // requiring `.claude` itself to be the direct child checked by lstat. + const realProjectRoot = fs.realpathSync(projectRoot); + realClaudeDir = fs.realpathSync(claudeDir); + if (realClaudeDir !== path.join(realProjectRoot, '.claude')) { + return { + rules, + diagnostics: [ + diagnostic( + 'HOOKIFY_RULE_DIRECTORY_UNSAFE', + null, + 'project .claude resolved outside the project' + ), + ], + totalBytes, + }; + } + } catch (error) { + if (error && error.code === 'ENOENT') return { rules, diagnostics, totalBytes }; + return { + rules, + diagnostics: [ + diagnostic( + 'HOOKIFY_RULE_DIRECTORY_UNSAFE', + null, + 'project .claude could not be inspected' + ), + ], + totalBytes, + }; + } + + let listed; + try { + listed = listRuleNames(claudeDir); + } catch { + return { + rules, + diagnostics: [ + diagnostic('HOOKIFY_RULE_DIRECTORY_UNSAFE', null, 'project .claude could not be read'), + ], + totalBytes, + }; + } + if (listed.exceeded) { + diagnostics.push( + diagnostic('HOOKIFY_RULE_LIMIT', null, 'rule or directory entry count limit reached') + ); + } + + for (const fileName of listed.names) { + const loaded = loadRuleFile({ + claudeDir, + fileName, + remainingTotalBytes: LIMITS.maxTotalBytes - totalBytes, + expectedRealDirectory: realClaudeDir, + }); + totalBytes += loaded.bytesRead; + if (loaded.diagnostic) { + diagnostics.push(loaded.diagnostic); + continue; + } + const rule = loaded.rule; + if (!rule.enabled) continue; + if (options.event && rule.event !== 'all' && rule.event !== options.event) continue; + if (!options.event && rule.event !== 'all') continue; + rules.push(rule); + } + + return { rules, diagnostics, totalBytes }; +} + +module.exports = { + LIMITS, + extractDocument, + loadRuleFile, + loadRules, + parseFrontmatter, + validateRule, +}; diff --git a/scripts/hooks/hookify-regex-worker.js b/scripts/hooks/hookify-regex-worker.js new file mode 100644 index 000000000..59b10ae73 --- /dev/null +++ b/scripts/hooks/hookify-regex-worker.js @@ -0,0 +1,109 @@ +#!/usr/bin/env node +/** + * Isolated condition evaluator. Untrusted regexes run only in this worker. + */ + +'use strict'; + +const { isMainThread, workerData } = require('worker_threads'); + +const HEADER_BYTES = Int32Array.BYTES_PER_ELEMENT * 2; + +function safeSource(value) { + return typeof value === 'string' && /^hookify\.[A-Za-z0-9._-]+\.local\.md$/.test(value) + ? value + : 'a Hookify rule'; +} + +function evaluateCondition(condition, values, diagnostics, source) { + const value = values[condition.field]; + if (typeof value !== 'string') return false; + + switch (condition.operator) { + case 'contains': + return value.includes(condition.pattern); + case 'equals': + return value === condition.pattern; + case 'not_contains': + return !value.includes(condition.pattern); + case 'starts_with': + return value.startsWith(condition.pattern); + case 'ends_with': + return value.endsWith(condition.pattern); + case 'regex_match': + try { + return new RegExp(condition.pattern, 'i').test(value); + } catch { + diagnostics.push({ + code: 'HOOKIFY_REGEX_INVALID', + message: `Hookify skipped ${safeSource(source)}: invalid regular expression.`, + }); + return false; + } + default: + return false; + } +} + +function evaluateTasks(tasks, values = {}) { + const matchedIndexes = []; + const diagnostics = []; + for (const task of tasks) { + let matched = true; + for (const condition of task.conditions) { + if (!evaluateCondition(condition, values, diagnostics, task.source)) { + matched = false; + break; + } + } + if (matched) matchedIndexes.push(task.index); + } + return { matchedIndexes, diagnostics }; +} + +function writeResult(sharedBuffer, result, state = 1) { + const header = new Int32Array(sharedBuffer, 0, 2); + const output = Buffer.from(JSON.stringify(result), 'utf8'); + const available = sharedBuffer.byteLength - HEADER_BYTES; + if (output.length > available) { + const fallback = Buffer.from(JSON.stringify({ + matchedIndexes: [], + diagnostics: [{ + code: 'HOOKIFY_REGEX_WORKER_FAILED', + message: 'Hookify skipped rule evaluation: worker result exceeded its limit.', + }], + }), 'utf8'); + new Uint8Array(sharedBuffer, HEADER_BYTES, fallback.length).set(fallback); + Atomics.store(header, 1, fallback.length); + Atomics.store(header, 0, 2); + Atomics.notify(header, 0); + return; + } + + new Uint8Array(sharedBuffer, HEADER_BYTES, output.length).set(output); + Atomics.store(header, 1, output.length); + Atomics.store(header, 0, state); + Atomics.notify(header, 0); +} + +if (!isMainThread) { + try { + writeResult( + workerData.sharedBuffer, + evaluateTasks(workerData.tasks, workerData.values) + ); + } catch { + writeResult(workerData.sharedBuffer, { + matchedIndexes: [], + diagnostics: [{ + code: 'HOOKIFY_REGEX_WORKER_FAILED', + message: 'Hookify skipped rule evaluation: isolated worker failed.', + }], + }, 2); + } +} + +module.exports = { + evaluateTasks, + writeResult, +}; diff --git a/scripts/hooks/hookify-runner.js b/scripts/hooks/hookify-runner.js new file mode 100644 index 000000000..7d5b7c6d3 --- /dev/null +++ b/scripts/hooks/hookify-runner.js @@ -0,0 +1,417 @@ +#!/usr/bin/env node +/** + * Event-aware Hookify runner. It always exits 0 and emits structured JSON. + */ + +'use strict'; + +const { StringDecoder } = require('string_decoder'); +const { isDryRun, isHookEnabled } = require('../lib/hook-flags'); +const { + LIMITS: LOADER_LIMITS, + loadRules, +} = require('./hookify-loader'); +const { + evaluateRules, + truncateUtf8, +} = require('./hookify-engine'); + +const LIMITS = Object.freeze({ + ...LOADER_LIMITS, + maxInputBytes: 256 * 1024, + maxOutputBytes: 8192, + regexTimeoutMs: 250, +}); +const EVENTS = new Set([ + 'PreToolUse', + 'PostToolUse', + 'Stop', + 'UserPromptSubmit', +]); +const FILE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']); + +function plainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function hasUnsafeControlCharacters(value) { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code === 0x7f || (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d)) { + return true; + } + } + return false; +} + +function safeString(value, maxBytes) { + return ( + typeof value === 'string' && + Buffer.byteLength(value, 'utf8') <= maxBytes && + !hasUnsafeControlCharacters(value) + ); +} + +function expectedEventFromContext(context, payload) { + if (EVENTS.has(context.expectedEvent)) return context.expectedEvent; + if (context.hookId === 'pre:hookify') return 'PreToolUse'; + if (context.hookId === 'post:hookify') return 'PostToolUse'; + if (context.hookId === 'stop:hookify') return 'Stop'; + if (context.hookId === 'prompt:hookify') return 'UserPromptSubmit'; + return EVENTS.has(payload?.hook_event_name) ? payload.hook_event_name : null; +} + +function validateInput(payload, expectedEvent) { + if (!plainObject(payload)) return 'hook input must be a JSON object'; + if (!expectedEvent || payload.hook_event_name !== expectedEvent) { + return 'hook event did not match the registered runtime event'; + } + + if (expectedEvent === 'PreToolUse' || expectedEvent === 'PostToolUse') { + if (!safeString(payload.tool_name, 128) || !plainObject(payload.tool_input)) { + return 'tool hooks require bounded tool_name and tool_input fields'; + } + } else if (expectedEvent === 'UserPromptSubmit') { + if (!safeString(payload.prompt, LIMITS.maxInputBytes)) { + return 'UserPromptSubmit requires a bounded prompt string'; + } + } else if (expectedEvent === 'Stop') { + if ( + payload.last_assistant_message !== undefined && + !safeString(payload.last_assistant_message, LIMITS.maxInputBytes) + ) { + return 'Stop last_assistant_message must be a bounded string'; + } + if ( + payload.stop_hook_active !== undefined && + typeof payload.stop_hook_active !== 'boolean' + ) { + return 'Stop stop_hook_active must be true or false'; + } + } + return null; +} + +function ruleEventForInput(eventName, payload) { + if (eventName === 'UserPromptSubmit') return 'prompt'; + if (eventName === 'Stop') return 'stop'; + if (payload.tool_name === 'Bash') return 'bash'; + if (FILE_TOOLS.has(payload.tool_name)) return 'file'; + return null; +} + +function textByteLength(value) { + return Buffer.byteLength(String(value || ''), 'utf8'); +} + +function truncateText(value, maxBytes) { + const text = String(value || ''); + if (textByteLength(text) <= maxBytes) return text; + const marker = '\n… [Hookify output truncated]'; + return `${truncateUtf8(text, Math.max(0, maxBytes - textByteLength(marker)))}${marker}`; +} + +function joinBounded(items, maxBytes) { + return truncateText(items.filter(Boolean).join('\n\n'), maxBytes); +} + +function formatRule(rule) { + return `**[${rule.name}]**\n${rule.message}`; +} + +function formatDiagnostics(diagnostics) { + return diagnostics.map(item => `**[Hookify diagnostic]**\n${item.message}`); +} + +function contextOutput(eventName, text) { + if (!text) return {}; + if (eventName === 'Stop') return { systemMessage: text }; + return { + hookSpecificOutput: { + hookEventName: eventName, + additionalContext: text, + }, + }; +} + +function immutableSetText(output, key, value) { + if (key === 'additionalContext') { + return { + ...output, + hookSpecificOutput: { + ...output.hookSpecificOutput, + additionalContext: value, + }, + }; + } + if (key === 'permissionDecisionReason') { + return { + ...output, + hookSpecificOutput: { + ...output.hookSpecificOutput, + permissionDecisionReason: value, + }, + }; + } + return { ...output, [key]: value }; +} + +function boundOutput(output) { + let bounded = output; + const textKeys = [ + 'additionalContext', + 'systemMessage', + 'reason', + 'permissionDecisionReason', + ]; + + for (let attempt = 0; attempt < 12; attempt += 1) { + const serialized = JSON.stringify(bounded); + if (Buffer.byteLength(serialized, 'utf8') <= LIMITS.maxOutputBytes) { + return serialized; + } + + let largest = null; + for (const key of textKeys) { + const value = key === 'additionalContext' || key === 'permissionDecisionReason' + ? bounded.hookSpecificOutput?.[key] + : bounded[key]; + if (typeof value !== 'string') continue; + const bytes = textByteLength(value); + if (!largest || bytes > largest.bytes) largest = { key, value, bytes }; + } + if (!largest || largest.bytes <= 96) break; + bounded = immutableSetText( + bounded, + largest.key, + truncateText(largest.value, Math.max(96, Math.floor(largest.bytes * 0.6))) + ); + } + + if (bounded.hookSpecificOutput?.permissionDecision === 'deny') { + return JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: 'A Hookify rule blocked this tool call; details were truncated.', + }, + }); + } + if (bounded.decision === 'block') { + return JSON.stringify({ + decision: 'block', + reason: 'A Hookify rule blocked this event; details were truncated.', + }); + } + return JSON.stringify(contextOutput( + bounded.hookSpecificOutput?.hookEventName || 'PreToolUse', + 'Hookify diagnostic: output exceeded its configured limit.' + )); +} + +function buildOutput(eventName, matches, diagnostics) { + const blocking = matches.filter(rule => rule.action === 'block'); + const warnings = matches.filter(rule => rule.action === 'warn'); + const contextItems = [ + ...warnings.map(formatRule), + ...formatDiagnostics(diagnostics), + ]; + const contextLimit = blocking.length > 0 ? 3000 : 7000; + const additionalContext = joinBounded(contextItems, contextLimit); + const blockReason = joinBounded(blocking.map(formatRule), 3800); + + if (blocking.length === 0) { + return contextOutput(eventName, additionalContext); + } + + if (eventName === 'PreToolUse') { + const hookSpecificOutput = { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: blockReason, + ...(additionalContext ? { additionalContext } : {}), + }; + return { hookSpecificOutput }; + } + + const reason = eventName === 'PostToolUse' + ? [ + 'The PostToolUse action already completed; this decision cannot undo it.', + 'Correct the result before continuing.', + blockReason, + ].join('\n\n') + : blockReason; + const output = { + decision: 'block', + reason, + }; + if (!additionalContext) return output; + if (eventName === 'Stop') return { ...output, systemMessage: additionalContext }; + return { + ...output, + hookSpecificOutput: { + hookEventName: eventName, + additionalContext, + }, + }; +} + +function failOpen(eventName, message) { + const safeEvent = EVENTS.has(eventName) ? eventName : 'PreToolUse'; + return buildOutput(safeEvent, [], [{ + code: 'HOOKIFY_INPUT_INVALID', + message: `Hookify diagnostic: ${message}. Rules were not enforced for this event.`, + }]); +} + +function run(rawInput, context = {}) { + const raw = typeof rawInput === 'string' ? rawInput : ''; + const preliminaryEvent = EVENTS.has(context.expectedEvent) + ? context.expectedEvent + : expectedEventFromContext(context, null); + + try { + if ( + context.truncated === true || + Buffer.byteLength(raw, 'utf8') > LIMITS.maxInputBytes + ) { + return { + stdout: boundOutput(failOpen(preliminaryEvent, 'input exceeded the byte limit')), + stderr: '', + exitCode: 0, + }; + } + + let payload; + try { + payload = JSON.parse(raw); + } catch { + return { + stdout: boundOutput(failOpen(preliminaryEvent, 'hook input was not valid JSON')), + stderr: '', + exitCode: 0, + }; + } + + const eventName = expectedEventFromContext(context, payload); + const inputError = validateInput(payload, eventName); + if (inputError) { + return { + stdout: boundOutput(failOpen(eventName || preliminaryEvent, inputError)), + stderr: '', + exitCode: 0, + }; + } + if (eventName === 'Stop' && payload.stop_hook_active === true) { + return { + stdout: '{}', + stderr: '', + exitCode: 0, + }; + } + + const loaded = loadRules({ + projectRoot: context.projectRoot || process.cwd(), + event: ruleEventForInput(eventName, payload), + }); + const evaluated = evaluateRules(loaded.rules, payload, { + timeoutMs: LIMITS.regexTimeoutMs, + }); + const output = buildOutput(eventName, evaluated.matches, [ + ...loaded.diagnostics, + ...evaluated.diagnostics, + ]); + return { + stdout: boundOutput(output), + stderr: '', + exitCode: 0, + }; + } catch { + return { + stdout: boundOutput(failOpen(preliminaryEvent, 'internal runtime failure')), + stderr: '', + exitCode: 0, + }; + } +} + +function readStdinBounded() { + return new Promise(resolve => { + const decoder = new StringDecoder('utf8'); + let raw = ''; + let bytesRead = 0; + let truncated = false; + let settled = false; + + const finish = () => { + if (settled) return; + settled = true; + if (!truncated) raw += decoder.end(); + resolve({ raw, truncated }); + }; + + process.stdin.on('data', chunk => { + if (settled) return; + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const remaining = Math.max(0, LIMITS.maxInputBytes - bytesRead); + const accepted = buffer.subarray(0, remaining); + if (accepted.length > 0) { + raw += decoder.write(accepted); + bytesRead += accepted.length; + } + if (accepted.length < buffer.length) { + truncated = true; + process.stdin.destroy(); + finish(); + } + }); + process.stdin.once('end', finish); + process.stdin.once('error', finish); + }); +} + +async function cli() { + const expectedEvent = EVENTS.has(process.argv[2]) ? process.argv[2] : null; + const hookId = typeof process.argv[3] === 'string' ? process.argv[3] : ''; + const profiles = typeof process.argv[4] === 'string' + ? process.argv[4] + : 'minimal,standard,strict'; + const input = await readStdinBounded(); + const passthrough = input.truncated ? '' : input.raw; + if (hookId && !isHookEnabled(hookId, { profiles })) { + process.exitCode = 0; + process.stdout.write(passthrough); + return; + } + if (isDryRun()) { + process.exitCode = 0; + process.stderr.write( + `[DryRun] Hook "${hookId || 'hookify'}" would evaluate ${expectedEvent || 'an unknown event'} rules\n` + ); + process.stdout.write(passthrough); + return; + } + const result = run(input.raw, { + expectedEvent, + truncated: input.truncated, + projectRoot: process.cwd(), + }); + process.exitCode = 0; + process.stdout.write(result.stdout); +} + +if (require.main === module) { + cli().catch(() => { + process.exitCode = 0; + process.stdout.write(boundOutput(failOpen(process.argv[2], 'internal runtime failure'))); + }); +} + +module.exports = { + LIMITS, + boundOutput, + buildOutput, + cli, + run, + validateInput, +}; diff --git a/scripts/hooks/posttooluse-dispatcher.js b/scripts/hooks/posttooluse-dispatcher.js index a5c3d3c41..8e40019b8 100644 --- a/scripts/hooks/posttooluse-dispatcher.js +++ b/scripts/hooks/posttooluse-dispatcher.js @@ -19,6 +19,7 @@ const { run: runSessionActivityTracker } = require('./session-activity-tracker') const { run: runObserve } = require('./observe-runner'); const { run: runMetricsBridge } = require('./ecc-metrics-bridge'); const { run: runContextMonitor } = require('./ecc-context-monitor'); +const { run: runHookify } = require('./hookify-runner'); const MAX_STDIN = 1024 * 1024; @@ -29,7 +30,20 @@ const SYNC_HOOKS = [ { id: 'post:governance-capture', matcher: 'Bash|Write|Edit|MultiEdit', profiles: 'standard,strict', script: 'scripts/hooks/governance-capture.js', run: runGovernanceCapture }, { id: 'post:session-activity-tracker', matcher: '*', profiles: 'standard,strict', script: 'scripts/hooks/session-activity-tracker.js', run: runSessionActivityTracker }, { id: 'post:ecc-metrics-bridge', matcher: '*', profiles: 'minimal,standard,strict', script: 'scripts/hooks/ecc-metrics-bridge.js', run: runMetricsBridge }, - { id: 'post:ecc-context-monitor', matcher: '*', profiles: 'standard,strict', script: 'scripts/hooks/ecc-context-monitor.js', run: runContextMonitor } + { id: 'post:ecc-context-monitor', matcher: '*', profiles: 'standard,strict', script: 'scripts/hooks/ecc-context-monitor.js', run: runContextMonitor }, + { + id: 'post:hookify', + matcher: '*', + profiles: 'minimal,standard,strict', + script: 'scripts/hooks/hookify-runner.js', + run(raw) { + const result = runHookify(raw, { + expectedEvent: 'PostToolUse', + projectRoot: process.cwd() + }); + return result.stdout === '{}' ? { ...result, stdout: '' } : result; + } + } ]; const ASYNC_HOOKS = [ @@ -132,12 +146,23 @@ function appendLine(current, next) { return current + (String(next).endsWith('\n') ? String(next) : `${next}\n`); } -function parseAdditionalContext(stdout) { +function parseStructuredPostOutput(stdout) { try { const parsed = JSON.parse(stdout); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; const output = parsed?.hookSpecificOutput; - if (output?.hookEventName !== 'PostToolUse') return null; - return typeof output.additionalContext === 'string' ? output.additionalContext : null; + const context = + output?.hookEventName === 'PostToolUse' && + typeof output.additionalContext === 'string' + ? output.additionalContext + : ''; + const blocked = parsed.decision === 'block' && typeof parsed.reason === 'string'; + if (!context && !blocked) return null; + return { + context, + decision: blocked ? 'block' : null, + reason: blocked ? parsed.reason : '' + }; } catch { return null; } @@ -147,15 +172,27 @@ function mergeHookStdout(outputs) { if (outputs.length === 0) return { stdout: '', warning: '' }; if (outputs.length === 1) return { stdout: outputs[0].stdout, warning: '' }; - const contexts = outputs.map(output => parseAdditionalContext(output.stdout)); - if (contexts.every(context => context !== null)) { - return { - stdout: JSON.stringify({ - hookSpecificOutput: { - hookEventName: 'PostToolUse', - additionalContext: contexts.join('\n') + const structured = outputs.map(output => parseStructuredPostOutput(output.stdout)); + if (structured.every(output => output !== null)) { + const contexts = structured.map(output => output.context).filter(Boolean); + const reasons = structured + .filter(output => output.decision === 'block') + .map(output => output.reason); + const payload = { + ...(reasons.length > 0 + ? { decision: 'block', reason: reasons.join('\n\n') } + : {}), + ...(contexts.length > 0 + ? { + hookSpecificOutput: { + hookEventName: 'PostToolUse', + additionalContext: contexts.join('\n') + } } - }), + : {}) + }; + return { + stdout: JSON.stringify(payload), warning: '' }; } diff --git a/skills/hookify-rules/SKILL.md b/skills/hookify-rules/SKILL.md index e256d2e92..2cb1b8f1b 100644 --- a/skills/hookify-rules/SKILL.md +++ b/skills/hookify-rules/SKILL.md @@ -7,7 +7,10 @@ description: This skill should be used when the user asks to create a hookify ru ## Overview -Hookify rules are markdown files with YAML frontmatter that define patterns to watch for and messages to show when those patterns match. Rules are stored in `.claude/hookify.{rule-name}.local.md` files. +Hookify rules are Markdown files with YAML frontmatter that define patterns to +watch for and messages to show when those patterns match. ECC's built-in Node.js +runtime loads them from the current project `.claude/` directory for +PreToolUse, PostToolUse, UserPromptSubmit, and Stop. ## Rule File Format @@ -32,8 +35,10 @@ Can include markdown formatting, warnings, suggestions, etc. | name | Yes | kebab-case string | Unique identifier (verb-first: warn-*, block-*, require-*) | | enabled | Yes | true/false | Toggle without deleting | | event | Yes | bash/file/stop/prompt/all | Which hook event triggers this | -| action | No | warn/block | warn (default) shows message; block prevents operation | +| action | No | warn/block | warn (default) shows a message; block uses the event-specific behavior below | | pattern | Yes* | regex string | Pattern to match (*or use conditions for complex rules) | +| conditions | Yes* | list | All field/operator/pattern entries must match (*use exactly one of pattern or conditions) | +| tool_matcher | No | `*` or exact names separated by `\|` | Limits a rule to tools such as `Bash` or `Write\|Edit` | ### Advanced Format (Multiple Conditions) @@ -58,10 +63,15 @@ You're adding an API key to a .env file. Ensure this file is in .gitignore! - bash: `command` - file: `file_path`, `new_text`, `old_text`, `content` - prompt: `user_prompt` +- stop: `content` (the last assistant message; transcripts are never read) +- all: any field above when it exists for the current event; unavailable + fields do not match **Operators:** `regex_match`, `contains`, `equals`, `not_contains`, `starts_with`, `ends_with` All conditions must match for rule to trigger. +`regex_match` is case-insensitive. The literal string operators are +case-sensitive. ## Event Type Guide @@ -78,10 +88,30 @@ Match Edit/Write/MultiEdit operations: - Sensitive files: `\.env$`, `credentials`, `\.pem$` ### stop Events -Completion checks and reminders. Pattern `.*` matches always. +Completion checks and reminders against the last assistant message. Pattern +`.*` matches every non-empty or empty final message. Use `action: block` when +Claude must continue; a `warn` Stop rule is only a non-blocking user-visible +system message. ### prompt Events -Match user prompt content for workflow enforcement. +Match Claude Code's submitted `prompt` through the rule field `user_prompt`. + +## Runtime Behavior + +- `action: block|warn` is enforced with Claude Code's event-specific structured + output. +- PreToolUse blocks deny a pending tool call. +- UserPromptSubmit blocks reject the prompt. +- Stop blocks continue the conversation. +- PostToolUse blocks provide corrective feedback only; the completed tool + cannot be undone. +- Warnings reach Claude through `additionalContext` for PreToolUse, + PostToolUse, and UserPromptSubmit. +- Stop warnings do not continue Claude. Use a blocking Stop rule for that. +- Recursive Stop evaluation is skipped when `stop_hook_active` is already + true, so an always-matching block cannot continue forever. +- Hookify never mutates tool input and never reads `transcript_path`. +- Malformed or unsafe rules fail open with a bounded structured diagnostic. ## Pattern Writing Tips @@ -98,15 +128,23 @@ Match user prompt content for workflow enforcement. ### Testing ```bash -python3 -c "import re; print(re.search(r'your_pattern', 'test text'))" +node -e "console.log(new RegExp('your_pattern', 'i').test('test text'))" ``` +Regex evaluation runs in a resource-limited worker with one hard total +deadline, but patterns should still be kept focused and short. + ## File Organization -- **Location**: `.claude/` directory in project root +- **Location**: the real (not symlinked) `.claude/` directory in the project root - **Naming**: `.claude/hookify.{descriptive-name}.local.md` - **Gitignore**: Add `.claude/*.local.md` to `.gitignore` +The loader accepts only direct regular files and a strict frontmatter subset. +Unknown fields, YAML anchors/tags, traversal, symlinks, and non-regular files +are rejected. Current bounds are 64 rules, 64 KiB per file, 512 KiB total, +512 characters per pattern, 16 conditions, and 4 KiB per message. + ## Commands - `/hookify [description]` - Create new rules (auto-analyzes conversation if no args) diff --git a/tests/docs/hookify-runtime-docs.test.js b/tests/docs/hookify-runtime-docs.test.js new file mode 100644 index 000000000..306bc56b8 --- /dev/null +++ b/tests/docs/hookify-runtime-docs.test.js @@ -0,0 +1,89 @@ +/** + * Documentation contract tests for the built-in Hookify runtime. + */ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const REPO_ROOT = path.join(__dirname, '..', '..'); + +function read(relativePath) { + return fs.readFileSync(path.join(REPO_ROOT, relativePath), 'utf8'); +} + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function runTests() { + console.log('\n=== Hookify runtime documentation tests ===\n'); + + let passed = 0; + let failed = 0; + + if (test('help documents the built-in runtime, limits, and event-correct warning/block behavior', () => { + const help = read('commands/hookify-help.md'); + + for (const requiredText of [ + 'built-in Node.js runtime', + 'PreToolUse', + 'PostToolUse', + 'UserPromptSubmit', + 'Stop', + 'PostToolUse cannot undo', + 'Stop warning does not make Claude continue', + 'fail open', + 'worker', + 'does not read `transcript_path`', + ]) { + assert.ok(help.includes(requiredText), `Hookify help should mention: ${requiredText}`); + } + })) passed++; else failed++; + + if (test('authoring command and skill document the complete supported schema', () => { + const authoring = read('commands/hookify.md'); + const skill = read('skills/hookify-rules/SKILL.md'); + const combined = `${authoring}\n${skill}`; + + for (const requiredText of [ + 'conditions:', + 'tool_matcher', + 'regex_match', + 'not_contains', + 'starts_with', + 'ends_with', + 'pattern', + 'action: block|warn', + ]) { + assert.ok(combined.includes(requiredText), `Hookify authoring docs should mention: ${requiredText}`); + } + assert.ok(skill.includes('last assistant message')); + assert.ok(skill.includes('project `.claude/` directory')); + })) passed++; else failed++; + + if (test('list and configure commands state that malformed rules are skipped rather than enforced', () => { + const list = read('commands/hookify-list.md'); + const configure = read('commands/hookify-configure.md'); + + assert.ok(list.includes('malformed')); + assert.ok(list.includes('skipped')); + assert.ok(configure.includes('strict schema')); + })) passed++; else failed++; + + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/hooks/hookify-engine.test.js b/tests/hooks/hookify-engine.test.js new file mode 100644 index 000000000..8eaab5609 --- /dev/null +++ b/tests/hooks/hookify-engine.test.js @@ -0,0 +1,357 @@ +/** + * Unit tests for Hookify condition evaluation. + */ + +'use strict'; + +const assert = require('assert'); + +const { + evaluateRules, + extractConditionValue, + truncateUtf8, +} = require('../../scripts/hooks/hookify-engine'); +const { + evaluateTasks, + writeResult, +} = require('../../scripts/hooks/hookify-regex-worker'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function rule(overrides = {}) { + return { + name: 'test-rule', + enabled: true, + event: 'bash', + action: 'warn', + toolMatcher: null, + conditions: [{ + field: 'command', + operator: 'regex_match', + pattern: 'npm\\s+publish', + }], + message: 'Check the release.', + source: 'hookify.test-rule.local.md', + ...overrides, + }; +} + +function deepFreeze(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value; + Object.freeze(value); + for (const child of Object.values(value)) deepFreeze(child); + return value; +} + +function runTests() { + console.log('\n=== Hookify engine tests ===\n'); + + let passed = 0; + let failed = 0; + + if (test('evaluates regexes case-insensitively and requires every condition', () => { + const rules = [ + rule({ + conditions: [ + { field: 'command', operator: 'regex_match', pattern: 'NPM\\s+PUBLISH' }, + { field: 'command', operator: 'not_contains', pattern: '--dry-run' }, + ], + }), + ]; + const matching = { + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'npm publish' }, + }; + const notMatching = { + ...matching, + tool_input: { command: 'npm publish --dry-run' }, + }; + + assert.deepStrictEqual(evaluateRules(rules, matching).matches.map(item => item.name), ['test-rule']); + assert.deepStrictEqual(evaluateRules(rules, notMatching).matches, []); + })) passed++; else failed++; + + if (test('supports every documented non-regex operator', () => { + const input = { + hook_event_name: 'PreToolUse', + tool_name: 'Write', + tool_input: { + file_path: '/repo/.env', + content: 'PREFIX=abc\nAPI_KEY=secret\nSUFFIX=xyz', + }, + }; + const rules = [ + rule({ + event: 'file', + conditions: [ + { field: 'file_path', operator: 'ends_with', pattern: '.env' }, + { field: 'content', operator: 'contains', pattern: 'API_KEY' }, + { field: 'content', operator: 'starts_with', pattern: 'PREFIX' }, + { field: 'content', operator: 'ends_with', pattern: 'xyz' }, + { field: 'file_path', operator: 'equals', pattern: '/repo/.env' }, + { field: 'content', operator: 'not_contains', pattern: 'SAFE_PLACEHOLDER' }, + ], + }), + ]; + + assert.deepStrictEqual(evaluateRules(rules, input).matches.map(item => item.name), ['test-rule']); + })) passed++; else failed++; + + if (test('extracts safe event fields without reading transcript paths', () => { + const multiEdit = { + hook_event_name: 'PreToolUse', + tool_name: 'MultiEdit', + tool_input: { + file_path: '/repo/index.js', + edits: [ + { old_string: 'before one', new_string: 'after one' }, + { old_string: 'before two', new_string: 'after two' }, + ], + }, + }; + const stop = { + hook_event_name: 'Stop', + transcript_path: '/private/transcript.jsonl', + last_assistant_message: 'Finished safely.', + }; + + assert.strictEqual(extractConditionValue('new_text', multiEdit), 'after one\nafter two'); + assert.strictEqual(extractConditionValue('old_text', multiEdit), 'before one\nbefore two'); + assert.strictEqual(extractConditionValue('content', stop), 'Finished safely.'); + assert.strictEqual(extractConditionValue('transcript', stop), null); + })) passed++; else failed++; + + if (test('uses the Claude prompt field for user_prompt conditions', () => { + const input = { + hook_event_name: 'UserPromptSubmit', + prompt: 'deploy production now', + user_prompt: 'this compatibility field must not be trusted', + }; + const promptRule = rule({ + event: 'prompt', + conditions: [{ + field: 'user_prompt', + operator: 'contains', + pattern: 'deploy production', + }], + }); + + assert.deepStrictEqual(evaluateRules([promptRule], input).matches.map(item => item.name), ['test-rule']); + })) passed++; else failed++; + + if (test('honors exact pipe-separated tool matchers', () => { + const matchedRule = rule({ toolMatcher: 'Bash|Write' }); + const bash = { + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'npm publish' }, + }; + const edit = { + ...bash, + tool_name: 'Edit', + tool_input: { command: 'npm publish' }, + }; + + assert.strictEqual(evaluateRules([matchedRule], bash).matches.length, 1); + assert.strictEqual(evaluateRules([matchedRule], edit).matches.length, 0); + })) passed++; else failed++; + + if (test('does not mutate hook input while evaluating file edits', () => { + const input = deepFreeze({ + hook_event_name: 'PreToolUse', + tool_name: 'Edit', + tool_input: { + file_path: '/repo/index.js', + old_string: 'old', + new_string: 'new API_KEY', + }, + }); + const snapshot = JSON.stringify(input); + const fileRule = rule({ + event: 'file', + conditions: [{ + field: 'new_text', + operator: 'contains', + pattern: 'API_KEY', + }], + }); + + assert.strictEqual(evaluateRules([fileRule], input).matches.length, 1); + assert.strictEqual(JSON.stringify(input), snapshot); + })) passed++; else failed++; + + if (test('fails open with a sanitized diagnostic for invalid regex syntax', () => { + const input = { + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'anything' }, + }; + const result = evaluateRules([ + rule({ + name: 'bad-regex', + source: 'hookify.bad-regex.local.md', + conditions: [{ field: 'command', operator: 'regex_match', pattern: '(' }], + }), + ], input); + + assert.deepStrictEqual(result.matches, []); + assert.strictEqual(result.diagnostics.length, 1); + assert.strictEqual(result.diagnostics[0].code, 'HOOKIFY_REGEX_INVALID'); + assert.ok(!result.diagnostics[0].message.includes('anything')); + })) passed++; else failed++; + + if (test('terminates catastrophic regex evaluation at one hard total deadline', () => { + const input = { + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: `${'a'.repeat(30000)}!` }, + }; + const dangerousRules = Array.from({ length: 8 }, (_, index) => rule({ + name: `danger-${index}`, + source: `hookify.danger-${index}.local.md`, + conditions: [{ field: 'command', operator: 'regex_match', pattern: '(a+)+$' }], + })); + const startedAt = Date.now(); + + const result = evaluateRules(dangerousRules, input, { timeoutMs: 100 }); + const elapsed = Date.now() - startedAt; + + assert.deepStrictEqual(result.matches, []); + assert.ok(result.diagnostics.some(item => item.code === 'HOOKIFY_REGEX_TIMEOUT')); + assert.ok(elapsed < 1500, `regex worker should be terminated promptly, took ${elapsed}ms`); + })) passed++; else failed++; + + if (test('bounds multibyte fields and handles absent or incompatible field shapes', () => { + const longValue = `${'a'.repeat(65535)}界tail`; + const truncated = truncateUtf8(longValue); + assert.ok(Buffer.byteLength(truncated, 'utf8') <= 64 * 1024); + assert.ok(!truncated.includes('\ufffd')); + + assert.strictEqual(extractConditionValue('command', null), null); + assert.strictEqual(extractConditionValue('command', []), null); + assert.strictEqual(extractConditionValue('command', { + tool_name: 'Read', + tool_input: {}, + }), null); + assert.strictEqual(extractConditionValue('file_path', { + tool_name: 'Read', + tool_input: {}, + }), null); + assert.strictEqual(extractConditionValue('new_text', { + tool_name: 'Read', + tool_input: {}, + }), null); + assert.strictEqual(extractConditionValue('old_text', { + tool_name: 'Write', + tool_input: { old_text: 'before' }, + }), 'before'); + assert.strictEqual(extractConditionValue('user_prompt', { + hook_event_name: 'Stop', + prompt: 'ignored', + }), null); + assert.strictEqual(extractConditionValue('content', { + hook_event_name: 'PreToolUse', + tool_name: 'Read', + tool_input: {}, + }), null); + assert.strictEqual(extractConditionValue('unknown', {}), null); + })) passed++; else failed++; + + if (test('normalizes Write/Edit fallbacks and skips malformed MultiEdit entries', () => { + assert.strictEqual(extractConditionValue('content', { + hook_event_name: 'PreToolUse', + tool_name: 'Write', + tool_input: { new_text: 'new text' }, + }), 'new text'); + assert.strictEqual(extractConditionValue('content', { + hook_event_name: 'PreToolUse', + tool_name: 'Edit', + tool_input: { new_string: 'new string' }, + }), 'new string'); + assert.strictEqual(extractConditionValue('new_text', { + tool_name: 'MultiEdit', + tool_input: { + edits: [null, [], { new_string: 42 }, { new_string: 'accepted' }], + }, + }), 'accepted'); + assert.strictEqual(extractConditionValue('old_text', { + tool_name: 'MultiEdit', + tool_input: {}, + }), null); + const notebookEdit = { + tool_name: 'NotebookEdit', + tool_input: { + notebook_path: '/repo/analysis.ipynb', + new_source: 'print("safe")', + }, + }; + assert.strictEqual( + extractConditionValue('file_path', notebookEdit), + '/repo/analysis.ipynb' + ); + assert.strictEqual(extractConditionValue('new_text', notebookEdit), 'print("safe")'); + assert.strictEqual(extractConditionValue('content', notebookEdit), 'print("safe")'); + })) passed++; else failed++; + + if (test('handles empty rule sets, filtered tools, and bounded timeout options', () => { + const input = { + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'npm publish' }, + }; + assert.deepStrictEqual(evaluateRules(null, input), { matches: [], diagnostics: [] }); + assert.deepStrictEqual(evaluateRules([], input), { matches: [], diagnostics: [] }); + assert.deepStrictEqual( + evaluateRules([rule({ toolMatcher: 'Write' })], input), + { matches: [], diagnostics: [] } + ); + assert.strictEqual(evaluateRules([rule()], input, { timeoutMs: -1 }).matches.length, 1); + assert.strictEqual(evaluateRules([rule()], input, { timeoutMs: 5000 }).matches.length, 1); + })) passed++; else failed++; + + if (test('worker helper bounds result serialization and rejects unsupported operators', () => { + const evaluated = evaluateTasks([ + { + index: 0, + source: '../unsafe-name', + conditions: [{ field: 'command', operator: 'regex_match', pattern: '(' }], + }, + { + index: 1, + source: 'hookify.operator.local.md', + conditions: [{ field: 'command', operator: 'unsupported', pattern: 'x' }], + }, + ], { command: 'anything' }); + assert.deepStrictEqual(evaluated.matchedIndexes, []); + assert.strictEqual(evaluated.diagnostics[0].code, 'HOOKIFY_REGEX_INVALID'); + assert.ok(evaluated.diagnostics[0].message.includes('a Hookify rule')); + + const shared = new SharedArrayBuffer(512); + writeResult(shared, { + matchedIndexes: [], + diagnostics: [{ code: 'X', message: 'x'.repeat(1000) }], + }); + const header = new Int32Array(shared, 0, 2); + assert.strictEqual(Atomics.load(header, 0), 2); + const bytes = new Uint8Array(shared, 8, Atomics.load(header, 1)); + const result = JSON.parse(Buffer.from(bytes).toString('utf8')); + assert.strictEqual(result.diagnostics[0].code, 'HOOKIFY_REGEX_WORKER_FAILED'); + })) passed++; else failed++; + + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/hooks/hookify-loader.test.js b/tests/hooks/hookify-loader.test.js new file mode 100644 index 000000000..372ed815c --- /dev/null +++ b/tests/hooks/hookify-loader.test.js @@ -0,0 +1,452 @@ +/** + * Unit tests for the bounded Hookify rule loader. + */ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + LIMITS, + extractDocument, + loadRuleFile, + loadRules, + parseFrontmatter, + validateRule, +} = require('../../scripts/hooks/hookify-loader'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function withProject(fn) { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-hookify-loader-')); + const claudeDir = path.join(projectRoot, '.claude'); + fs.mkdirSync(claudeDir); + try { + return fn({ projectRoot, claudeDir }); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } +} + +function writeRule(claudeDir, fileName, frontmatter, message = 'Rule matched.') { + const source = `---\n${frontmatter}\n---\n${message}\n`; + fs.writeFileSync(path.join(claudeDir, fileName), source); +} + +function runTests() { + console.log('\n=== Hookify loader tests ===\n'); + + let passed = 0; + let failed = 0; + + if (test('loads enabled pattern and condition rules for the requested event', () => { + withProject(({ projectRoot, claudeDir }) => { + writeRule( + claudeDir, + 'hookify.block-danger.local.md', + [ + 'name: block-danger', + 'enabled: true', + 'event: bash', + 'action: block', + 'pattern: "rm\\\\s+-rf"', + ].join('\n'), + 'Do not recursively remove this path.' + ); + writeRule( + claudeDir, + 'hookify.warn-secrets.local.md', + [ + 'name: warn-secrets', + 'enabled: true', + 'event: all', + 'action: warn', + 'tool_matcher: Write|Edit', + 'conditions:', + ' - field: file_path', + ' operator: ends_with', + ' pattern: .env', + ' - field: content', + ' operator: contains', + ' pattern: API_KEY', + ].join('\n'), + 'Keep credentials out of source control.' + ); + writeRule( + claudeDir, + 'hookify.disabled.local.md', + [ + 'name: disabled', + 'enabled: false', + 'event: bash', + 'pattern: anything', + ].join('\n') + ); + + const result = loadRules({ projectRoot, event: 'bash' }); + + assert.deepStrictEqual(result.rules.map(rule => rule.name), [ + 'block-danger', + 'warn-secrets', + ]); + assert.deepStrictEqual(result.rules[0].conditions, [{ + field: 'command', + operator: 'regex_match', + pattern: 'rm\\s+-rf', + }]); + assert.strictEqual(result.rules[1].conditions.length, 2); + assert.deepStrictEqual(result.diagnostics, []); + }); + })) passed++; else failed++; + + if (test('rejects unknown fields, ambiguous matchers, invalid operators, and event-incompatible fields', () => { + withProject(({ projectRoot, claudeDir }) => { + writeRule( + claudeDir, + 'hookify.unknown.local.md', + [ + 'name: unknown', + 'enabled: true', + 'event: bash', + 'pattern: ls', + 'surprise: nope', + ].join('\n') + ); + writeRule( + claudeDir, + 'hookify.ambiguous.local.md', + [ + 'name: ambiguous', + 'enabled: true', + 'event: bash', + 'pattern: ls', + 'conditions:', + ' - field: command', + ' operator: contains', + ' pattern: npm', + ].join('\n') + ); + writeRule( + claudeDir, + 'hookify.operator.local.md', + [ + 'name: operator', + 'enabled: true', + 'event: file', + 'conditions:', + ' - field: file_path', + ' operator: execute', + ' pattern: .env', + ].join('\n') + ); + writeRule( + claudeDir, + 'hookify.field.local.md', + [ + 'name: field', + 'enabled: true', + 'event: prompt', + 'conditions:', + ' - field: command', + ' operator: contains', + ' pattern: deploy', + ].join('\n') + ); + + const result = loadRules({ projectRoot, event: null }); + + assert.deepStrictEqual(result.rules, []); + assert.strictEqual(result.diagnostics.length, 4); + assert.ok(result.diagnostics.every(item => item.code === 'HOOKIFY_RULE_INVALID')); + assert.ok(result.diagnostics.every(item => !item.message.includes(projectRoot))); + }); + })) passed++; else failed++; + + if (test('rejects traversal names, symlinked files, and a symlinked .claude directory', () => { + withProject(({ projectRoot, claudeDir }) => { + const outside = path.join(projectRoot, 'outside.md'); + fs.writeFileSync(outside, [ + '---', + 'name: outside', + 'enabled: true', + 'event: bash', + 'pattern: pwd', + '---', + 'Outside.', + ].join('\n')); + fs.symlinkSync(outside, path.join(claudeDir, 'hookify.link.local.md')); + + const direct = loadRuleFile({ + claudeDir, + fileName: '../outside.md', + remainingTotalBytes: LIMITS.maxTotalBytes, + }); + assert.strictEqual(direct.rule, null); + assert.strictEqual(direct.diagnostic.code, 'HOOKIFY_RULE_FILE_UNSAFE'); + + const linkedFile = loadRules({ projectRoot, event: 'bash' }); + assert.deepStrictEqual(linkedFile.rules, []); + assert.ok(linkedFile.diagnostics.some(item => item.code === 'HOOKIFY_RULE_FILE_UNSAFE')); + + fs.rmSync(claudeDir, { recursive: true, force: true }); + fs.mkdirSync(path.join(projectRoot, 'elsewhere')); + fs.symlinkSync(path.join(projectRoot, 'elsewhere'), claudeDir); + const linkedDirectory = loadRules({ projectRoot, event: 'bash' }); + assert.deepStrictEqual(linkedDirectory.rules, []); + assert.strictEqual(linkedDirectory.diagnostics[0].code, 'HOOKIFY_RULE_DIRECTORY_UNSAFE'); + }); + })) passed++; else failed++; + + if (test('rejects a rule path swapped through a symlink while the file is opened', () => { + withProject(({ projectRoot, claudeDir }) => { + const fileName = 'hookify.race.local.md'; + const rulePath = path.join(claudeDir, fileName); + const backupPath = path.join(claudeDir, 'safe-rule.backup'); + const outsidePath = path.join(projectRoot, 'outside-rule.md'); + writeRule( + claudeDir, + fileName, + 'name: safe-rule\nenabled: true\nevent: bash\npattern: safe' + ); + fs.writeFileSync(outsidePath, [ + '---', + 'name: raced-rule', + 'enabled: true', + 'event: bash', + 'pattern: raced', + '---', + 'This outside rule must never be loaded.', + ].join('\n')); + + const originalOpenSync = fs.openSync; + let swapped = false; + fs.openSync = function openWithSwap(target, flags, ...args) { + if (!swapped && target === rulePath) { + swapped = true; + fs.renameSync(rulePath, backupPath); + fs.symlinkSync(outsidePath, rulePath); + const descriptor = originalOpenSync.call(fs, target, fs.constants.O_RDONLY, ...args); + fs.unlinkSync(rulePath); + fs.renameSync(backupPath, rulePath); + return descriptor; + } + return originalOpenSync.call(fs, target, flags, ...args); + }; + + try { + const result = loadRuleFile({ + claudeDir, + fileName, + remainingTotalBytes: LIMITS.maxTotalBytes, + expectedRealDirectory: fs.realpathSync(claudeDir), + }); + assert.strictEqual(result.rule, null); + assert.strictEqual(result.diagnostic.code, 'HOOKIFY_RULE_FILE_UNSAFE'); + } finally { + fs.openSync = originalOpenSync; + if (fs.existsSync(backupPath) && !fs.existsSync(rulePath)) { + fs.renameSync(backupPath, rulePath); + } + } + }); + })) passed++; else failed++; + + if (test('caps rule count, individual bytes, total bytes, and pattern length', () => { + withProject(({ projectRoot, claudeDir }) => { + for (let index = 0; index < LIMITS.maxRuleFiles + 2; index += 1) { + writeRule( + claudeDir, + `hookify.rule-${String(index).padStart(3, '0')}.local.md`, + [ + `name: rule-${index}`, + 'enabled: true', + 'event: bash', + 'pattern: safe', + ].join('\n') + ); + } + writeRule( + claudeDir, + 'hookify.pattern-too-long.local.md', + [ + 'name: pattern-too-long', + 'enabled: true', + 'event: bash', + `pattern: ${'x'.repeat(LIMITS.maxPatternLength + 1)}`, + ].join('\n') + ); + fs.writeFileSync( + path.join(claudeDir, 'hookify.file-too-large.local.md'), + Buffer.alloc(LIMITS.maxFileBytes + 1, 0x61) + ); + + const result = loadRules({ projectRoot, event: 'bash' }); + + assert.ok(result.rules.length <= LIMITS.maxRuleFiles); + assert.ok(result.totalBytes <= LIMITS.maxTotalBytes); + assert.ok(result.diagnostics.some(item => item.code === 'HOOKIFY_RULE_LIMIT')); + }); + })) passed++; else failed++; + + if (test('rejects malformed frontmatter and invalid UTF-8 without throwing', () => { + withProject(({ projectRoot, claudeDir }) => { + fs.writeFileSync( + path.join(claudeDir, 'hookify.frontmatter.local.md'), + '---\nname: broken\nenabled: true\n' + ); + fs.writeFileSync( + path.join(claudeDir, 'hookify.encoding.local.md'), + Buffer.from([0xff, 0xfe, 0xfd]) + ); + + const result = loadRules({ projectRoot, event: 'bash' }); + + assert.deepStrictEqual(result.rules, []); + assert.strictEqual(result.diagnostics.length, 2); + assert.ok(result.diagnostics.every(item => item.code === 'HOOKIFY_RULE_INVALID')); + }); + })) passed++; else failed++; + + if (test('counts malformed file reads against the hard total byte budget', () => { + withProject(({ projectRoot, claudeDir }) => { + for (let index = 0; index < 12; index += 1) { + writeRule( + claudeDir, + `hookify.invalid-${String(index).padStart(2, '0')}.local.md`, + [ + `name: invalid-${index}`, + 'enabled: maybe', + 'event: bash', + 'pattern: anything', + ].join('\n'), + 'x'.repeat(60 * 1024) + ); + } + + const result = loadRules({ projectRoot, event: 'bash' }); + + assert.deepStrictEqual(result.rules, []); + assert.ok(result.totalBytes <= LIMITS.maxTotalBytes); + assert.ok(result.diagnostics.some(item => + item.code === 'HOOKIFY_RULE_LIMIT' && + item.message.includes('total rule byte limit') + )); + }); + })) passed++; else failed++; + + if (test('strict frontmatter parser handles supported quoting and rejects YAML expansion', () => { + const parsed = parseFrontmatter([ + '# a full-line comment is allowed', + "name: 'quoted-rule'", + 'enabled: true', + 'event: file', + 'action: warn', + 'conditions:', + ' - field: file_path', + ' operator: ends_with', + " pattern: '.env'", + ].join('\n')); + assert.strictEqual(parsed.name, 'quoted-rule'); + assert.strictEqual(parsed.enabled, true); + assert.deepStrictEqual(parsed.conditions[0], { + field: 'file_path', + operator: 'ends_with', + pattern: '.env', + }); + + for (const source of [ + 'name: "unterminated', + "name: 'unterminated", + 'name: first\nname: second', + 'conditions: inline', + 'conditions:\n - unknown: value', + 'conditions:\n field: command', + 'name: &anchor value', + ]) { + assert.throws(() => parseFrontmatter(source)); + } + assert.throws(() => extractDocument('no frontmatter')); + assert.throws(() => extractDocument('---\nname: no-close')); + })) passed++; else failed++; + + if (test('strict schema defaults warn and rejects invalid names, matchers, messages, and condition counts', () => { + const base = { + name: 'valid-rule', + enabled: true, + event: 'bash', + pattern: 'safe', + }; + const valid = validateRule(base, 'Message.', 'hookify.valid-rule.local.md'); + assert.strictEqual(valid.action, 'warn'); + assert.strictEqual(valid.toolMatcher, null); + + for (const frontmatter of [ + { ...base, name: 'Not Kebab' }, + { ...base, enabled: 'true' }, + { ...base, event: 'unknown' }, + { ...base, action: 'execute' }, + { ...base, tool_matcher: 'Bash |Write' }, + { ...base, tool_matcher: '!' }, + { ...base, pattern: undefined }, + { name: 'conditionless', enabled: true, event: 'bash', conditions: [] }, + { + name: 'too-many', + enabled: true, + event: 'bash', + conditions: Array.from( + { length: LIMITS.maxConditionCount + 1 }, + () => ({ field: 'command', operator: 'contains', pattern: 'x' }) + ), + }, + ]) { + assert.throws(() => + validateRule(frontmatter, 'Message.', 'hookify.invalid.local.md') + ); + } + assert.throws(() => validateRule(base, '\u0000', 'hookify.invalid.local.md')); + assert.throws(() => + validateRule(base, 'x'.repeat(LIMITS.maxMessageBytes + 1), 'hookify.invalid.local.md') + ); + })) passed++; else failed++; + + if (test('missing rule directories and non-rule files are harmless', () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-hookify-empty-')); + try { + assert.deepStrictEqual(loadRules({ projectRoot, event: 'bash' }), { + rules: [], + diagnostics: [], + totalBytes: 0, + }); + fs.mkdirSync(path.join(projectRoot, '.claude')); + fs.writeFileSync(path.join(projectRoot, '.claude', 'settings.json'), '{}'); + assert.deepStrictEqual(loadRules({ projectRoot, event: 'bash' }).rules, []); + fs.rmSync(path.join(projectRoot, '.claude'), { recursive: true, force: true }); + fs.writeFileSync(path.join(projectRoot, '.claude'), 'not a directory'); + assert.strictEqual( + loadRules({ projectRoot, event: 'bash' }).diagnostics[0].code, + 'HOOKIFY_RULE_DIRECTORY_UNSAFE' + ); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + })) passed++; else failed++; + + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/hooks/hookify-runner.test.js b/tests/hooks/hookify-runner.test.js new file mode 100644 index 000000000..4045b091a --- /dev/null +++ b/tests/hooks/hookify-runner.test.js @@ -0,0 +1,605 @@ +/** + * Integration tests for the Hookify hook runner and Claude output contracts. + */ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const { + LIMITS, + boundOutput, + buildOutput, + run, + validateInput, +} = require('../../scripts/hooks/hookify-runner'); + +const RUNNER_PATH = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'hookify-runner.js'); +const REPO_ROOT = path.join(__dirname, '..', '..'); +const HOOKS_PATH = path.join(REPO_ROOT, 'hooks', 'hooks.json'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function withProject(fn) { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-hookify-runner-')); + const claudeDir = path.join(projectRoot, '.claude'); + fs.mkdirSync(claudeDir); + try { + return fn({ projectRoot, claudeDir }); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } +} + +function writeRule(claudeDir, { + name = 'test-rule', + enabled = true, + event = 'bash', + action = 'warn', + pattern = 'danger', + message = 'Correct this behavior.', +}) { + fs.writeFileSync( + path.join(claudeDir, `hookify.${name}.local.md`), + [ + '---', + `name: ${name}`, + `enabled: ${enabled}`, + `event: ${event}`, + `action: ${action}`, + `pattern: ${pattern}`, + '---', + message, + '', + ].join('\n') + ); +} + +function invoke(projectRoot, eventName, payload, context = {}) { + const result = run(JSON.stringify(payload), { + projectRoot, + expectedEvent: eventName, + ...context, + }); + assert.strictEqual(result.exitCode, 0); + assert.strictEqual(result.stderr, ''); + return JSON.parse(result.stdout); +} + +function runConfiguredCommand(entry, projectRoot, payload, env = {}) { + return spawnSync(entry.hooks[0].command, { + shell: true, + cwd: projectRoot, + input: JSON.stringify(payload), + encoding: 'utf8', + env: { + ...process.env, + CLAUDE_PLUGIN_ROOT: REPO_ROOT, + ECC_PLUGIN_ROOT: REPO_ROOT, + ...env, + }, + timeout: 10000, + }); +} + +function runTests() { + console.log('\n=== Hookify runner tests ===\n'); + + let passed = 0; + let failed = 0; + + if (test('PreToolUse warnings reach Claude through additionalContext', () => { + withProject(({ projectRoot, claudeDir }) => { + writeRule(claudeDir, { message: 'Use a safer command.' }); + const output = invoke(projectRoot, 'PreToolUse', { + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'danger --now' }, + }); + + assert.deepStrictEqual(Object.keys(output), ['hookSpecificOutput']); + assert.strictEqual(output.hookSpecificOutput.hookEventName, 'PreToolUse'); + assert.ok(output.hookSpecificOutput.additionalContext.includes('[test-rule]')); + assert.ok(output.hookSpecificOutput.additionalContext.includes('Use a safer command.')); + assert.ok(!('updatedInput' in output.hookSpecificOutput), 'Hookify must not mutate tool input'); + }); + })) passed++; else failed++; + + if (test('PreToolUse blocks use permissionDecision deny without changing input', () => { + withProject(({ projectRoot, claudeDir }) => { + writeRule(claudeDir, { action: 'block', message: 'This command is prohibited.' }); + const payload = { + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'danger' }, + }; + const snapshot = JSON.stringify(payload); + const output = invoke(projectRoot, 'PreToolUse', payload); + + assert.deepStrictEqual(output, { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: '**[test-rule]**\nThis command is prohibited.', + }, + }); + assert.strictEqual(JSON.stringify(payload), snapshot); + }); + })) passed++; else failed++; + + if (test('PostToolUse block feedback truthfully says the completed tool is not undone', () => { + withProject(({ projectRoot, claudeDir }) => { + writeRule(claudeDir, { action: 'block', message: 'Repair the result.' }); + const output = invoke(projectRoot, 'PostToolUse', { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'danger' }, + tool_response: { ok: true }, + }); + + assert.strictEqual(output.decision, 'block'); + assert.ok(output.reason.includes('already completed')); + assert.ok(output.reason.includes('cannot undo')); + assert.ok(output.reason.includes('Repair the result.')); + }); + })) passed++; else failed++; + + if (test('UserPromptSubmit blocks with the documented top-level decision shape', () => { + withProject(({ projectRoot, claudeDir }) => { + writeRule(claudeDir, { + event: 'prompt', + action: 'block', + pattern: 'production', + message: 'Clarify the deployment target.', + }); + const output = invoke(projectRoot, 'UserPromptSubmit', { + hook_event_name: 'UserPromptSubmit', + prompt: 'deploy production', + }); + + assert.deepStrictEqual(output, { + decision: 'block', + reason: '**[test-rule]**\nClarify the deployment target.', + }); + }); + })) passed++; else failed++; + + if (test('Stop blocks continue Claude but Stop warnings are only non-blocking systemMessage output', () => { + withProject(({ projectRoot, claudeDir }) => { + writeRule(claudeDir, { + name: 'block-stop', + event: 'stop', + action: 'block', + pattern: 'unfinished', + message: 'Finish verification before stopping.', + }); + writeRule(claudeDir, { + name: 'warn-stop', + event: 'stop', + action: 'warn', + pattern: 'unfinished', + message: 'A non-blocking Stop warning.', + }); + const output = invoke(projectRoot, 'Stop', { + hook_event_name: 'Stop', + stop_hook_active: false, + last_assistant_message: 'Work is unfinished.', + transcript_path: '/must/not/be/read.jsonl', + }); + + assert.strictEqual(output.decision, 'block'); + assert.ok(output.reason.includes('Finish verification before stopping.')); + assert.ok(output.systemMessage.includes('A non-blocking Stop warning.')); + + const recursiveStop = invoke(projectRoot, 'Stop', { + hook_event_name: 'Stop', + stop_hook_active: true, + last_assistant_message: 'Work is unfinished.', + }); + assert.deepStrictEqual( + recursiveStop, + {}, + 'an active Stop hook must not re-block and create an infinite continuation loop' + ); + + fs.unlinkSync(path.join(claudeDir, 'hookify.block-stop.local.md')); + const warningOnly = invoke(projectRoot, 'Stop', { + hook_event_name: 'Stop', + stop_hook_active: false, + last_assistant_message: 'Work is unfinished.', + }); + assert.deepStrictEqual(Object.keys(warningOnly), ['systemMessage']); + assert.ok(!('decision' in warningOnly)); + assert.ok(!('hookSpecificOutput' in warningOnly)); + }); + })) passed++; else failed++; + + if (test('never reads transcript_path while evaluating Stop rules', () => { + withProject(({ projectRoot, claudeDir }) => { + const transcriptPath = path.join(projectRoot, 'transcript.jsonl'); + fs.writeFileSync(transcriptPath, 'TRANSCRIPT_SENTINEL'); + writeRule(claudeDir, { + event: 'stop', + action: 'block', + pattern: 'TRANSCRIPT_SENTINEL', + }); + const output = invoke(projectRoot, 'Stop', { + hook_event_name: 'Stop', + last_assistant_message: 'Safe final response.', + transcript_path: transcriptPath, + }); + + assert.deepStrictEqual(output, {}); + }); + })) passed++; else failed++; + + if (test('malformed rules fail open and diagnostics reach Claude without leaking paths', () => { + withProject(({ projectRoot, claudeDir }) => { + fs.writeFileSync( + path.join(claudeDir, 'hookify.broken.local.md'), + '---\nname: broken\nenabled: maybe\nevent: bash\npattern: danger\n---\nBroken.\n' + ); + const output = invoke(projectRoot, 'PreToolUse', { + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'danger' }, + }); + + assert.ok(output.hookSpecificOutput.additionalContext.includes('Hookify diagnostic')); + assert.ok(output.hookSpecificOutput.additionalContext.includes('hookify.broken.local.md')); + assert.ok(!output.hookSpecificOutput.additionalContext.includes(projectRoot)); + assert.ok(!('permissionDecision' in output.hookSpecificOutput)); + }); + })) passed++; else failed++; + + if (test('rejects mismatched, malformed, and oversized hook inputs with bounded fail-open JSON', () => { + withProject(({ projectRoot }) => { + for (const [raw, context] of [ + ['{bad json', { expectedEvent: 'PreToolUse' }], + [JSON.stringify({ hook_event_name: 'Stop' }), { expectedEvent: 'PreToolUse' }], + [JSON.stringify({ hook_event_name: 'PreToolUse' }), { expectedEvent: 'PreToolUse', truncated: true }], + ]) { + const result = run(raw, { projectRoot, ...context }); + assert.strictEqual(result.exitCode, 0); + assert.strictEqual(result.stderr, ''); + assert.ok(Buffer.byteLength(result.stdout) <= LIMITS.maxOutputBytes); + const output = JSON.parse(result.stdout); + assert.ok(output.hookSpecificOutput.additionalContext.includes('Hookify diagnostic')); + assert.ok(!('permissionDecision' in output.hookSpecificOutput)); + } + }); + })) passed++; else failed++; + + if (test('caps combined messages and CLI stdin while always emitting valid structured JSON', () => { + withProject(({ projectRoot, claudeDir }) => { + for (let index = 0; index < 12; index += 1) { + writeRule(claudeDir, { + name: `warning-${index}`, + message: `Message ${index}: ${'界'.repeat(1400)}`, + }); + } + const output = run(JSON.stringify({ + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'danger' }, + }), { + projectRoot, + expectedEvent: 'PreToolUse', + }); + assert.ok(Buffer.byteLength(output.stdout) <= LIMITS.maxOutputBytes); + JSON.parse(output.stdout); + + const oversized = JSON.stringify({ + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'x'.repeat(LIMITS.maxInputBytes + 1024) }, + }); + const cli = spawnSync(process.execPath, [RUNNER_PATH, 'PreToolUse'], { + cwd: projectRoot, + input: oversized, + encoding: 'utf8', + timeout: 5000, + }); + assert.strictEqual(cli.status, 0, cli.stderr); + assert.strictEqual(cli.stderr, ''); + assert.ok(Buffer.byteLength(cli.stdout) <= LIMITS.maxOutputBytes); + const cliOutput = JSON.parse(cli.stdout); + assert.ok(cliOutput.hookSpecificOutput.additionalContext.includes('input exceeded')); + }); + })) passed++; else failed++; + + if (test('hooks.json registers bounded Hookify entrypoints for all four events', () => { + const hooks = JSON.parse(fs.readFileSync(HOOKS_PATH, 'utf8')).hooks; + const pre = hooks.PreToolUse.find(entry => entry.id === 'pre:hookify'); + const stop = hooks.Stop.find(entry => entry.id === 'stop:hookify'); + const prompt = hooks.UserPromptSubmit.find(entry => entry.id === 'prompt:hookify'); + + for (const [eventName, entry] of [ + ['PreToolUse', pre], + ['Stop', stop], + ['UserPromptSubmit', prompt], + ]) { + assert.ok(entry, `${eventName} should register Hookify`); + assert.ok(entry.hooks[0].command.includes('hookify-runner.js')); + assert.ok(entry.hooks[0].command.includes(eventName)); + assert.ok(!entry.hooks[0].command.includes('plugin-hook-bootstrap.js')); + assert.ok(!entry.hooks[0].command.includes('run-with-flags.js')); + assert.ok(!entry.hooks[0].command.includes("readFileSync(0")); + assert.ok(!entry.hooks[0].command.includes('spawnSync')); + assert.ok(entry.hooks[0].timeout > 0); + } + + const dispatcherSource = fs.readFileSync( + path.join(REPO_ROOT, 'scripts', 'hooks', 'posttooluse-dispatcher.js'), + 'utf8' + ); + assert.ok(dispatcherSource.includes("id: 'post:hookify'")); + assert.ok(dispatcherSource.includes("expectedEvent: 'PostToolUse'")); + assert.deepStrictEqual( + hooks.PostToolUse.map(entry => entry.id), + ['post:dispatcher:sync', 'post:dispatcher:async'], + 'PostToolUse should retain its two-process dispatcher contract' + ); + })) passed++; else failed++; + + if (test('configured hook commands enforce project rules end to end and ignore payload cwd', () => { + withProject(({ projectRoot, claudeDir }) => { + writeRule(claudeDir, { + event: 'all', + pattern: 'danger', + message: 'Configured Hookify warning.', + }); + const hooks = JSON.parse(fs.readFileSync(HOOKS_PATH, 'utf8')).hooks; + const cases = [ + { + eventName: 'PreToolUse', + entry: hooks.PreToolUse.find(entry => entry.id === 'pre:hookify'), + payload: { + hook_event_name: 'PreToolUse', + cwd: path.join(projectRoot, 'untrusted-cwd'), + tool_name: 'Bash', + tool_input: { command: 'danger' }, + }, + field: 'hookSpecificOutput', + }, + { + eventName: 'UserPromptSubmit', + entry: hooks.UserPromptSubmit.find(entry => entry.id === 'prompt:hookify'), + payload: { + hook_event_name: 'UserPromptSubmit', + cwd: path.join(projectRoot, 'untrusted-cwd'), + prompt: 'danger', + }, + field: 'hookSpecificOutput', + }, + { + eventName: 'Stop', + entry: hooks.Stop.find(entry => entry.id === 'stop:hookify'), + payload: { + hook_event_name: 'Stop', + cwd: path.join(projectRoot, 'untrusted-cwd'), + last_assistant_message: 'danger', + }, + field: 'systemMessage', + }, + { + eventName: 'PostToolUse', + entry: hooks.PostToolUse.find(entry => entry.id === 'post:dispatcher:sync'), + payload: { + hook_event_name: 'PostToolUse', + cwd: path.join(projectRoot, 'untrusted-cwd'), + tool_name: 'Bash', + tool_input: { command: 'danger' }, + tool_response: {}, + }, + field: 'hookSpecificOutput', + env: { + ECC_HOOK_PROFILE: 'minimal', + ECC_DISABLED_HOOKS: 'post:ecc-metrics-bridge', + }, + }, + ]; + + for (const item of cases) { + const result = runConfiguredCommand( + item.entry, + projectRoot, + item.payload, + item.env + ); + assert.strictEqual(result.status, 0, `${item.eventName}: ${result.stderr}`); + const output = JSON.parse(result.stdout); + const text = item.field === 'systemMessage' + ? output.systemMessage + : output.hookSpecificOutput?.additionalContext; + assert.ok( + text.includes('Configured Hookify warning.'), + `${item.eventName} should return the configured warning` + ); + } + }); + })) passed++; else failed++; + + if (test('configured Hookify entrypoints return bounded fail-open output for oversized input', () => { + withProject(({ projectRoot }) => { + const hooks = JSON.parse(fs.readFileSync(HOOKS_PATH, 'utf8')).hooks; + const cases = [ + { + eventName: 'PreToolUse', + entry: hooks.PreToolUse.find(entry => entry.id === 'pre:hookify'), + payload: { + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'x'.repeat(LIMITS.maxInputBytes + 64 * 1024) }, + }, + message(output) { + return output.hookSpecificOutput?.additionalContext; + }, + }, + { + eventName: 'UserPromptSubmit', + entry: hooks.UserPromptSubmit.find(entry => entry.id === 'prompt:hookify'), + payload: { + hook_event_name: 'UserPromptSubmit', + prompt: 'x'.repeat(LIMITS.maxInputBytes + 64 * 1024), + }, + message(output) { + return output.hookSpecificOutput?.additionalContext; + }, + }, + { + eventName: 'Stop', + entry: hooks.Stop.find(entry => entry.id === 'stop:hookify'), + payload: { + hook_event_name: 'Stop', + last_assistant_message: 'x'.repeat(LIMITS.maxInputBytes + 64 * 1024), + }, + message(output) { + return output.systemMessage; + }, + }, + ]; + + for (const item of cases) { + const serializedInput = JSON.stringify(item.payload); + const result = runConfiguredCommand(item.entry, projectRoot, item.payload); + assert.strictEqual(result.status, 0, `${item.eventName}: ${result.stderr}`); + assert.ok( + Buffer.byteLength(result.stdout) <= LIMITS.maxOutputBytes, + `${item.eventName} output must stay within the Hookify limit` + ); + assert.ok( + Buffer.byteLength(result.stdout) < Buffer.byteLength(serializedInput), + `${item.eventName} must not echo the oversized input` + ); + const output = JSON.parse(result.stdout); + assert.ok( + item.message(output)?.includes('input exceeded'), + `${item.eventName} should return an event-correct fail-open diagnostic` + ); + } + }); + })) passed++; else failed++; + + if (test('strict hook input validation rejects malformed event-specific fields', () => { + const cases = [ + [null, 'PreToolUse'], + [[], 'PreToolUse'], + [{ hook_event_name: 'Stop' }, 'PreToolUse'], + [{ hook_event_name: 'PreToolUse', tool_name: '\u0000', tool_input: {} }, 'PreToolUse'], + [{ hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: [] }, 'PreToolUse'], + [{ hook_event_name: 'UserPromptSubmit', prompt: 42 }, 'UserPromptSubmit'], + [{ hook_event_name: 'Stop', last_assistant_message: 42 }, 'Stop'], + [{ hook_event_name: 'Stop', stop_hook_active: 'false' }, 'Stop'], + ]; + for (const [payload, eventName] of cases) { + assert.strictEqual(typeof validateInput(payload, eventName), 'string'); + } + assert.strictEqual(validateInput({ + hook_event_name: 'Stop', + last_assistant_message: 'done', + stop_hook_active: true, + }, 'Stop'), null); + })) passed++; else failed++; + + if (test('block plus warning output preserves both event decisions and bounded context', () => { + const matches = [ + { + name: 'block-rule', + action: 'block', + message: 'Block.', + }, + { + name: 'warn-rule', + action: 'warn', + message: 'Warn.', + }, + ]; + const prompt = buildOutput('UserPromptSubmit', matches, []); + assert.strictEqual(prompt.decision, 'block'); + assert.ok(prompt.hookSpecificOutput.additionalContext.includes('Warn.')); + + const stop = buildOutput('Stop', matches, []); + assert.strictEqual(stop.decision, 'block'); + assert.ok(stop.systemMessage.includes('Warn.')); + + for (const output of [ + { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: '\\'.repeat(20000), + additionalContext: '\\'.repeat(20000), + }, + }, + { + decision: 'block', + reason: '\\'.repeat(20000), + systemMessage: '\\'.repeat(20000), + }, + ]) { + const serialized = boundOutput(output); + assert.ok(Buffer.byteLength(serialized) <= LIMITS.maxOutputBytes); + JSON.parse(serialized); + } + })) passed++; else failed++; + + if (test('hook IDs select their registered event and non-string input fails open', () => { + withProject(({ projectRoot }) => { + const cases = [ + ['pre:hookify', 'PreToolUse', { + hook_event_name: 'PreToolUse', + tool_name: 'Read', + tool_input: {}, + }], + ['post:hookify', 'PostToolUse', { + hook_event_name: 'PostToolUse', + tool_name: 'Read', + tool_input: {}, + }], + ['stop:hookify', 'Stop', { + hook_event_name: 'Stop', + last_assistant_message: '', + }], + ['prompt:hookify', 'UserPromptSubmit', { + hook_event_name: 'UserPromptSubmit', + prompt: '', + }], + ]; + for (const [hookId, _eventName, payload] of cases) { + const output = run(JSON.stringify(payload), { projectRoot, hookId }); + assert.strictEqual(output.exitCode, 0); + JSON.parse(output.stdout); + } + const invalid = run(Buffer.from('not accepted'), { + projectRoot, + expectedEvent: 'PreToolUse', + }); + assert.ok( + JSON.parse(invalid.stdout).hookSpecificOutput.additionalContext.includes( + 'not valid JSON' + ) + ); + }); + })) passed++; else failed++; + + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 82724226a..80c924673 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -2621,16 +2621,25 @@ async function runTests() { for (const hook of [...stopHooks, ...sessionEndHooks]) { const commandText = Array.isArray(hook.command) ? hook.command.join(' ') : hook.command; + const usesBoundedHookifyRunner = commandText.includes('hookify-runner.js'); assert.ok( (Array.isArray(hook.command) && hook.command[0] === 'node' && hook.command[1] === '-e') || (typeof hook.command === 'string' && hook.command.startsWith('node -e "')), 'Lifecycle hook should use inline node resolver' ); - assert.ok(commandText.includes('run-with-flags.js'), 'Lifecycle hook should resolve the runner script'); + assert.ok( + commandText.includes('run-with-flags.js') || usesBoundedHookifyRunner, + 'Lifecycle hook should resolve its bounded runner script' + ); assert.ok(commandText.includes('CLAUDE_PLUGIN_ROOT'), 'Lifecycle hook should consult CLAUDE_PLUGIN_ROOT'); assert.ok(!commandText.includes('${CLAUDE_PLUGIN_ROOT}'), 'Lifecycle hook should not depend on raw shell placeholder expansion'); assert.ok(commandText.includes('resolve-ecc-root'), 'Lifecycle hook should delegate to the committed resolver module'); assert.ok(!commandText.includes('find '), 'Lifecycle hook should not scan arbitrary plugin paths with find'); assert.ok(!commandText.includes('head -n 1'), 'Lifecycle hook should not pick the first matching plugin path'); + if (usesBoundedHookifyRunner) { + assert.ok(commandText.includes('.cli()'), 'Hookify should invoke its bounded CLI directly'); + assert.ok(!commandText.includes('readFileSync(0'), 'Hookify resolver must not buffer stdin'); + assert.ok(!commandText.includes('run-with-flags.js'), 'Hookify must not use the legacy stdin reader'); + } } }) ) @@ -2650,8 +2659,9 @@ async function runTests() { const usesInlineResolver = commandStart.startsWith('node -e') && commandText.includes('run-with-flags.js'); const usesPluginBootstrap = commandStart.startsWith('node -e') && commandText.includes('plugin-hook-bootstrap.js'); const usesDirectPostDispatcher = commandStart.startsWith('node -e') && commandText.includes('posttooluse-dispatcher.js') && commandText.includes('resolve-ecc-root'); + const usesDirectHookify = commandStart.startsWith('node -e') && commandText.includes('hookify-runner.js') && commandText.includes('resolve-ecc-root') && commandText.includes('.cli()'); assert.ok(!commandText.includes('${CLAUDE_PLUGIN_ROOT}'), `Script paths should not depend on raw shell placeholder expansion: ${commandText.substring(0, 80)}...`); - assert.ok(usesInlineResolver || usesPluginBootstrap || usesDirectPostDispatcher, `Script paths should use the inline resolver or plugin bootstrap: ${commandText.substring(0, 80)}...`); + assert.ok(usesInlineResolver || usesPluginBootstrap || usesDirectPostDispatcher || usesDirectHookify, `Script paths should use a safe inline resolver or plugin bootstrap: ${commandText.substring(0, 80)}...`); } } } diff --git a/tests/hooks/posttooluse-dispatcher.test.js b/tests/hooks/posttooluse-dispatcher.test.js index afa5db29b..c4ca4b510 100644 --- a/tests/hooks/posttooluse-dispatcher.test.js +++ b/tests/hooks/posttooluse-dispatcher.test.js @@ -112,23 +112,24 @@ function runTests() { 'post:governance-capture', 'post:session-activity-tracker', 'post:ecc-metrics-bridge', - 'post:ecc-context-monitor' + 'post:ecc-context-monitor', + 'post:hookify' ], async: ['post:quality-gate', 'post:observe:continuous-learning'] }, { tool: 'Write', - sync: ['post:edit:design-quality-check', 'post:edit:accumulator', 'post:governance-capture', 'post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor'], + sync: ['post:edit:design-quality-check', 'post:edit:accumulator', 'post:governance-capture', 'post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor', 'post:hookify'], async: ['post:quality-gate', 'post:observe:continuous-learning'] }, { tool: 'Bash', - sync: ['post:governance-capture', 'post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor'], + sync: ['post:governance-capture', 'post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor', 'post:hookify'], async: ['post:bash:dispatcher', 'post:observe:continuous-learning'] }, { tool: 'Read', - sync: ['post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor'], + sync: ['post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor', 'post:hookify'], async: ['post:observe:continuous-learning'] } ]; @@ -172,6 +173,7 @@ function runTests() { 'post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor', + 'post:hookify', 'post:quality-gate', 'post:observe:continuous-learning' ]); @@ -213,7 +215,7 @@ function runTests() { ECC_HOOK_PROFILE: 'minimal' }); assert.strictEqual(minimalSync.status, 0, minimalSync.stderr); - assert.deepStrictEqual(previewedIds(minimalSync.stderr), ['post:ecc-metrics-bridge']); + assert.deepStrictEqual(previewedIds(minimalSync.stderr), ['post:ecc-metrics-bridge', 'post:hookify']); const minimalAsync = runDispatcher('async', 'Bash', { ECC_DRY_RUN: '1', @@ -398,7 +400,7 @@ function runTests() { else failed++; if ( - test('multiple additionalContext outputs merge; raw stdout conflicts warn', () => { + test('multiple additionalContext outputs merge with structured block decisions; raw stdout conflicts warn', () => { const { mergeHookStdout, runHooks } = require(dispatcherPath); const envelope = context => JSON.stringify({ @@ -418,6 +420,30 @@ function runTests() { assert.strictEqual(merged.stdout, envelope('first warning\nsecond warning'), 'context envelopes should merge into one'); assert.ok(!merged.stderr.includes('dropped'), merged.stderr); + const blocked = mergeHookStdout([ + { id: 'post:test:ctx', stdout: envelope('corrective context') }, + { + id: 'post:hookify', + stdout: JSON.stringify({ + decision: 'block', + reason: 'The completed tool needs correction.', + hookSpecificOutput: { + hookEventName: 'PostToolUse', + additionalContext: 'warning context', + }, + }), + }, + ]); + assert.deepStrictEqual(JSON.parse(blocked.stdout), { + decision: 'block', + reason: 'The completed tool needs correction.', + hookSpecificOutput: { + hookEventName: 'PostToolUse', + additionalContext: 'corrective context\nwarning context', + }, + }); + assert.strictEqual(blocked.warning, ''); + const conflicting = mergeHookStdout([ { id: 'post:test:raw', stdout: 'plain output' }, { id: 'post:test:ctx', stdout: envelope('kept warning') } diff --git a/tests/hooks/stop-hooks-stdout.test.js b/tests/hooks/stop-hooks-stdout.test.js index 04ef84acd..3ea8ce751 100644 --- a/tests/hooks/stop-hooks-stdout.test.js +++ b/tests/hooks/stop-hooks-stdout.test.js @@ -207,14 +207,25 @@ assert.ok(multibytePayload.length < MAX_STDIN, 'fixture must stay below the runn assert.ok(Buffer.byteLength(multibytePayload) > MAX_STDIN, 'fixture must exceed the default byte buffer'); for (const entry of hooksConfig.hooks.Stop) { + const multibyteExpectation = entry.id === 'stop:hookify' + ? 'suppresses a multibyte payload above its byte cap' + : 'preserves a multibyte sub-cap payload'; if ( - test(`${entry.id} registered wrapper preserves a multibyte sub-cap payload`, () => { + test(`${entry.id} registered wrapper ${multibyteExpectation}`, () => { const result = runRegisteredStopHook(entry, multibytePayload); assert.strictEqual( result.status, 0, `${entry.id}: expected exit 0, got ${result.status}: ${result.stderr}` ); + if (entry.id === 'stop:hookify') { + assert.strictEqual( + result.stdout, + '', + 'stop:hookify must suppress disabled pass-through above its 256 KiB byte cap' + ); + return; + } assert.ok( result.stdout === multibytePayload, `${entry.id}: registered wrapper must echo ${Buffer.byteLength(multibytePayload)} bytes uncut (got ${Buffer.byteLength(result.stdout)})` diff --git a/tests/scripts/npm-publish-surface.test.js b/tests/scripts/npm-publish-surface.test.js index bac522796..e6a18cbb6 100644 --- a/tests/scripts/npm-publish-surface.test.js +++ b/tests/scripts/npm-publish-surface.test.js @@ -145,6 +145,10 @@ function main() { "scripts/ci/supply-chain-advisory-sources.js", "scripts/consult.js", "scripts/control-pane.js", + "scripts/hooks/hookify-engine.js", + "scripts/hooks/hookify-loader.js", + "scripts/hooks/hookify-regex-worker.js", + "scripts/hooks/hookify-runner.js", "scripts/ito.js", "scripts/discussion-audit.js", "scripts/operator-readiness-dashboard.js",