|
1 | 1 | import assert from 'node:assert/strict'; |
2 | 2 | import { describe, it } from 'node:test'; |
3 | 3 | import type { PreToolUseHookInput, ResolvedHookDefinition } from '@maka/core/hooks'; |
4 | | -import { createHookCommandRunner, type HookCommandRunner } from '../hooks/command-runner.js'; |
5 | | -import { createPreToolUseHookDispatcher } from '../hooks/engine.js'; |
| 4 | +import { |
| 5 | + createHookCommandRunner, |
| 6 | + HOOK_EXECUTION_MARKER, |
| 7 | + type HookCommandRunner, |
| 8 | +} from '../hooks/command-runner.js'; |
| 9 | +import { createHookExecutionLimiter, createPreToolUseHookDispatcher } from '../hooks/engine.js'; |
6 | 10 |
|
7 | 11 | describe('PreToolUse Hook engine', () => { |
8 | 12 | it('freezes one snapshot per turn and skips untrusted matching definitions', async () => { |
@@ -37,6 +41,37 @@ describe('PreToolUse Hook engine', () => { |
37 | 41 | assert.equal(first.audits[0]?.status, 'skipped_untrusted'); |
38 | 42 | }); |
39 | 43 |
|
| 44 | + it('retains every active Turn snapshot until that Turn is released', async () => { |
| 45 | + let revision = 1; |
| 46 | + let loads = 0; |
| 47 | + const dispatcher = createPreToolUseHookDispatcher({ |
| 48 | + loadSnapshot: async () => { |
| 49 | + loads += 1; |
| 50 | + return [definition({ id: `revision-${revision}`, trusted: false })]; |
| 51 | + }, |
| 52 | + }); |
| 53 | + for (let index = 1; index <= 9; index += 1) dispatcher.prepareTurn(`turn-${index}`); |
| 54 | + await Promise.resolve(); |
| 55 | + revision = 2; |
| 56 | + |
| 57 | + const retained = await dispatcher.runPreToolUse( |
| 58 | + hookInput('turn-1'), |
| 59 | + new AbortController().signal, |
| 60 | + { invocationId: 'invocation-1' }, |
| 61 | + ); |
| 62 | + assert.equal(retained.audits[0]?.handlerId, 'revision-1'); |
| 63 | + assert.equal(loads, 9); |
| 64 | + |
| 65 | + dispatcher.releaseTurn('turn-1'); |
| 66 | + const reloaded = await dispatcher.runPreToolUse( |
| 67 | + hookInput('turn-1'), |
| 68 | + new AbortController().signal, |
| 69 | + { invocationId: 'invocation-1-reloaded' }, |
| 70 | + ); |
| 71 | + assert.equal(reloaded.audits[0]?.handlerId, 'revision-2'); |
| 72 | + assert.equal(loads, 10); |
| 73 | + }); |
| 74 | + |
40 | 75 | it('runs matching handlers concurrently and reports denials in configuration order', async () => { |
41 | 76 | let active = 0; |
42 | 77 | let maxActive = 0; |
@@ -73,6 +108,49 @@ describe('PreToolUse Hook engine', () => { |
73 | 108 | ); |
74 | 109 | }); |
75 | 110 |
|
| 111 | + it('shares one concurrency ceiling across simultaneous dispatchers and sessions', async () => { |
| 112 | + let active = 0; |
| 113 | + let maxActive = 0; |
| 114 | + let release!: () => void; |
| 115 | + const gate = new Promise<void>((resolve) => { |
| 116 | + release = resolve; |
| 117 | + }); |
| 118 | + const runner: HookCommandRunner = { |
| 119 | + run: async () => { |
| 120 | + active += 1; |
| 121 | + maxActive = Math.max(maxActive, active); |
| 122 | + await gate; |
| 123 | + active -= 1; |
| 124 | + return result(0); |
| 125 | + }, |
| 126 | + }; |
| 127 | + const limiter = createHookExecutionLimiter(2); |
| 128 | + const createDispatcher = () => |
| 129 | + createPreToolUseHookDispatcher({ |
| 130 | + loadSnapshot: async () => [ |
| 131 | + definition({ id: 'first', definitionOrder: 0 }), |
| 132 | + definition({ id: 'second', definitionOrder: 1 }), |
| 133 | + ], |
| 134 | + commandRunner: runner, |
| 135 | + executionLimiter: limiter, |
| 136 | + }); |
| 137 | + const first = createDispatcher().runPreToolUse( |
| 138 | + hookInput('turn-1'), |
| 139 | + new AbortController().signal, |
| 140 | + { invocationId: 'invocation-1' }, |
| 141 | + ); |
| 142 | + const second = createDispatcher().runPreToolUse( |
| 143 | + { ...hookInput('turn-2'), session_id: 'session-2' }, |
| 144 | + new AbortController().signal, |
| 145 | + { invocationId: 'invocation-2' }, |
| 146 | + ); |
| 147 | + await new Promise<void>((resolve) => setImmediate(resolve)); |
| 148 | + assert.equal(maxActive, 2); |
| 149 | + release(); |
| 150 | + await Promise.all([first, second]); |
| 151 | + assert.equal(maxActive, 2); |
| 152 | + }); |
| 153 | + |
76 | 154 | it('fails open on handler and audit failures but preserves an explicit denial', async () => { |
77 | 155 | const dispatcher = createPreToolUseHookDispatcher({ |
78 | 156 | loadSnapshot: async () => [ |
@@ -133,6 +211,37 @@ describe('PreToolUse Hook engine', () => { |
133 | 211 | assert.equal(output.reason, 'structured policy denial'); |
134 | 212 | }); |
135 | 213 |
|
| 214 | + it('marks Hook children and refuses execution in a Host recursively started by a Hook', async () => { |
| 215 | + const runner = createHookCommandRunner(); |
| 216 | + const markerProbe = await runner.run( |
| 217 | + definition({ |
| 218 | + command: process.execPath, |
| 219 | + args: [ |
| 220 | + '-e', |
| 221 | + `process.exit(process.env[${JSON.stringify(HOOK_EXECUTION_MARKER)}] === '1' ? 0 : 9)`, |
| 222 | + ], |
| 223 | + }), |
| 224 | + hookInput('turn-1'), |
| 225 | + new AbortController().signal, |
| 226 | + ); |
| 227 | + assert.equal(markerProbe.exitCode, 0); |
| 228 | + |
| 229 | + const previous = process.env[HOOK_EXECUTION_MARKER]; |
| 230 | + process.env[HOOK_EXECUTION_MARKER] = '1'; |
| 231 | + try { |
| 232 | + const recursive = await runner.run( |
| 233 | + definition({ command: process.execPath, args: ['-e', 'process.exit(99)'] }), |
| 234 | + hookInput('turn-recursive'), |
| 235 | + new AbortController().signal, |
| 236 | + ); |
| 237 | + assert.equal(recursive.exitCode, null); |
| 238 | + assert.equal(recursive.spawnError, 'Recursive Hook execution is not allowed'); |
| 239 | + } finally { |
| 240 | + if (previous === undefined) delete process.env[HOOK_EXECUTION_MARKER]; |
| 241 | + else process.env[HOOK_EXECUTION_MARKER] = previous; |
| 242 | + } |
| 243 | + }); |
| 244 | + |
136 | 245 | it('terminates timed-out Hook process trees and fails open', async () => { |
137 | 246 | const dispatcher = createPreToolUseHookDispatcher({ |
138 | 247 | loadSnapshot: async () => [ |
|
0 commit comments