mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-13 05:07:53 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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": [
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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: ''
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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)}...`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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') }
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user