@namzu/sdk 62.15.0 → 63.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/dist/plugin/hook-context.d.ts +30 -0
- package/dist/plugin/hook-context.d.ts.map +1 -0
- package/dist/plugin/hook-context.js +51 -0
- package/dist/plugin/hook-context.js.map +1 -0
- package/dist/plugin/index.d.ts +2 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +1 -0
- package/dist/plugin/index.js.map +1 -1
- package/dist/plugin/lifecycle.d.ts +9 -0
- package/dist/plugin/lifecycle.d.ts.map +1 -1
- package/dist/plugin/lifecycle.js +13 -0
- package/dist/plugin/lifecycle.js.map +1 -1
- package/dist/plugin/shell-hook.d.ts +87 -12
- package/dist/plugin/shell-hook.d.ts.map +1 -1
- package/dist/plugin/shell-hook.js +222 -31
- package/dist/plugin/shell-hook.js.map +1 -1
- package/dist/public-runtime.d.ts +1 -1
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +1 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-types.d.ts +1 -1
- package/dist/public-types.d.ts.map +1 -1
- package/dist/runtime/query/executor/tool-call-admission.d.ts.map +1 -1
- package/dist/runtime/query/executor/tool-call-admission.js +2 -3
- package/dist/runtime/query/executor/tool-call-admission.js.map +1 -1
- package/dist/runtime/query/executor.d.ts +2 -0
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +13 -1
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +3 -2
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/types/plugin/index.d.ts +37 -10
- package/dist/types/plugin/index.d.ts.map +1 -1
- package/dist/types/plugin/index.js.map +1 -1
- package/package.json +1 -1
- package/src/plugin/hook-context.ts +58 -0
- package/src/plugin/index.ts +9 -0
- package/src/plugin/lifecycle.ts +13 -0
- package/src/plugin/shell-hook.ts +288 -42
- package/src/public-runtime.ts +6 -0
- package/src/public-types.ts +1 -0
- package/src/runtime/query/executor/tool-call-admission.ts +2 -4
- package/src/runtime/query/executor.ts +15 -3
- package/src/runtime/query/index.ts +3 -3
- package/src/types/plugin/index.ts +48 -16
package/src/plugin/shell-hook.ts
CHANGED
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
* `permission_mode`. The Namzu names stay, so no existing script changes.
|
|
23
23
|
* The same facts go out as
|
|
24
24
|
* same facts as `NAMZU_HOOK_EVENT`, `NAMZU_SESSION_ID`, `NAMZU_TURN_ID`,
|
|
25
|
-
* `NAMZU_TOOL_NAME` and, when the input names a file, `NAMZU_TOOL_PATH
|
|
25
|
+
* `NAMZU_TOOL_NAME` and, when the input names a file, `NAMZU_TOOL_PATH`;
|
|
26
|
+
* `NAMZU_PROJECT_DIR` is the folder the session was started for.
|
|
27
|
+
* `session_start` also sends `source` (`startup` or `resume`) and `session_end`
|
|
28
|
+
* `reason` (`exit` or `replaced`).
|
|
26
29
|
*
|
|
27
30
|
* `session_start` and `session_end` run outside any turn, so they carry no
|
|
28
31
|
* `turn_id` on stdin and no `NAMZU_TURN_ID` in the environment — not even
|
|
@@ -32,8 +35,8 @@
|
|
|
32
35
|
* Its answer is the exit code and, on exit `0`, an optional JSON object on
|
|
33
36
|
* stdout. The exit codes:
|
|
34
37
|
*
|
|
35
|
-
* - `0` — carry on. On `user_prompt_submit`,
|
|
36
|
-
* context for the model.
|
|
38
|
+
* - `0` — carry on. On `user_prompt_submit`, `subagent_start` and
|
|
39
|
+
* `session_start`, plain (non-JSON) stdout becomes context for the model.
|
|
37
40
|
* - `2` — **block**, read per event: before a tool (`pre_tool_use`) the call is
|
|
38
41
|
* skipped and the model is told why, with the hook's stderr as the reason;
|
|
39
42
|
* on `user_prompt_submit` the turn ends; on `turn_end` and `subagent_stop`
|
|
@@ -68,7 +71,9 @@
|
|
|
68
71
|
* `permissionDecisionReason`, `updatedInput` and `additionalContext` for
|
|
69
72
|
* `pre_tool_use`; `additionalContext` for `user_prompt_submit`,
|
|
70
73
|
* `post_tool_use`, `post_tool_use_failure`, `turn_end`, `subagent_stop`,
|
|
71
|
-
* `session_start
|
|
74
|
+
* `session_start` (context for the whole session), `notification`;
|
|
75
|
+
* `updatedToolOutput` for `post_tool_use` (the text the model reads instead
|
|
76
|
+
* of the tool's result); and for `permission_request` a `decision`
|
|
72
77
|
* `{ behavior: "allow" }` or `{ behavior: "deny", message, interrupt }`.
|
|
73
78
|
*
|
|
74
79
|
* ## What a hook may decide, and what it may not
|
|
@@ -123,6 +128,7 @@ import { RENAMED_PLUGIN_HOOK_EVENTS } from '../types/plugin/index.js'
|
|
|
123
128
|
import { asPluginId } from '../utils/id.js'
|
|
124
129
|
import { NOOP_LOGGER } from '../utils/log/create-logger.js'
|
|
125
130
|
import type { Logger } from '../utils/logger.js'
|
|
131
|
+
import { boundHookReplacement } from './hook-context.js'
|
|
126
132
|
import type { PluginLifecycleManager } from './lifecycle.js'
|
|
127
133
|
import { postHookJson } from './shell-hook-http.js'
|
|
128
134
|
|
|
@@ -181,8 +187,10 @@ interface ShellHookEntryBase {
|
|
|
181
187
|
* What this applies to. On a tool event (and `permission_request`) the tool
|
|
182
188
|
* name; on `notification` the notification type. `*` or absent matches
|
|
183
189
|
* everything; a list of names (`a|b`, `a,b`, each optionally ending in `*`
|
|
184
|
-
* as a prefix) matches those
|
|
185
|
-
*
|
|
190
|
+
* as a prefix) matches those without regard to case, and on a tool event
|
|
191
|
+
* also by the names skills write (`Bash`, `Edit`); anything else is a
|
|
192
|
+
* regular expression tested against the name (and, on a tool event, the
|
|
193
|
+
* names skills write for that tool).
|
|
186
194
|
*/
|
|
187
195
|
readonly matcher?: string
|
|
188
196
|
/** Deadline in milliseconds; default `DEFAULT_SHELL_HOOK_TIMEOUT_MS`, capped at `MAX_SHELL_HOOK_TIMEOUT_MS`. */
|
|
@@ -202,10 +210,19 @@ interface ShellHookEntryBase {
|
|
|
202
210
|
* What the hook's own failure does: it could not start, it timed out, it
|
|
203
211
|
* exited with a code other than `0` or `2`, or it printed JSON that is not
|
|
204
212
|
* valid. `continue` (the default) reports it and lets the action go ahead;
|
|
205
|
-
* `block` counts it as exit `2`, so the action the event guards is refused
|
|
213
|
+
* `block` counts it as exit `2`, so the action the event guards is refused;
|
|
214
|
+
* on `post_tool_use` and `post_tool_use_failure`, where the call has already
|
|
215
|
+
* run, it withholds the result from the model instead (set it on a hook that
|
|
216
|
+
* redacts).
|
|
206
217
|
* Not read on a `prompt` hook or an `async` command.
|
|
207
218
|
*/
|
|
208
219
|
readonly onFailure?: ShellHookOnFailure
|
|
220
|
+
/**
|
|
221
|
+
* Where the entry was declared, as a label a host shows (a file's path).
|
|
222
|
+
* It has no effect on how the hook runs, and two entries that differ only
|
|
223
|
+
* here are the same entry.
|
|
224
|
+
*/
|
|
225
|
+
readonly source?: string
|
|
209
226
|
}
|
|
210
227
|
|
|
211
228
|
/** One shell command at one event. */
|
|
@@ -269,6 +286,51 @@ export const MAX_SHELL_HOOK_TIMEOUT_MS = 10 * 60_000
|
|
|
269
286
|
/** Captured output bound per stream. */
|
|
270
287
|
const MAX_CAPTURE_BYTES = 64 * 1024
|
|
271
288
|
|
|
289
|
+
/**
|
|
290
|
+
* How a skill writes each name in `SKILL_TOOL_NAME_ALIASES` (the alias table
|
|
291
|
+
* is keyed lower-case). A regular-expression matcher is also tested against
|
|
292
|
+
* these, so `^(Edit|Write)$` written for those names still fires.
|
|
293
|
+
*/
|
|
294
|
+
const WRITTEN_TOOL_NAMES: Readonly<Record<string, string>> = Object.freeze({
|
|
295
|
+
read: 'Read',
|
|
296
|
+
write: 'Write',
|
|
297
|
+
edit: 'Edit',
|
|
298
|
+
multiedit: 'MultiEdit',
|
|
299
|
+
bash: 'Bash',
|
|
300
|
+
grep: 'Grep',
|
|
301
|
+
glob: 'Glob',
|
|
302
|
+
ls: 'LS',
|
|
303
|
+
webfetch: 'WebFetch',
|
|
304
|
+
websearch: 'WebSearch',
|
|
305
|
+
skill: 'Skill',
|
|
306
|
+
askuserquestion: 'AskUserQuestion',
|
|
307
|
+
task: 'Task',
|
|
308
|
+
agent: 'Agent',
|
|
309
|
+
lsp: 'LSP',
|
|
310
|
+
toolsearch: 'ToolSearch',
|
|
311
|
+
bashoutput: 'BashOutput',
|
|
312
|
+
taskoutput: 'TaskOutput',
|
|
313
|
+
killshell: 'KillShell',
|
|
314
|
+
killbash: 'KillBash',
|
|
315
|
+
taskstop: 'TaskStop',
|
|
316
|
+
taskcreate: 'TaskCreate',
|
|
317
|
+
taskupdate: 'TaskUpdate',
|
|
318
|
+
tasklist: 'TaskList',
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
/** The names a skill writes for the tool called `toolName` here. */
|
|
322
|
+
function writtenNamesFor(toolName: string): string[] {
|
|
323
|
+
const lower = toolName.toLowerCase()
|
|
324
|
+
const names: string[] = []
|
|
325
|
+
for (const [written, tool] of Object.entries(SKILL_TOOL_NAME_ALIASES)) {
|
|
326
|
+
if (tool !== lower) continue
|
|
327
|
+
names.push(written)
|
|
328
|
+
const spelled = WRITTEN_TOOL_NAMES[written]
|
|
329
|
+
if (spelled !== undefined) names.push(spelled)
|
|
330
|
+
}
|
|
331
|
+
return names
|
|
332
|
+
}
|
|
333
|
+
|
|
272
334
|
/**
|
|
273
335
|
* `matcher` against a name (a tool's, or a notification's type).
|
|
274
336
|
*
|
|
@@ -276,25 +338,55 @@ const MAX_CAPTURE_BYTES = 64 * 1024
|
|
|
276
338
|
* matches exactly, each part optionally ending in `*` as a prefix (the
|
|
277
339
|
* spelling this adapter has always taken). Anything else is a regular
|
|
278
340
|
* expression, tested unanchored against the name; one that does not compile
|
|
279
|
-
* matches nothing.
|
|
280
|
-
*
|
|
341
|
+
* matches nothing. A matcher on an event that has no name (`turn_start`)
|
|
342
|
+
* matches nothing unless it is `*`.
|
|
343
|
+
*
|
|
344
|
+
* Names in a list compare without regard to case. With `tool: true` (the
|
|
345
|
+
* event's name is a tool: the tool events and `permission_request`) they also
|
|
346
|
+
* take the names skills write, the way an entry's `if` does: `Bash` matches
|
|
347
|
+
* the tool `bash`, `MultiEdit` matches `edit`, and a prefix such as `Web*`
|
|
348
|
+
* matches `web_fetch` by its written name too. Where the name is not a tool
|
|
349
|
+
* (a notification's type, an agent's name, a session's `source` or `reason`)
|
|
350
|
+
* no tool alias applies, since those words are not tool names; case is still
|
|
351
|
+
* ignored there, because a guard that silently never runs over a capital
|
|
352
|
+
* letter is worse than one that also runs for a name differing only in case.
|
|
353
|
+
*
|
|
354
|
+
* A list part ending in `.*` is also tried as a regular expression.
|
|
355
|
+
*
|
|
356
|
+
* A regular expression is tested, case-sensitively as written, against the
|
|
357
|
+
* real name and, with `tool: true`, against each name skills write for that
|
|
358
|
+
* tool, so one written for those names still fires. It never matches less
|
|
359
|
+
* than it did against the real name alone.
|
|
281
360
|
*/
|
|
282
361
|
export function shellHookMatches(
|
|
283
362
|
matcher: string | undefined,
|
|
284
363
|
toolName: string | undefined,
|
|
364
|
+
options: { readonly tool?: boolean } = {},
|
|
285
365
|
): boolean {
|
|
286
366
|
if (matcher === undefined || matcher.trim() === '' || matcher.trim() === '*') return true
|
|
287
367
|
if (toolName === undefined) return false
|
|
368
|
+
const tool = options.tool === true
|
|
288
369
|
const parts = matcher.split(/[|,]/).map((part) => part.trim())
|
|
289
370
|
if (parts.every((part) => /^[A-Za-z0-9_.:-]+\*?$/.test(part))) {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
371
|
+
const subject = toolName.toLowerCase()
|
|
372
|
+
const listed = parts.some((candidate) => {
|
|
373
|
+
const prefix = candidate.endsWith('*')
|
|
374
|
+
const written = (prefix ? candidate.slice(0, -1) : candidate).toLowerCase()
|
|
375
|
+
const spellings = [written]
|
|
376
|
+
if (tool) {
|
|
377
|
+
const alias = SKILL_TOOL_NAME_ALIASES[written]
|
|
378
|
+
if (alias !== undefined) spellings.push(alias)
|
|
379
|
+
}
|
|
380
|
+
return spellings.some((name) => (prefix ? subject.startsWith(name) : subject === name))
|
|
381
|
+
})
|
|
382
|
+
// `Bash.*` reads as the prefix `bash.` above, which no tool has. A part
|
|
383
|
+
// ending in `.*` is also a regular expression, so it is tried as one.
|
|
384
|
+
if (listed || !matcher.includes('.*')) return listed
|
|
295
385
|
}
|
|
296
386
|
try {
|
|
297
|
-
|
|
387
|
+
const expression = new RegExp(matcher)
|
|
388
|
+
if (expression.test(toolName)) return true
|
|
389
|
+
return tool && writtenNamesFor(toolName).some((name) => expression.test(name))
|
|
298
390
|
} catch {
|
|
299
391
|
return false
|
|
300
392
|
}
|
|
@@ -311,6 +403,7 @@ export function shellHookIfMatches(
|
|
|
311
403
|
filter: string | undefined,
|
|
312
404
|
toolName: string | undefined,
|
|
313
405
|
toolInput: unknown,
|
|
406
|
+
pathFilter?: ShellHookPathFilter,
|
|
314
407
|
): boolean {
|
|
315
408
|
if (filter === undefined || filter.trim() === '') return true
|
|
316
409
|
if (toolName === undefined) return true
|
|
@@ -318,10 +411,17 @@ export function shellHookIfMatches(
|
|
|
318
411
|
if (!shape) return true
|
|
319
412
|
const written = (shape[1] ?? '').toLowerCase()
|
|
320
413
|
const tool = SKILL_TOOL_NAME_ALIASES[written] ?? written
|
|
321
|
-
if (tool !== toolName.toLowerCase()) return false
|
|
322
414
|
const specifier = shape[2]?.trim()
|
|
323
|
-
|
|
324
|
-
|
|
415
|
+
const hasPattern =
|
|
416
|
+
specifier !== undefined && specifier !== '' && specifier.replaceAll('*', '') !== ''
|
|
417
|
+
// A path pattern is the permission rules' own: the host that has them says
|
|
418
|
+
// whether this call's path is named. It decides for the tools it knows.
|
|
419
|
+
if (hasPattern && pathFilter !== undefined) {
|
|
420
|
+
const answer = pathFilter(tool, specifier, toolName, toolInput)
|
|
421
|
+
if (answer !== undefined) return answer
|
|
422
|
+
}
|
|
423
|
+
if (tool !== toolName.toLowerCase()) return false
|
|
424
|
+
if (!hasPattern) return true
|
|
325
425
|
if (tool !== 'bash') return true
|
|
326
426
|
const line =
|
|
327
427
|
toolInput !== null && typeof toolInput === 'object'
|
|
@@ -345,12 +445,28 @@ export function shellHookIfMatches(
|
|
|
345
445
|
}
|
|
346
446
|
}
|
|
347
447
|
|
|
448
|
+
/**
|
|
449
|
+
* A host's reading of a path pattern in a hook's `if`: whether the call's path
|
|
450
|
+
* is named by `pattern` the way the host's permission rules read it. `tool` is
|
|
451
|
+
* the filter's tool (`edit`, `read`), `callTool` the tool being called.
|
|
452
|
+
* `undefined` means the host does not read this tool's paths, and the
|
|
453
|
+
* filter is then read as it is for any other tool.
|
|
454
|
+
*/
|
|
455
|
+
export type ShellHookPathFilter = (
|
|
456
|
+
tool: string,
|
|
457
|
+
pattern: string,
|
|
458
|
+
callTool: string,
|
|
459
|
+
toolInput: unknown,
|
|
460
|
+
) => boolean | undefined
|
|
461
|
+
|
|
348
462
|
/** What one hook run came back with, before it is mapped to a verdict. */
|
|
349
463
|
export interface ShellHookOutcome {
|
|
350
464
|
readonly exitCode: number | null
|
|
351
465
|
readonly timedOut: boolean
|
|
352
466
|
readonly stdout: string
|
|
353
467
|
readonly stderr: string
|
|
468
|
+
/** Set when the hook printed more on stdout than is kept (`MAX_CAPTURE_BYTES`). */
|
|
469
|
+
readonly stdoutTruncated?: boolean
|
|
354
470
|
/** Set when the process could not be started at all. */
|
|
355
471
|
readonly spawnError?: string
|
|
356
472
|
}
|
|
@@ -362,6 +478,12 @@ export interface ShellHookInput {
|
|
|
362
478
|
readonly sessionId: string
|
|
363
479
|
/** The turn the hook fired in. Absent on `session_start` and `session_end`. */
|
|
364
480
|
readonly turnId?: string
|
|
481
|
+
/** Why the session started, on `session_start`. Sent as `source`. */
|
|
482
|
+
readonly sessionSource?: string
|
|
483
|
+
/** Why the session closed, on `session_end`. Sent as `reason`. */
|
|
484
|
+
readonly endReason?: string
|
|
485
|
+
/** The folder the session was started for. Sent as `NAMZU_PROJECT_DIR`; absent, `cwd`. */
|
|
486
|
+
readonly projectDir?: string
|
|
365
487
|
/** The delegating session, on `subagent_start` and `subagent_stop`. */
|
|
366
488
|
readonly parentSessionId?: string
|
|
367
489
|
/** The parent turn whose tool call spawned the child, on `subagent_start` and `subagent_stop`. */
|
|
@@ -403,6 +525,7 @@ export function shellHookPayload(input: ShellHookInput): string {
|
|
|
403
525
|
...(input.transcriptPath !== undefined ? { transcript_path: input.transcriptPath } : {}),
|
|
404
526
|
...(input.permissionMode !== undefined ? { permission_mode: input.permissionMode } : {}),
|
|
405
527
|
...(input.turnId !== undefined ? { turn_id: input.turnId } : {}),
|
|
528
|
+
...(input.sessionSource !== undefined ? { source: input.sessionSource } : {}),
|
|
406
529
|
...(input.parentSessionId !== undefined ? { parent_session_id: input.parentSessionId } : {}),
|
|
407
530
|
...(input.parentTurnId !== undefined ? { parent_turn_id: input.parentTurnId } : {}),
|
|
408
531
|
...(input.agentType !== undefined
|
|
@@ -429,7 +552,11 @@ export function shellHookPayload(input: ShellHookInput): string {
|
|
|
429
552
|
})),
|
|
430
553
|
}
|
|
431
554
|
: {}),
|
|
432
|
-
...(input.reason !== undefined
|
|
555
|
+
...(input.reason !== undefined
|
|
556
|
+
? { reason: input.reason }
|
|
557
|
+
: input.endReason !== undefined
|
|
558
|
+
? { reason: input.endReason }
|
|
559
|
+
: {}),
|
|
433
560
|
...(input.event === 'turn_failure' && input.error !== undefined ? { error: input.error } : {}),
|
|
434
561
|
...(input.notification !== undefined
|
|
435
562
|
? {
|
|
@@ -463,6 +590,7 @@ export function runShellHook(
|
|
|
463
590
|
...inherited,
|
|
464
591
|
NAMZU_HOOK_EVENT: input.event,
|
|
465
592
|
NAMZU_SESSION_ID: input.sessionId,
|
|
593
|
+
NAMZU_PROJECT_DIR: input.projectDir ?? input.cwd,
|
|
466
594
|
...(input.turnId !== undefined ? { NAMZU_TURN_ID: input.turnId } : {}),
|
|
467
595
|
...(input.toolName !== undefined ? { NAMZU_TOOL_NAME: input.toolName } : {}),
|
|
468
596
|
...(toolPath !== undefined ? { NAMZU_TOOL_PATH: toolPath } : {}),
|
|
@@ -507,17 +635,24 @@ export function runShellHook(
|
|
|
507
635
|
}
|
|
508
636
|
let stdout = ''
|
|
509
637
|
let stderr = ''
|
|
638
|
+
let stdoutTruncated = false
|
|
510
639
|
let timedOut = false
|
|
511
640
|
let settled = false
|
|
512
|
-
const capture = (current: string, chunk: Buffer): string =>
|
|
513
|
-
current.length >= MAX_CAPTURE_BYTES
|
|
514
|
-
|
|
515
|
-
|
|
641
|
+
const capture = (current: string, chunk: Buffer): { text: string; cut: boolean } => {
|
|
642
|
+
if (current.length >= MAX_CAPTURE_BYTES) return { text: current, cut: chunk.length > 0 }
|
|
643
|
+
const joined = current + chunk.toString('utf8')
|
|
644
|
+
return {
|
|
645
|
+
text: joined.slice(0, MAX_CAPTURE_BYTES),
|
|
646
|
+
cut: joined.length > MAX_CAPTURE_BYTES,
|
|
647
|
+
}
|
|
648
|
+
}
|
|
516
649
|
child.stdout?.on('data', (chunk: Buffer) => {
|
|
517
|
-
|
|
650
|
+
const next = capture(stdout, chunk)
|
|
651
|
+
stdout = next.text
|
|
652
|
+
if (next.cut) stdoutTruncated = true
|
|
518
653
|
})
|
|
519
654
|
child.stderr?.on('data', (chunk: Buffer) => {
|
|
520
|
-
stderr = capture(stderr, chunk)
|
|
655
|
+
stderr = capture(stderr, chunk).text
|
|
521
656
|
})
|
|
522
657
|
const finish = (outcome: ShellHookOutcome) => {
|
|
523
658
|
if (settled) return
|
|
@@ -550,11 +685,18 @@ export function runShellHook(
|
|
|
550
685
|
timedOut,
|
|
551
686
|
stdout,
|
|
552
687
|
stderr,
|
|
688
|
+
...(stdoutTruncated ? { stdoutTruncated } : {}),
|
|
553
689
|
spawnError: error.message,
|
|
554
690
|
})
|
|
555
691
|
})
|
|
556
692
|
child.on('close', (code) => {
|
|
557
|
-
finish({
|
|
693
|
+
finish({
|
|
694
|
+
exitCode: code,
|
|
695
|
+
timedOut,
|
|
696
|
+
stdout,
|
|
697
|
+
stderr,
|
|
698
|
+
...(stdoutTruncated ? { stdoutTruncated } : {}),
|
|
699
|
+
})
|
|
558
700
|
})
|
|
559
701
|
child.on('exit', () => {
|
|
560
702
|
// After a kill, a survivor that still holds the pipes must not hold
|
|
@@ -610,6 +752,8 @@ interface ShellHookReading {
|
|
|
610
752
|
context?: string
|
|
611
753
|
halt?: { reason: string }
|
|
612
754
|
notice?: string
|
|
755
|
+
/** Text that replaces a tool's result, on `post_tool_use`. */
|
|
756
|
+
replacement?: string
|
|
613
757
|
/** The hook asked for its stdout to stay out of the debug log. */
|
|
614
758
|
suppressOutput?: boolean
|
|
615
759
|
/** Why the JSON was set aside, when it was. */
|
|
@@ -728,6 +872,12 @@ function readShellHookJson(event: ShellHookEvent, stdout: string): ShellHookRead
|
|
|
728
872
|
reading.updatedInput = specific.updatedInput
|
|
729
873
|
}
|
|
730
874
|
}
|
|
875
|
+
if (event === 'post_tool_use' && specific.updatedToolOutput !== undefined) {
|
|
876
|
+
if (typeof specific.updatedToolOutput !== 'string') {
|
|
877
|
+
return { invalid: 'hookSpecificOutput: `updatedToolOutput` must be a string' }
|
|
878
|
+
}
|
|
879
|
+
reading.replacement = boundHookReplacement(specific.updatedToolOutput)
|
|
880
|
+
}
|
|
731
881
|
if (event === 'permission_request') {
|
|
732
882
|
const decision = specific.decision
|
|
733
883
|
if (!isRecord(decision)) {
|
|
@@ -765,15 +915,30 @@ export function shellHookVerdict(
|
|
|
765
915
|
}
|
|
766
916
|
// A failure of the hook itself is "carry on" unless the entry asked for
|
|
767
917
|
// it to count as a refusal, which is exit 2 with the failure as the reason.
|
|
768
|
-
const failed = (reason: string): PluginHookResult =>
|
|
769
|
-
entry.onFailure
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
918
|
+
const failed = (reason: string, shown: string = reason): PluginHookResult => {
|
|
919
|
+
if (entry.onFailure !== 'block' || entry.type === 'prompt' || isAsyncShellHook(entry)) {
|
|
920
|
+
return { action: 'continue' }
|
|
921
|
+
}
|
|
922
|
+
// The call has run and a refusal cannot undo it, but the hook may have
|
|
923
|
+
// been the one thing keeping something out of the result (a redaction),
|
|
924
|
+
// so a failed guard withholds the result instead of passing it on. The
|
|
925
|
+
// kernel marks the record as it does for any replaced result.
|
|
926
|
+
if (event === 'post_tool_use' || event === 'post_tool_use_failure') {
|
|
927
|
+
const line = `A hook guarding this result failed, so the result is not shown (${shown}). The tool call itself has already run.`
|
|
928
|
+
return {
|
|
929
|
+
action: 'replace',
|
|
930
|
+
output: line,
|
|
931
|
+
content: [{ type: 'text', text: line }],
|
|
932
|
+
disclose: true,
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
return shellHookVerdict(
|
|
936
|
+
event,
|
|
937
|
+
{ ...entry, onFailure: 'continue' },
|
|
938
|
+
{ exitCode: 2, timedOut: false, stdout: '', stderr: `hook failed: ${reason}` },
|
|
939
|
+
log,
|
|
940
|
+
)
|
|
941
|
+
}
|
|
777
942
|
if (outcome.spawnError !== undefined) {
|
|
778
943
|
log.warn('hook could not start', {
|
|
779
944
|
...attrs,
|
|
@@ -822,12 +987,20 @@ export function shellHookVerdict(
|
|
|
822
987
|
|
|
823
988
|
// Exit 0.
|
|
824
989
|
const text = outcome.stdout.trim()
|
|
825
|
-
const reading
|
|
990
|
+
const reading: ShellHookReading | undefined =
|
|
991
|
+
outcome.stdoutTruncated === true && text.startsWith('{')
|
|
992
|
+
? {
|
|
993
|
+
invalid: `answer is too large: the hook printed more than the ${MAX_CAPTURE_BYTES} characters of stdout that are read`,
|
|
994
|
+
}
|
|
995
|
+
: readShellHookJson(event, outcome.stdout)
|
|
826
996
|
if (reading === undefined) {
|
|
827
997
|
// On the prompt event, what the hook printed is context for the
|
|
828
998
|
// model — the branch, the time, a ticket. Everywhere else stdout is
|
|
829
999
|
// the operator's to read and the model's business not at all.
|
|
830
|
-
if (
|
|
1000
|
+
if (
|
|
1001
|
+
(event === 'user_prompt_submit' || event === 'subagent_start' || event === 'session_start') &&
|
|
1002
|
+
text.length > 0
|
|
1003
|
+
)
|
|
831
1004
|
return { action: 'annotate', text }
|
|
832
1005
|
return { action: 'continue' }
|
|
833
1006
|
}
|
|
@@ -836,7 +1009,9 @@ export function shellHookVerdict(
|
|
|
836
1009
|
...attrs,
|
|
837
1010
|
'namzu.hook.problem': reading.invalid,
|
|
838
1011
|
})
|
|
839
|
-
|
|
1012
|
+
// The parser's own detail can quote a piece of the answer; the line the
|
|
1013
|
+
// model reads keeps only the lead.
|
|
1014
|
+
return failed(reading.invalid, reading.invalid.split(' (')[0])
|
|
840
1015
|
}
|
|
841
1016
|
if (!reading.suppressOutput) {
|
|
842
1017
|
log.debug('hook output', {
|
|
@@ -885,11 +1060,34 @@ export function shellHookVerdict(
|
|
|
885
1060
|
const context = [reading.block, reading.context].filter(Boolean).join('\n')
|
|
886
1061
|
return context.length > 0 ? { action: 'annotate', text: context } : { action: 'continue' }
|
|
887
1062
|
}
|
|
1063
|
+
case 'session_start': {
|
|
1064
|
+
// Nothing here can be refused. What the hook said for the model becomes
|
|
1065
|
+
// the session's context; a stop request and a warning still count.
|
|
1066
|
+
return reading.context !== undefined
|
|
1067
|
+
? { action: 'annotate', text: reading.context }
|
|
1068
|
+
: {
|
|
1069
|
+
action: 'continue',
|
|
1070
|
+
...(reading.halt ? { halt: reading.halt } : {}),
|
|
1071
|
+
...(reading.notice ? { notice: reading.notice } : {}),
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
888
1074
|
case 'post_tool_use':
|
|
889
1075
|
case 'post_tool_use_failure': {
|
|
890
1076
|
// The call has run, so a "block" cannot undo it: its reason is what the
|
|
891
1077
|
// model is told about the result.
|
|
892
1078
|
const context = [reading.block, reading.context].filter(Boolean).join('\n')
|
|
1079
|
+
// A replacement is the hook's text in place of the tool's: the record
|
|
1080
|
+
// says so beside it (the kernel adds the mark).
|
|
1081
|
+
if (reading.replacement !== undefined) {
|
|
1082
|
+
return {
|
|
1083
|
+
action: 'replace',
|
|
1084
|
+
output: reading.replacement,
|
|
1085
|
+
disclose: true,
|
|
1086
|
+
...(context.length > 0 ? { context } : {}),
|
|
1087
|
+
...(reading.halt ? { halt: reading.halt } : {}),
|
|
1088
|
+
...(reading.notice ? { notice: reading.notice } : {}),
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
893
1091
|
return {
|
|
894
1092
|
action: 'continue',
|
|
895
1093
|
...(context.length > 0 ? { context } : {}),
|
|
@@ -986,6 +1184,28 @@ export interface ShellHookOptions {
|
|
|
986
1184
|
readonly transcriptPath?: (sessionId: string) => string | undefined
|
|
987
1185
|
/** The permission mode in force now, sent as `permission_mode`. Called at each run. */
|
|
988
1186
|
readonly permissionMode?: () => string | undefined
|
|
1187
|
+
/**
|
|
1188
|
+
* The folder the session was started for, sent to every command as
|
|
1189
|
+
* `NAMZU_PROJECT_DIR`. Absent, `cwd`.
|
|
1190
|
+
*/
|
|
1191
|
+
readonly projectDir?: string
|
|
1192
|
+
/** How a path pattern in an entry's `if` is read. Absent, such a filter runs the hook. */
|
|
1193
|
+
readonly pathFilter?: ShellHookPathFilter
|
|
1194
|
+
/**
|
|
1195
|
+
* A deadline, in milliseconds, for entries of an event that set no
|
|
1196
|
+
* `timeoutMs` of their own (an entry's own is honoured as written, up to
|
|
1197
|
+
* the cap). Absent, or an event not listed, the ordinary default applies.
|
|
1198
|
+
*/
|
|
1199
|
+
readonly defaultTimeoutMs?: { readonly [event in ShellHookEvent]?: number }
|
|
1200
|
+
/**
|
|
1201
|
+
* Called when a hook of this adapter ran out its deadline and was ended.
|
|
1202
|
+
* `hook` is the command, URL or prompt text the entry names.
|
|
1203
|
+
*/
|
|
1204
|
+
readonly onTimeout?: (info: {
|
|
1205
|
+
readonly event: ShellHookEvent
|
|
1206
|
+
readonly hook: string
|
|
1207
|
+
readonly timeoutMs: number
|
|
1208
|
+
}) => void
|
|
989
1209
|
}
|
|
990
1210
|
|
|
991
1211
|
/** The deadline for `prompt` hooks when they name none. */
|
|
@@ -1087,10 +1307,19 @@ function readJudgement(reply: string): { ok: boolean; reason: string } | undefin
|
|
|
1087
1307
|
/** One entry as a hook definition the lifecycle manager accepts. */
|
|
1088
1308
|
export function createShellHook(
|
|
1089
1309
|
event: ShellHookEvent,
|
|
1090
|
-
|
|
1310
|
+
configured: ShellHookHandler,
|
|
1091
1311
|
options: ShellHookOptions,
|
|
1092
1312
|
): PluginHookDefinition {
|
|
1093
1313
|
const log = options.log ?? NOOP_LOGGER
|
|
1314
|
+
const eventDefault = options.defaultTimeoutMs?.[event]
|
|
1315
|
+
const entry: ShellHookHandler =
|
|
1316
|
+
configured.timeoutMs === undefined && eventDefault !== undefined
|
|
1317
|
+
? { ...configured, timeoutMs: eventDefault }
|
|
1318
|
+
: configured
|
|
1319
|
+
const reportTimeout = (timedOut: boolean): void => {
|
|
1320
|
+
if (!timedOut) return
|
|
1321
|
+
options.onTimeout?.({ event, hook: describeShellHook(entry), timeoutMs: hookTimeout(entry) })
|
|
1322
|
+
}
|
|
1094
1323
|
// `once` is per definition, and a definition is per session: the manager
|
|
1095
1324
|
// is built for one session and its hooks are attached to it.
|
|
1096
1325
|
let spent = false
|
|
@@ -1102,14 +1331,26 @@ export function createShellHook(
|
|
|
1102
1331
|
handler: async (context: PluginHookContext) => {
|
|
1103
1332
|
if (spent) return { action: 'continue' }
|
|
1104
1333
|
// A notification is matched on its type; every other event on the tool.
|
|
1334
|
+
const namesATool = !(
|
|
1335
|
+
event === 'notification' ||
|
|
1336
|
+
event === 'subagent_start' ||
|
|
1337
|
+
event === 'subagent_stop' ||
|
|
1338
|
+
event === 'session_start' ||
|
|
1339
|
+
event === 'session_end'
|
|
1340
|
+
)
|
|
1105
1341
|
const subject =
|
|
1106
1342
|
event === 'notification'
|
|
1107
1343
|
? context.notification?.type
|
|
1108
1344
|
: event === 'subagent_start' || event === 'subagent_stop'
|
|
1109
1345
|
? context.agentType
|
|
1110
|
-
:
|
|
1111
|
-
|
|
1112
|
-
|
|
1346
|
+
: event === 'session_start'
|
|
1347
|
+
? context.source
|
|
1348
|
+
: event === 'session_end'
|
|
1349
|
+
? context.endReason
|
|
1350
|
+
: context.toolName
|
|
1351
|
+
if (!shellHookMatches(entry.matcher, subject, { tool: namesATool }))
|
|
1352
|
+
return { action: 'continue' }
|
|
1353
|
+
if (!shellHookIfMatches(entry.if, context.toolName, context.toolInput, options.pathFilter)) {
|
|
1113
1354
|
return { action: 'continue' }
|
|
1114
1355
|
}
|
|
1115
1356
|
if (entry.once === true) spent = true
|
|
@@ -1118,7 +1359,10 @@ export function createShellHook(
|
|
|
1118
1359
|
const hookInput: ShellHookInput = {
|
|
1119
1360
|
event,
|
|
1120
1361
|
cwd: options.cwd,
|
|
1362
|
+
...(options.projectDir !== undefined ? { projectDir: options.projectDir } : {}),
|
|
1121
1363
|
sessionId: String(context.sessionId),
|
|
1364
|
+
...(context.source !== undefined ? { sessionSource: context.source } : {}),
|
|
1365
|
+
...(context.endReason !== undefined ? { endReason: context.endReason } : {}),
|
|
1122
1366
|
...(context.turnId !== undefined ? { turnId: String(context.turnId) } : {}),
|
|
1123
1367
|
...(context.parentSessionId !== undefined
|
|
1124
1368
|
? { parentSessionId: String(context.parentSessionId) }
|
|
@@ -1159,6 +1403,7 @@ export function createShellHook(
|
|
|
1159
1403
|
timeoutMs: hookTimeout(entry),
|
|
1160
1404
|
...(context.signal !== undefined ? { signal: context.signal } : {}),
|
|
1161
1405
|
})
|
|
1406
|
+
reportTimeout(response.timedOut)
|
|
1162
1407
|
const ok = response.status !== null && response.status >= 200 && response.status < 300
|
|
1163
1408
|
return shellHookVerdict(
|
|
1164
1409
|
event,
|
|
@@ -1187,6 +1432,7 @@ export function createShellHook(
|
|
|
1187
1432
|
return { action: 'continue' }
|
|
1188
1433
|
}
|
|
1189
1434
|
const outcome = await runShellHook(entry, hookInput, context.signal)
|
|
1435
|
+
reportTimeout(outcome.timedOut)
|
|
1190
1436
|
return shellHookVerdict(event, entry, outcome, log)
|
|
1191
1437
|
},
|
|
1192
1438
|
} as PluginHookDefinition
|
package/src/public-runtime.ts
CHANGED
|
@@ -697,9 +697,15 @@ export {
|
|
|
697
697
|
|
|
698
698
|
export {
|
|
699
699
|
attachShellHooks,
|
|
700
|
+
boundHookContext,
|
|
701
|
+
boundHookReplacement,
|
|
700
702
|
createShellHook,
|
|
701
703
|
DEFAULT_SHELL_HOOK_TIMEOUT_MS,
|
|
702
704
|
definePlugin,
|
|
705
|
+
frameOperatorHookContext,
|
|
706
|
+
MAX_HOOK_CONTEXT_CHARS,
|
|
707
|
+
MAX_HOOK_REPLACEMENT_CHARS,
|
|
708
|
+
sessionStartHookContext,
|
|
703
709
|
discoverAllPluginDirs,
|
|
704
710
|
discoverPlugins,
|
|
705
711
|
assertEnableable,
|
package/src/public-types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { GENAI, NAMZU } from '../../../constants/telemetry/index.js'
|
|
2
|
+
import { boundHookContext } from '../../../plugin/hook-context.js'
|
|
2
3
|
import { callableToolNames, formatToolNames } from '../../../registry/tool/callable.js'
|
|
3
4
|
import { renderToolSchema } from '../../../registry/tool/schema.js'
|
|
4
5
|
import type { ToolCall, ToolInputError } from '../../../types/message/index.js'
|
|
@@ -433,12 +434,9 @@ function interpretPreToolResults(
|
|
|
433
434
|
*/
|
|
434
435
|
export function hookNote(texts: readonly string[]): string {
|
|
435
436
|
const joined = texts.join('\n')
|
|
436
|
-
return `[Hook note]\n${joined
|
|
437
|
+
return `[Hook note]\n${boundHookContext(joined)}`
|
|
437
438
|
}
|
|
438
439
|
|
|
439
|
-
/** A hook's text is one more thing for the model to read; it gets a bound. */
|
|
440
|
-
const MAX_HOOK_NOTE_CHARS = 8_000
|
|
441
|
-
|
|
442
440
|
/**
|
|
443
441
|
* Turn the call the model issued into a name and a parsed input, giving
|
|
444
442
|
* a configured repairer one chance to fix it first.
|
|
@@ -564,8 +564,14 @@ interface PostToolOverride {
|
|
|
564
564
|
readonly output: string
|
|
565
565
|
readonly isError: boolean
|
|
566
566
|
readonly content?: ToolResultContent
|
|
567
|
+
/** A hook replaced a result that stood; the record says so beside it. */
|
|
568
|
+
readonly replacedByHook?: true
|
|
567
569
|
}
|
|
568
570
|
|
|
571
|
+
/** What the record and the model are told beside a result a hook replaced. */
|
|
572
|
+
export const HOOK_REPLACED_RESULT_NOTE =
|
|
573
|
+
"A hook replaced this tool result: the text above is the hook's, not the tool's own output."
|
|
574
|
+
|
|
569
575
|
/** What `pre_tool_use` hooks said about a call beyond running it or not. */
|
|
570
576
|
export interface PreToolHookNotes {
|
|
571
577
|
/** The strictest permission answer among the hooks: `ask` outranks `allow`. */
|
|
@@ -2944,9 +2950,12 @@ export class ToolExecutor {
|
|
|
2944
2950
|
// receipt and the host's alike, framed as a hook's words. Added after
|
|
2945
2951
|
// the output budget so a bounded result is never trimmed to make room
|
|
2946
2952
|
// for it; the note has its own bound (`hookNote`).
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2953
|
+
// The mark comes first so the bound on hook text never cuts it off.
|
|
2954
|
+
const hookTexts = [
|
|
2955
|
+
postOverride?.replacedByHook === true ? HOOK_REPLACED_RESULT_NOTE : undefined,
|
|
2956
|
+
preparedHookContext,
|
|
2957
|
+
post.context,
|
|
2958
|
+
].filter((text): text is string => text !== undefined && text.length > 0)
|
|
2950
2959
|
if (hookTexts.length > 0) {
|
|
2951
2960
|
const note = hookNote(hookTexts)
|
|
2952
2961
|
output = `${output}\n\n${note}`
|
|
@@ -3562,8 +3571,11 @@ export class ToolExecutor {
|
|
|
3562
3571
|
override = {
|
|
3563
3572
|
output: result.output,
|
|
3564
3573
|
isError: false,
|
|
3574
|
+
...(result.disclose === true ? { replacedByHook: true as const } : {}),
|
|
3565
3575
|
...(result.content !== undefined ? { content: result.content } : {}),
|
|
3566
3576
|
}
|
|
3577
|
+
if (result.context) contexts.push(result.context)
|
|
3578
|
+
if (result.halt) this.requestHalt(result.halt.reason)
|
|
3567
3579
|
continue
|
|
3568
3580
|
// `retry` was a declared variant with no implementation: every
|
|
3569
3581
|
// site that consumed it threw. Here it finally means something
|