commit a0d423053726c5a4f7cf24cb34b55ca3907746d4 Author: Agent Date: Tue Oct 13 07:54:44 2026 +0000 feat: initial commit for omp-hooks plugin diff --git a/README.md b/README.md new file mode 100644 index 0000000..cf2f030 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# @local/omp-hooks + +Project-level and session-level lifecycle hooks for DeepSeek Harness (DSH), with full compatibility for Claude Code `.claude/settings.json`, `.claude/hooks.json`, and project-root `hooks.json`. + +## Supported Hook Events + +| Event | Firing Moment | Capabilities | +|---|---|---| +| `SessionStart` | On agent creation / session init | Injects context before the first turn | +| `UserPromptSubmit` | When user prompt / messages are submitted | Can block prompt (exit 2) or attach extra context | +| `PreToolUse` | Before any tool executes | Can block execution (`deny`), ask confirmation (`ask`), or pass | +| `PostToolUse` | After a tool finishes execution | Can block result with feedback, or append context | +| `Stop` | When agent turn is stopping | Can steer agent into continuing with a reason | + +## Configuration Locations (per-project discovery) + +In any project workspace directory, hooks are automatically discovered from: +1. `.claude/settings.local.json` +2. `.claude/settings.json` +3. `.claude/hooks.json` +4. `hooks.json` + +## Example `.claude/settings.json` + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "bash|write", + "hooks": [ + { + "type": "command", + "command": "python3 $CLAUDE_PROJECT_DIR/.claude/hooks/guard.py", + "timeout": 10 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "edit|write", + "hooks": [ + { + "type": "command", + "command": "npx prettier --write $CLAUDE_PROJECT_DIR/src" + } + ] + } + ] + } +} +``` diff --git a/cordis.patch.yml b/cordis.patch.yml new file mode 100644 index 0000000..c6644ff --- /dev/null +++ b/cordis.patch.yml @@ -0,0 +1,5 @@ +- insert: + - id: omp-hooks + name: '@local/omp-hooks' + config: + enabled: true diff --git a/index.js b/index.js new file mode 100644 index 0000000..7546655 --- /dev/null +++ b/index.js @@ -0,0 +1,423 @@ +/** + * @local/omp-hooks — Project-level & session-level lifecycle hooks for DSH. + * Full compatibility with Claude Code .claude/settings.json, .claude/hooks.json, + * and project-level hooks. + * + * Interception seams: + * - SessionStart -> agent/created (injects initial prompt context) + * - UserPromptSubmit -> agent/pre-step (can block prompt or attach context) + * - PreToolUse -> tools/pre-execute (can deny or ask approval before execution) + * - PostToolUse -> tools/post-execute (can block result or attach context) + * - Stop -> agent/turn-stopping (can steer agent to force continuation) + */ +import fs from 'node:fs' +import path from 'node:path' +import { spawn } from 'node:child_process' +import { createUserMessage } from './lib/message.js' + +export const name = 'omp-hooks' +export const inject = ['tools'] + +const CLAUDE_EVENTS = [ + 'SessionStart', + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', + 'Stop', + 'SubagentStart', + 'SubagentStop', +] + +const CONTEXT_SOURCE = { kind: 'omp-hooks' } + +function asObject(val) { + return typeof val === 'object' && val !== null && !Array.isArray(val) ? val : undefined +} + +export function substituteCommand(command, vars = {}) { + let out = command + if (vars.pluginRoot !== undefined) out = out.split('${CLAUDE_PLUGIN_ROOT}').join(vars.pluginRoot) + if (vars.projectDir !== undefined) out = out.split('${CLAUDE_PROJECT_DIR}').join(vars.projectDir) + return out +} + +export function parseClaudeConfig(raw, vars = {}) { + const config = {} + const root = asObject(raw) + const hooksMap = root ? asObject(root.hooks) ?? root : undefined + if (!hooksMap) return config + + for (const event of CLAUDE_EVENTS) { + const rawGroups = hooksMap[event] + if (!Array.isArray(rawGroups)) continue + const groups = [] + for (const rawGroup of rawGroups) { + const group = asObject(rawGroup) + if (!group || !Array.isArray(group.hooks)) continue + const commands = [] + for (const rawHook of group.hooks) { + const hook = asObject(rawHook) + if (!hook) continue + const type = typeof hook.type === 'string' ? hook.type : 'command' + if (type !== 'command') continue + if (typeof hook.command !== 'string') continue + commands.push({ + command: substituteCommand(hook.command, vars), + timeoutSec: typeof hook.timeout === 'number' ? hook.timeout : 600, + }) + } + if (commands.length === 0) continue + const matcher = (event === 'UserPromptSubmit' || event === 'Stop') + ? undefined + : (typeof group.matcher === 'string' ? group.matcher : undefined) + groups.push({ + matcher, + hooks: commands, + }) + } + if (groups.length > 0) config[event] = groups + } + return config +} + +/** + * Discovers hook configuration in order of precedence: + * 1. .claude/settings.local.json + * 2. .claude/settings.json + * 3. .claude/hooks.json + * 4. hooks.json + */ +export function findProjectHookConfig(projectDir) { + if (!projectDir || typeof projectDir !== 'string') return null + const candidates = [ + path.join(projectDir, '.claude', 'settings.local.json'), + path.join(projectDir, '.claude', 'settings.json'), + path.join(projectDir, '.claude', 'hooks.json'), + path.join(projectDir, 'hooks.json'), + ] + + for (const candidate of candidates) { + try { + if (fs.existsSync(candidate)) { + const text = fs.readFileSync(candidate, 'utf8') + const raw = JSON.parse(text) + return parseClaudeConfig(raw, { projectDir }) + } + } catch {} + } + return null +} + +export function matchesPattern(pattern, subject) { + if (!pattern || pattern === '*' || pattern === '') return true + if (pattern === subject) return true + try { + const rx = new RegExp(`^(${pattern})$`) + return rx.test(subject) + } catch { + return false + } +} + +/** + * Execute a hook command with JSON payload on stdin + */ +export async function executeHookCommand(cmdStr, payload, { cwd, timeoutSec = 600, signal }) { + return new Promise((resolve) => { + let resolved = false + const done = (res) => { + if (resolved) return + resolved = true + resolve(res) + } + + const timer = setTimeout(() => { + done({ exitCode: 124, stdout: '', stderr: 'Hook timed out' }) + try { proc.kill('SIGTERM') } catch {} + }, timeoutSec * 1000) + + const env = { + ...process.env, + CLAUDE_PROJECT_DIR: cwd || process.cwd(), + } + + const proc = spawn('bash', ['-c', cmdStr], { + cwd: cwd || process.cwd(), + env, + stdio: ['pipe', 'pipe', 'pipe'], + }) + + if (signal) { + signal.addEventListener('abort', () => { + clearTimeout(timer) + try { proc.kill('SIGKILL') } catch {} + done({ exitCode: 130, stdout: '', stderr: 'Operation cancelled' }) + }, { once: true }) + } + + let stdout = '' + let stderr = '' + proc.stdout.on('data', (d) => { stdout += d.toString('utf8') }) + proc.stderr.on('data', (d) => { stderr += d.toString('utf8') }) + + proc.on('error', (err) => { + clearTimeout(timer) + done({ exitCode: 1, stdout: '', stderr: String(err?.message ?? err) }) + }) + + proc.on('close', (code) => { + clearTimeout(timer) + done({ exitCode: code ?? 0, stdout, stderr }) + }) + + try { + proc.stdin.write(JSON.stringify(payload) + '\n') + proc.stdin.end() + } catch {} + }) +} + +export function parseHookStdout(stdout, exitCode, stderr) { + let decision = 'allow' // allow | deny | ask + let reason = undefined + let additionalContext = [] + + // Check exit code conventions: + // Exit 2 = Deny / Block + // Exit 0 = Allow + if (exitCode === 2) { + decision = 'deny' + reason = stderr.trim() || 'Blocked by lifecycle hook (exit code 2)' + } + + // Parse stdout JSON if available + const trimmed = stdout.trim() + if (trimmed) { + try { + const data = JSON.parse(trimmed) + if (data && typeof data === 'object') { + if (data.decision === 'deny' || data.blocked === true) { + decision = 'deny' + reason = data.reason || reason || 'Blocked by lifecycle hook' + } else if (data.decision === 'ask') { + decision = 'ask' + reason = data.reason + } + if (typeof data.reason === 'string' && decision === 'deny') { + reason = data.reason + } + if (typeof data.context === 'string') { + additionalContext.push(data.context) + } else if (Array.isArray(data.context)) { + additionalContext.push(...data.context.filter((c) => typeof c === 'string')) + } + if (typeof data.additionalContext === 'string') { + additionalContext.push(data.additionalContext) + } else if (Array.isArray(data.additionalContext)) { + additionalContext.push(...data.additionalContext.filter((c) => typeof c === 'string')) + } + } + } catch { + // stdout was non-JSON text; if exitCode is 0 and it's non-empty, could be context + if (exitCode === 0 && trimmed.length > 0 && !trimmed.startsWith('{')) { + additionalContext.push(trimmed) + } + } + } + + return { decision, reason, additionalContext } +} + +export function apply(ctx, config = {}) { + const cfg = { + enabled: true, + ...config, + } + + if (!cfg.enabled) return + + // Cache configs per cwd with 5s expiry + const configCache = new Map() + function getConfigForDir(dir) { + const cwd = dir || process.cwd() + const now = Date.now() + const hit = configCache.get(cwd) + if (hit && now - hit.time < 5000) return hit.config + + const conf = findProjectHookConfig(cwd) + configCache.set(cwd, { time: now, config: conf }) + return conf + } + + function cwdOf(entity) { + return entity?.session?.header?.cwd || entity?.cwd || process.cwd() + } + + async function runHooks(point, matchQuery, payload, cwd, signal) { + const conf = getConfigForDir(cwd) + if (!conf) return { decision: 'allow', additionalContext: [] } + + const groups = conf[point] ?? [] + let finalDecision = 'allow' + let finalReason = undefined + const allContext = [] + + for (const group of groups) { + if (!matchesPattern(group.matcher, matchQuery)) continue + for (const hook of group.hooks) { + const res = await executeHookCommand(hook.command, payload, { + cwd, + timeoutSec: hook.timeoutSec, + signal, + }) + const parsed = parseHookStdout(res.stdout, res.exitCode, res.stderr) + if (parsed.decision === 'deny') { + finalDecision = 'deny' + finalReason = parsed.reason || res.stderr.trim() || 'Blocked by hook' + } else if (parsed.decision === 'ask' && finalDecision !== 'deny') { + finalDecision = 'ask' + finalReason = parsed.reason + } + if (parsed.additionalContext.length > 0) { + allContext.push(...parsed.additionalContext) + } + } + } + + return { decision: finalDecision, reason: finalReason, additionalContext: allContext } + } + + // 1. SessionStart + ctx.on('agent/created', async ({ agent, source, signal }) => { + const cwd = cwdOf(agent) + const payload = { + session_id: agent?.id, + source: source || 'user', + cwd, + } + const { additionalContext } = await runHooks('SessionStart', source || '', payload, cwd, signal) + if (additionalContext.length > 0 && agent?.inject) { + agent.inject(createUserMessage({ + content: additionalContext.map((text) => ({ type: 'text', text })), + source: CONTEXT_SOURCE, + })) + } + }) + + // 2. UserPromptSubmit + ctx.on('agent/pre-step', async ({ agent, messages, signal }, next) => { + if (!messages || messages.length === 0) return next() + const cwd = cwdOf(agent) + const promptText = messages.map((m) => { + if (typeof m.content === 'string') return m.content + if (Array.isArray(m.content)) { + return m.content.map((c) => c.text || '').join('\n') + } + return '' + }).join('\n') + + const payload = { + session_id: agent?.id, + prompt: promptText, + } + + const { decision, reason, additionalContext } = await runHooks('UserPromptSubmit', '', payload, cwd, signal) + if (decision === 'deny') { + return { kind: 'deny', reason: reason || 'Prompt rejected by UserPromptSubmit hook' } + } + + const downstream = await next() + if (additionalContext.length > 0 && downstream?.kind === 'enter') { + const extraMsg = createUserMessage({ + content: additionalContext.map((text) => ({ type: 'text', text })), + source: CONTEXT_SOURCE, + }) + return { + ...downstream, + messages: [...(downstream.messages || []), extraMsg], + } + } + return downstream + }) + + // 3. PreToolUse + ctx.on('tools/pre-execute', async (exec, next) => { + const cwd = exec.agent ? cwdOf(exec.agent) : process.cwd() + const payload = { + session_id: exec.agent?.id, + tool_name: exec.name, + tool_input: exec.arguments ?? {}, + } + + const { decision, reason } = await runHooks('PreToolUse', exec.name, payload, cwd, exec.signal) + if (decision === 'deny') { + return { + kind: 'deny', + reason: reason || `Blocked by PreToolUse hook for ${exec.name}`, + } + } + if (decision === 'ask') { + return { + kind: 'ask', + reason: reason || `Approval requested by PreToolUse hook for ${exec.name}`, + } + } + return next() + }) + + // 4. PostToolUse + ctx.on('tools/post-execute', async (exec, result, next) => { + const cwd = exec.agent ? cwdOf(exec.agent) : process.cwd() + const payload = { + session_id: exec.agent?.id, + tool_name: exec.name, + tool_input: exec.arguments ?? {}, + tool_result: result?.content ?? result ?? null, + is_error: !!result?.isError, + } + + const { decision, reason, additionalContext } = await runHooks('PostToolUse', exec.name, payload, cwd, exec.signal) + if (decision === 'deny') { + return { + kind: 'block', + feedback: [{ type: 'text', text: reason || `Blocked by PostToolUse hook for ${exec.name}` }], + ...(additionalContext.length > 0 ? { + additionalContexts: [createUserMessage({ + content: additionalContext.map((t) => ({ type: 'text', text: t })), + source: CONTEXT_SOURCE, + })], + } : {}), + } + } + + const downstream = await next() + if (additionalContext.length > 0) { + const extra = createUserMessage({ + content: additionalContext.map((t) => ({ type: 'text', text: t })), + source: CONTEXT_SOURCE, + }) + return { + ...downstream, + additionalContexts: [extra, ...(downstream?.additionalContexts || [])], + } + } + return downstream + }) + + // 5. Stop + ctx.on('agent/turn-stopping', async ({ agent, turn, signal }) => { + const cwd = cwdOf(agent) + const payload = { + session_id: agent?.id, + turn, + } + const { decision, reason } = await runHooks('Stop', '', payload, cwd, signal) + if (decision === 'deny') { + const text = reason || 'Continue: blocked by Stop hook' + agent?.steer?.(createUserMessage({ + content: [{ type: 'text', text }], + source: CONTEXT_SOURCE, + })) + } + }) +} diff --git a/lib/message.js b/lib/message.js new file mode 100644 index 0000000..695c36c --- /dev/null +++ b/lib/message.js @@ -0,0 +1,10 @@ +import crypto from 'node:crypto' + +export function createUserMessage(input) { + return { + id: crypto.randomUUID(), + role: 'user', + content: input.content, + source: input.source, + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..db58915 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "@local/omp-hooks", + "version": "1.0.0", + "description": "Project-level and session-level lifecycle hooks for DSH with full .claude/settings.json and hooks.json compatibility", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": "./index.js", + "./package.json": "./package.json" + }, + "files": [ + "index.js", + "lib/*.js", + "cordis.patch.yml" + ], + "scripts": { + "test": "node --test test/*.test.mjs" + }, + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + } +} diff --git a/test/hooks.test.mjs b/test/hooks.test.mjs new file mode 100644 index 0000000..4fc3bd1 --- /dev/null +++ b/test/hooks.test.mjs @@ -0,0 +1,108 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import os from 'node:os' +import { + parseClaudeConfig, + matchesPattern, + substituteCommand, + parseHookStdout, + executeHookCommand, + findProjectHookConfig, +} from '../index.js' + +test('substituteCommand replaces CLAUDE_PROJECT_DIR and CLAUDE_PLUGIN_ROOT', () => { + const cmd = 'python3 ${CLAUDE_PROJECT_DIR}/check.py --root ${CLAUDE_PLUGIN_ROOT}' + const res = substituteCommand(cmd, { + projectDir: '/my/proj', + pluginRoot: '/my/plugin', + }) + assert.equal(res, 'python3 /my/proj/check.py --root /my/plugin') +}) + +test('parseClaudeConfig handles settings.json format with matchers and timeouts', () => { + const raw = { + hooks: { + PreToolUse: [ + { + matcher: 'bash|write', + hooks: [ + { type: 'command', command: 'echo "check"', timeout: 5 }, + ], + }, + ], + UserPromptSubmit: [ + { + hooks: [ + { type: 'command', command: 'echo "prompt"' }, + ], + }, + ], + }, + } + + const parsed = parseClaudeConfig(raw, { projectDir: '/app' }) + assert.ok(parsed.PreToolUse) + assert.equal(parsed.PreToolUse[0].matcher, 'bash|write') + assert.equal(parsed.PreToolUse[0].hooks[0].timeoutSec, 5) + assert.ok(parsed.UserPromptSubmit) +}) + +test('matchesPattern tests exact, regex, wildcard, and empty', () => { + assert.equal(matchesPattern('*', 'bash'), true) + assert.equal(matchesPattern('', 'bash'), true) + assert.equal(matchesPattern(undefined, 'bash'), true) + assert.equal(matchesPattern('bash', 'bash'), true) + assert.equal(matchesPattern('bash', 'read'), false) + assert.equal(matchesPattern('Edit|Write', 'Edit'), true) + assert.equal(matchesPattern('Edit|Write', 'Write'), true) + assert.equal(matchesPattern('Edit|Write', 'bash'), false) +}) + +test('parseHookStdout handles exit code 2 as deny', () => { + const res = parseHookStdout('', 2, 'Sensitive file blocked') + assert.equal(res.decision, 'deny') + assert.equal(res.reason, 'Sensitive file blocked') +}) + +test('parseHookStdout handles JSON decision and context', () => { + const jsonOut = JSON.stringify({ + decision: 'deny', + reason: 'Security violation', + additionalContext: 'Remember security rule 101', + }) + const res = parseHookStdout(jsonOut, 0, '') + assert.equal(res.decision, 'deny') + assert.equal(res.reason, 'Security violation') + assert.deepEqual(res.additionalContext, ['Remember security rule 101']) +}) + +test('executeHookCommand runs shell command and passes stdin payload', async () => { + const res = await executeHookCommand( + 'read line; echo "Received: $line"', + { tool: 'test_tool' }, + { timeoutSec: 5 } + ) + assert.equal(res.exitCode, 0) + assert.ok(res.stdout.includes('Received: {"tool":"test_tool"}')) +}) + +test('findProjectHookConfig discovers .claude/settings.json', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'hooks-test-')) + const claudeDir = path.join(tmp, '.claude') + fs.mkdirSync(claudeDir) + fs.writeFileSync(path.join(claudeDir, 'settings.json'), JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: 'bash', hooks: [{ type: 'command', command: 'echo 1' }] }, + ], + }, + })) + + const conf = findProjectHookConfig(tmp) + assert.ok(conf) + assert.ok(conf.PreToolUse) + + fs.rmSync(tmp, { recursive: true, force: true }) +})