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
This commit is contained in:
Affaan Mustafa
2026-07-26 05:07:34 -04:00
parent ad8db87780
commit 504261d069
19 changed files with 3224 additions and 48 deletions
+9 -4
View File
@@ -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.
+92 -9
View File
@@ -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
+11 -5
View File
@@ -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.
+37 -2
View File
@@ -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.
+37 -1
View File
@@ -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<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/hookify-runner.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);try{require(s).cli().catch(()=>{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<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/hookify-runner.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);try{require(s).cli().catch(()=>{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<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/hookify-runner.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);try{require(s).cli().catch(()=>{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": [
+243
View File
@@ -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,
};
+613
View File
@@ -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,
};
+109
View File
@@ -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,
};
+417
View File
@@ -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,
};
+49 -12
View File
@@ -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: ''
};
}
+44 -6
View File
@@ -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)
+89
View File
@@ -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();
+357
View File
@@ -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();
+452
View File
@@ -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();
+605
View File
@@ -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();
+12 -2
View File
@@ -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)}...`);
}
}
}
+32 -6
View File
@@ -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') }
+12 -1
View File
@@ -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)})`
@@ -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",