@namzu/sdk 62.14.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 +32 -0
- package/dist/connector/mcp/stdio.d.ts.map +1 -1
- package/dist/connector/mcp/stdio.js +3 -0
- package/dist/connector/mcp/stdio.js.map +1 -1
- package/dist/eval/compare.d.ts +54 -0
- package/dist/eval/compare.d.ts.map +1 -1
- package/dist/eval/compare.js +146 -1
- package/dist/eval/compare.js.map +1 -1
- package/dist/eval/experiment.d.ts.map +1 -1
- package/dist/eval/experiment.js +46 -6
- package/dist/eval/experiment.js.map +1 -1
- package/dist/eval/index.d.ts +6 -2
- package/dist/eval/index.d.ts.map +1 -1
- package/dist/eval/index.js +3 -1
- package/dist/eval/index.js.map +1 -1
- package/dist/eval/report-stats.d.ts +6 -0
- package/dist/eval/report-stats.d.ts.map +1 -1
- package/dist/eval/report-stats.js +10 -0
- package/dist/eval/report-stats.js.map +1 -1
- package/dist/eval/run-options.d.ts +25 -0
- package/dist/eval/run-options.d.ts.map +1 -1
- package/dist/eval/run-options.js +36 -0
- package/dist/eval/run-options.js.map +1 -1
- package/dist/eval/run-summary.d.ts +8 -0
- package/dist/eval/run-summary.d.ts.map +1 -1
- package/dist/eval/run-summary.js +13 -3
- package/dist/eval/run-summary.js.map +1 -1
- package/dist/eval/settings.d.ts +74 -0
- package/dist/eval/settings.d.ts.map +1 -0
- package/dist/eval/settings.js +161 -0
- package/dist/eval/settings.js.map +1 -0
- package/dist/eval/split.d.ts +48 -0
- package/dist/eval/split.d.ts.map +1 -0
- package/dist/eval/split.js +41 -0
- package/dist/eval/split.js.map +1 -0
- package/dist/eval/types.d.ts +12 -0
- package/dist/eval/types.d.ts.map +1 -1
- package/dist/execution/local.d.ts.map +1 -1
- package/dist/execution/local.js +2 -1
- package/dist/execution/local.js.map +1 -1
- package/dist/peers/envelope.d.ts +11 -0
- package/dist/peers/envelope.d.ts.map +1 -1
- package/dist/peers/envelope.js +29 -0
- package/dist/peers/envelope.js.map +1 -1
- package/dist/peers/index.d.ts +3 -3
- package/dist/peers/index.d.ts.map +1 -1
- package/dist/peers/index.js +2 -2
- package/dist/peers/index.js.map +1 -1
- package/dist/peers/protocol.d.ts +26 -0
- package/dist/peers/protocol.d.ts.map +1 -1
- package/dist/peers/protocol.js +22 -2
- package/dist/peers/protocol.js.map +1 -1
- package/dist/peers/record.d.ts +2 -0
- package/dist/peers/record.d.ts.map +1 -1
- 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 +224 -32
- package/dist/plugin/shell-hook.js.map +1 -1
- package/dist/process/session-child.d.ts +21 -0
- package/dist/process/session-child.d.ts.map +1 -0
- package/dist/process/session-child.js +32 -0
- package/dist/process/session-child.js.map +1 -0
- package/dist/public-runtime.d.ts +4 -3
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +4 -3
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-types.d.ts +2 -2
- 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/runtime/system-events.d.ts +3 -3
- package/dist/runtime/system-events.d.ts.map +1 -1
- package/dist/runtime/system-events.js +2 -1
- package/dist/runtime/system-events.js.map +1 -1
- package/dist/sandbox/provider/local.d.ts +1 -0
- package/dist/sandbox/provider/local.d.ts.map +1 -1
- package/dist/sandbox/provider/local.js +5 -2
- package/dist/sandbox/provider/local.js.map +1 -1
- package/dist/sandbox/terminal.d.ts.map +1 -1
- package/dist/sandbox/terminal.js +2 -1
- package/dist/sandbox/terminal.js.map +1 -1
- package/dist/tools/command-shell.d.ts.map +1 -1
- package/dist/tools/command-shell.js +7 -1
- package/dist/tools/command-shell.js.map +1 -1
- package/dist/types/message/index.d.ts +1 -1
- package/dist/types/message/index.d.ts.map +1 -1
- package/dist/types/message/index.js +1 -0
- package/dist/types/message/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/connector/mcp/stdio.ts +3 -0
- package/src/eval/compare.ts +245 -2
- package/src/eval/experiment.ts +58 -4
- package/src/eval/index.ts +32 -1
- package/src/eval/report-stats.ts +11 -0
- package/src/eval/run-options.ts +51 -0
- package/src/eval/run-summary.ts +19 -1
- package/src/eval/settings.ts +225 -0
- package/src/eval/split.ts +83 -0
- package/src/eval/types.ts +12 -0
- package/src/execution/local.ts +2 -1
- package/src/peers/envelope.ts +36 -0
- package/src/peers/index.ts +3 -1
- package/src/peers/protocol.ts +24 -2
- 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 +290 -43
- package/src/process/session-child.ts +38 -0
- package/src/public-runtime.ts +27 -0
- package/src/public-types.ts +2 -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/runtime/system-events.ts +2 -1
- package/src/sandbox/provider/local.ts +5 -1
- package/src/sandbox/terminal.ts +3 -1
- package/src/tools/command-shell.ts +7 -1
- package/src/types/message/index.ts +1 -0
- 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
|
|
@@ -106,6 +111,7 @@ import {
|
|
|
106
111
|
SKILL_TOOL_NAME_ALIASES,
|
|
107
112
|
permissionPatternToRegExpSource,
|
|
108
113
|
} from '../authorization/skill-grant.js'
|
|
114
|
+
import { withSessionChildMarker } from '../process/session-child.js'
|
|
109
115
|
import { spawnHostShell } from '../tools/command-shell.js'
|
|
110
116
|
import type { PluginId } from '../types/ids/index.js'
|
|
111
117
|
import type {
|
|
@@ -122,6 +128,7 @@ import { RENAMED_PLUGIN_HOOK_EVENTS } from '../types/plugin/index.js'
|
|
|
122
128
|
import { asPluginId } from '../utils/id.js'
|
|
123
129
|
import { NOOP_LOGGER } from '../utils/log/create-logger.js'
|
|
124
130
|
import type { Logger } from '../utils/logger.js'
|
|
131
|
+
import { boundHookReplacement } from './hook-context.js'
|
|
125
132
|
import type { PluginLifecycleManager } from './lifecycle.js'
|
|
126
133
|
import { postHookJson } from './shell-hook-http.js'
|
|
127
134
|
|
|
@@ -180,8 +187,10 @@ interface ShellHookEntryBase {
|
|
|
180
187
|
* What this applies to. On a tool event (and `permission_request`) the tool
|
|
181
188
|
* name; on `notification` the notification type. `*` or absent matches
|
|
182
189
|
* everything; a list of names (`a|b`, `a,b`, each optionally ending in `*`
|
|
183
|
-
* as a prefix) matches those
|
|
184
|
-
*
|
|
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).
|
|
185
194
|
*/
|
|
186
195
|
readonly matcher?: string
|
|
187
196
|
/** Deadline in milliseconds; default `DEFAULT_SHELL_HOOK_TIMEOUT_MS`, capped at `MAX_SHELL_HOOK_TIMEOUT_MS`. */
|
|
@@ -201,10 +210,19 @@ interface ShellHookEntryBase {
|
|
|
201
210
|
* What the hook's own failure does: it could not start, it timed out, it
|
|
202
211
|
* exited with a code other than `0` or `2`, or it printed JSON that is not
|
|
203
212
|
* valid. `continue` (the default) reports it and lets the action go ahead;
|
|
204
|
-
* `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).
|
|
205
217
|
* Not read on a `prompt` hook or an `async` command.
|
|
206
218
|
*/
|
|
207
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
|
|
208
226
|
}
|
|
209
227
|
|
|
210
228
|
/** One shell command at one event. */
|
|
@@ -268,6 +286,51 @@ export const MAX_SHELL_HOOK_TIMEOUT_MS = 10 * 60_000
|
|
|
268
286
|
/** Captured output bound per stream. */
|
|
269
287
|
const MAX_CAPTURE_BYTES = 64 * 1024
|
|
270
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
|
+
|
|
271
334
|
/**
|
|
272
335
|
* `matcher` against a name (a tool's, or a notification's type).
|
|
273
336
|
*
|
|
@@ -275,25 +338,55 @@ const MAX_CAPTURE_BYTES = 64 * 1024
|
|
|
275
338
|
* matches exactly, each part optionally ending in `*` as a prefix (the
|
|
276
339
|
* spelling this adapter has always taken). Anything else is a regular
|
|
277
340
|
* expression, tested unanchored against the name; one that does not compile
|
|
278
|
-
* matches nothing.
|
|
279
|
-
*
|
|
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.
|
|
280
360
|
*/
|
|
281
361
|
export function shellHookMatches(
|
|
282
362
|
matcher: string | undefined,
|
|
283
363
|
toolName: string | undefined,
|
|
364
|
+
options: { readonly tool?: boolean } = {},
|
|
284
365
|
): boolean {
|
|
285
366
|
if (matcher === undefined || matcher.trim() === '' || matcher.trim() === '*') return true
|
|
286
367
|
if (toolName === undefined) return false
|
|
368
|
+
const tool = options.tool === true
|
|
287
369
|
const parts = matcher.split(/[|,]/).map((part) => part.trim())
|
|
288
370
|
if (parts.every((part) => /^[A-Za-z0-9_.:-]+\*?$/.test(part))) {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
294
385
|
}
|
|
295
386
|
try {
|
|
296
|
-
|
|
387
|
+
const expression = new RegExp(matcher)
|
|
388
|
+
if (expression.test(toolName)) return true
|
|
389
|
+
return tool && writtenNamesFor(toolName).some((name) => expression.test(name))
|
|
297
390
|
} catch {
|
|
298
391
|
return false
|
|
299
392
|
}
|
|
@@ -310,6 +403,7 @@ export function shellHookIfMatches(
|
|
|
310
403
|
filter: string | undefined,
|
|
311
404
|
toolName: string | undefined,
|
|
312
405
|
toolInput: unknown,
|
|
406
|
+
pathFilter?: ShellHookPathFilter,
|
|
313
407
|
): boolean {
|
|
314
408
|
if (filter === undefined || filter.trim() === '') return true
|
|
315
409
|
if (toolName === undefined) return true
|
|
@@ -317,10 +411,17 @@ export function shellHookIfMatches(
|
|
|
317
411
|
if (!shape) return true
|
|
318
412
|
const written = (shape[1] ?? '').toLowerCase()
|
|
319
413
|
const tool = SKILL_TOOL_NAME_ALIASES[written] ?? written
|
|
320
|
-
if (tool !== toolName.toLowerCase()) return false
|
|
321
414
|
const specifier = shape[2]?.trim()
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
324
425
|
if (tool !== 'bash') return true
|
|
325
426
|
const line =
|
|
326
427
|
toolInput !== null && typeof toolInput === 'object'
|
|
@@ -344,12 +445,28 @@ export function shellHookIfMatches(
|
|
|
344
445
|
}
|
|
345
446
|
}
|
|
346
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
|
+
|
|
347
462
|
/** What one hook run came back with, before it is mapped to a verdict. */
|
|
348
463
|
export interface ShellHookOutcome {
|
|
349
464
|
readonly exitCode: number | null
|
|
350
465
|
readonly timedOut: boolean
|
|
351
466
|
readonly stdout: string
|
|
352
467
|
readonly stderr: string
|
|
468
|
+
/** Set when the hook printed more on stdout than is kept (`MAX_CAPTURE_BYTES`). */
|
|
469
|
+
readonly stdoutTruncated?: boolean
|
|
353
470
|
/** Set when the process could not be started at all. */
|
|
354
471
|
readonly spawnError?: string
|
|
355
472
|
}
|
|
@@ -361,6 +478,12 @@ export interface ShellHookInput {
|
|
|
361
478
|
readonly sessionId: string
|
|
362
479
|
/** The turn the hook fired in. Absent on `session_start` and `session_end`. */
|
|
363
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
|
|
364
487
|
/** The delegating session, on `subagent_start` and `subagent_stop`. */
|
|
365
488
|
readonly parentSessionId?: string
|
|
366
489
|
/** The parent turn whose tool call spawned the child, on `subagent_start` and `subagent_stop`. */
|
|
@@ -402,6 +525,7 @@ export function shellHookPayload(input: ShellHookInput): string {
|
|
|
402
525
|
...(input.transcriptPath !== undefined ? { transcript_path: input.transcriptPath } : {}),
|
|
403
526
|
...(input.permissionMode !== undefined ? { permission_mode: input.permissionMode } : {}),
|
|
404
527
|
...(input.turnId !== undefined ? { turn_id: input.turnId } : {}),
|
|
528
|
+
...(input.sessionSource !== undefined ? { source: input.sessionSource } : {}),
|
|
405
529
|
...(input.parentSessionId !== undefined ? { parent_session_id: input.parentSessionId } : {}),
|
|
406
530
|
...(input.parentTurnId !== undefined ? { parent_turn_id: input.parentTurnId } : {}),
|
|
407
531
|
...(input.agentType !== undefined
|
|
@@ -428,7 +552,11 @@ export function shellHookPayload(input: ShellHookInput): string {
|
|
|
428
552
|
})),
|
|
429
553
|
}
|
|
430
554
|
: {}),
|
|
431
|
-
...(input.reason !== undefined
|
|
555
|
+
...(input.reason !== undefined
|
|
556
|
+
? { reason: input.reason }
|
|
557
|
+
: input.endReason !== undefined
|
|
558
|
+
? { reason: input.endReason }
|
|
559
|
+
: {}),
|
|
432
560
|
...(input.event === 'turn_failure' && input.error !== undefined ? { error: input.error } : {}),
|
|
433
561
|
...(input.notification !== undefined
|
|
434
562
|
? {
|
|
@@ -462,6 +590,7 @@ export function runShellHook(
|
|
|
462
590
|
...inherited,
|
|
463
591
|
NAMZU_HOOK_EVENT: input.event,
|
|
464
592
|
NAMZU_SESSION_ID: input.sessionId,
|
|
593
|
+
NAMZU_PROJECT_DIR: input.projectDir ?? input.cwd,
|
|
465
594
|
...(input.turnId !== undefined ? { NAMZU_TURN_ID: input.turnId } : {}),
|
|
466
595
|
...(input.toolName !== undefined ? { NAMZU_TOOL_NAME: input.toolName } : {}),
|
|
467
596
|
...(toolPath !== undefined ? { NAMZU_TOOL_PATH: toolPath } : {}),
|
|
@@ -478,7 +607,7 @@ export function runShellHook(
|
|
|
478
607
|
? // The exec form: no shell reads `command` or any argument.
|
|
479
608
|
spawn(entry.command, [...entry.args], {
|
|
480
609
|
cwd: input.cwd,
|
|
481
|
-
env,
|
|
610
|
+
env: withSessionChildMarker(env),
|
|
482
611
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
483
612
|
detached,
|
|
484
613
|
})
|
|
@@ -506,17 +635,24 @@ export function runShellHook(
|
|
|
506
635
|
}
|
|
507
636
|
let stdout = ''
|
|
508
637
|
let stderr = ''
|
|
638
|
+
let stdoutTruncated = false
|
|
509
639
|
let timedOut = false
|
|
510
640
|
let settled = false
|
|
511
|
-
const capture = (current: string, chunk: Buffer): string =>
|
|
512
|
-
current.length >= MAX_CAPTURE_BYTES
|
|
513
|
-
|
|
514
|
-
|
|
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
|
+
}
|
|
515
649
|
child.stdout?.on('data', (chunk: Buffer) => {
|
|
516
|
-
|
|
650
|
+
const next = capture(stdout, chunk)
|
|
651
|
+
stdout = next.text
|
|
652
|
+
if (next.cut) stdoutTruncated = true
|
|
517
653
|
})
|
|
518
654
|
child.stderr?.on('data', (chunk: Buffer) => {
|
|
519
|
-
stderr = capture(stderr, chunk)
|
|
655
|
+
stderr = capture(stderr, chunk).text
|
|
520
656
|
})
|
|
521
657
|
const finish = (outcome: ShellHookOutcome) => {
|
|
522
658
|
if (settled) return
|
|
@@ -549,11 +685,18 @@ export function runShellHook(
|
|
|
549
685
|
timedOut,
|
|
550
686
|
stdout,
|
|
551
687
|
stderr,
|
|
688
|
+
...(stdoutTruncated ? { stdoutTruncated } : {}),
|
|
552
689
|
spawnError: error.message,
|
|
553
690
|
})
|
|
554
691
|
})
|
|
555
692
|
child.on('close', (code) => {
|
|
556
|
-
finish({
|
|
693
|
+
finish({
|
|
694
|
+
exitCode: code,
|
|
695
|
+
timedOut,
|
|
696
|
+
stdout,
|
|
697
|
+
stderr,
|
|
698
|
+
...(stdoutTruncated ? { stdoutTruncated } : {}),
|
|
699
|
+
})
|
|
557
700
|
})
|
|
558
701
|
child.on('exit', () => {
|
|
559
702
|
// After a kill, a survivor that still holds the pipes must not hold
|
|
@@ -609,6 +752,8 @@ interface ShellHookReading {
|
|
|
609
752
|
context?: string
|
|
610
753
|
halt?: { reason: string }
|
|
611
754
|
notice?: string
|
|
755
|
+
/** Text that replaces a tool's result, on `post_tool_use`. */
|
|
756
|
+
replacement?: string
|
|
612
757
|
/** The hook asked for its stdout to stay out of the debug log. */
|
|
613
758
|
suppressOutput?: boolean
|
|
614
759
|
/** Why the JSON was set aside, when it was. */
|
|
@@ -727,6 +872,12 @@ function readShellHookJson(event: ShellHookEvent, stdout: string): ShellHookRead
|
|
|
727
872
|
reading.updatedInput = specific.updatedInput
|
|
728
873
|
}
|
|
729
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
|
+
}
|
|
730
881
|
if (event === 'permission_request') {
|
|
731
882
|
const decision = specific.decision
|
|
732
883
|
if (!isRecord(decision)) {
|
|
@@ -764,15 +915,30 @@ export function shellHookVerdict(
|
|
|
764
915
|
}
|
|
765
916
|
// A failure of the hook itself is "carry on" unless the entry asked for
|
|
766
917
|
// it to count as a refusal, which is exit 2 with the failure as the reason.
|
|
767
|
-
const failed = (reason: string): PluginHookResult =>
|
|
768
|
-
entry.onFailure
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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
|
+
}
|
|
776
942
|
if (outcome.spawnError !== undefined) {
|
|
777
943
|
log.warn('hook could not start', {
|
|
778
944
|
...attrs,
|
|
@@ -821,12 +987,20 @@ export function shellHookVerdict(
|
|
|
821
987
|
|
|
822
988
|
// Exit 0.
|
|
823
989
|
const text = outcome.stdout.trim()
|
|
824
|
-
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)
|
|
825
996
|
if (reading === undefined) {
|
|
826
997
|
// On the prompt event, what the hook printed is context for the
|
|
827
998
|
// model — the branch, the time, a ticket. Everywhere else stdout is
|
|
828
999
|
// the operator's to read and the model's business not at all.
|
|
829
|
-
if (
|
|
1000
|
+
if (
|
|
1001
|
+
(event === 'user_prompt_submit' || event === 'subagent_start' || event === 'session_start') &&
|
|
1002
|
+
text.length > 0
|
|
1003
|
+
)
|
|
830
1004
|
return { action: 'annotate', text }
|
|
831
1005
|
return { action: 'continue' }
|
|
832
1006
|
}
|
|
@@ -835,7 +1009,9 @@ export function shellHookVerdict(
|
|
|
835
1009
|
...attrs,
|
|
836
1010
|
'namzu.hook.problem': reading.invalid,
|
|
837
1011
|
})
|
|
838
|
-
|
|
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])
|
|
839
1015
|
}
|
|
840
1016
|
if (!reading.suppressOutput) {
|
|
841
1017
|
log.debug('hook output', {
|
|
@@ -884,11 +1060,34 @@ export function shellHookVerdict(
|
|
|
884
1060
|
const context = [reading.block, reading.context].filter(Boolean).join('\n')
|
|
885
1061
|
return context.length > 0 ? { action: 'annotate', text: context } : { action: 'continue' }
|
|
886
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
|
+
}
|
|
887
1074
|
case 'post_tool_use':
|
|
888
1075
|
case 'post_tool_use_failure': {
|
|
889
1076
|
// The call has run, so a "block" cannot undo it: its reason is what the
|
|
890
1077
|
// model is told about the result.
|
|
891
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
|
+
}
|
|
892
1091
|
return {
|
|
893
1092
|
action: 'continue',
|
|
894
1093
|
...(context.length > 0 ? { context } : {}),
|
|
@@ -985,6 +1184,28 @@ export interface ShellHookOptions {
|
|
|
985
1184
|
readonly transcriptPath?: (sessionId: string) => string | undefined
|
|
986
1185
|
/** The permission mode in force now, sent as `permission_mode`. Called at each run. */
|
|
987
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
|
|
988
1209
|
}
|
|
989
1210
|
|
|
990
1211
|
/** The deadline for `prompt` hooks when they name none. */
|
|
@@ -1086,10 +1307,19 @@ function readJudgement(reply: string): { ok: boolean; reason: string } | undefin
|
|
|
1086
1307
|
/** One entry as a hook definition the lifecycle manager accepts. */
|
|
1087
1308
|
export function createShellHook(
|
|
1088
1309
|
event: ShellHookEvent,
|
|
1089
|
-
|
|
1310
|
+
configured: ShellHookHandler,
|
|
1090
1311
|
options: ShellHookOptions,
|
|
1091
1312
|
): PluginHookDefinition {
|
|
1092
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
|
+
}
|
|
1093
1323
|
// `once` is per definition, and a definition is per session: the manager
|
|
1094
1324
|
// is built for one session and its hooks are attached to it.
|
|
1095
1325
|
let spent = false
|
|
@@ -1101,14 +1331,26 @@ export function createShellHook(
|
|
|
1101
1331
|
handler: async (context: PluginHookContext) => {
|
|
1102
1332
|
if (spent) return { action: 'continue' }
|
|
1103
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
|
+
)
|
|
1104
1341
|
const subject =
|
|
1105
1342
|
event === 'notification'
|
|
1106
1343
|
? context.notification?.type
|
|
1107
1344
|
: event === 'subagent_start' || event === 'subagent_stop'
|
|
1108
1345
|
? context.agentType
|
|
1109
|
-
:
|
|
1110
|
-
|
|
1111
|
-
|
|
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)) {
|
|
1112
1354
|
return { action: 'continue' }
|
|
1113
1355
|
}
|
|
1114
1356
|
if (entry.once === true) spent = true
|
|
@@ -1117,7 +1359,10 @@ export function createShellHook(
|
|
|
1117
1359
|
const hookInput: ShellHookInput = {
|
|
1118
1360
|
event,
|
|
1119
1361
|
cwd: options.cwd,
|
|
1362
|
+
...(options.projectDir !== undefined ? { projectDir: options.projectDir } : {}),
|
|
1120
1363
|
sessionId: String(context.sessionId),
|
|
1364
|
+
...(context.source !== undefined ? { sessionSource: context.source } : {}),
|
|
1365
|
+
...(context.endReason !== undefined ? { endReason: context.endReason } : {}),
|
|
1121
1366
|
...(context.turnId !== undefined ? { turnId: String(context.turnId) } : {}),
|
|
1122
1367
|
...(context.parentSessionId !== undefined
|
|
1123
1368
|
? { parentSessionId: String(context.parentSessionId) }
|
|
@@ -1158,6 +1403,7 @@ export function createShellHook(
|
|
|
1158
1403
|
timeoutMs: hookTimeout(entry),
|
|
1159
1404
|
...(context.signal !== undefined ? { signal: context.signal } : {}),
|
|
1160
1405
|
})
|
|
1406
|
+
reportTimeout(response.timedOut)
|
|
1161
1407
|
const ok = response.status !== null && response.status >= 200 && response.status < 300
|
|
1162
1408
|
return shellHookVerdict(
|
|
1163
1409
|
event,
|
|
@@ -1186,6 +1432,7 @@ export function createShellHook(
|
|
|
1186
1432
|
return { action: 'continue' }
|
|
1187
1433
|
}
|
|
1188
1434
|
const outcome = await runShellHook(entry, hookInput, context.signal)
|
|
1435
|
+
reportTimeout(outcome.timedOut)
|
|
1189
1436
|
return shellHookVerdict(event, entry, outcome, log)
|
|
1190
1437
|
},
|
|
1191
1438
|
} as PluginHookDefinition
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readEnvironmentEntry, setEnvironmentEntry } from '../utils/process-environment.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The name of the variable the kernel puts into the environment of every process it starts on a
|
|
5
|
+
* session's behalf: a command the model ran, a hook, a tool server, a delegated or background run's
|
|
6
|
+
* command, a terminal a session opened.
|
|
7
|
+
*
|
|
8
|
+
* It says one thing: this process, or one of its ancestors, was started by a running session, not by a
|
|
9
|
+
* person at their own terminal. `namzu send` reads it and refuses, because a message to another session
|
|
10
|
+
* is a person's, and a command a model ran is not a person.
|
|
11
|
+
*
|
|
12
|
+
* It is a marker and not a lock. Any process of the same account can start another with a cleaner
|
|
13
|
+
* environment (`env -i`), so the kernel does not rely on it alone: the command-line permission layer
|
|
14
|
+
* refuses the same command before it runs, and the receiving terminal checks the sender's process
|
|
15
|
+
* ancestry. What a process of the same account can do after hiding both is the account boundary's to
|
|
16
|
+
* hold, not this marker's.
|
|
17
|
+
*/
|
|
18
|
+
export const SESSION_CHILD_ENV = 'NAMZU_STARTED_BY_SESSION'
|
|
19
|
+
|
|
20
|
+
/** A copy of `env` that carries the marker. Everywhere the kernel builds a child's environment ends here. */
|
|
21
|
+
export function withSessionChildMarker<T extends Readonly<Record<string, string | undefined>>>(
|
|
22
|
+
env: T,
|
|
23
|
+
platform: NodeJS.Platform = process.platform,
|
|
24
|
+
): Record<string, string | undefined> {
|
|
25
|
+
const out: Record<string, string> = {}
|
|
26
|
+
for (const [name, value] of Object.entries(env)) if (value !== undefined) out[name] = value
|
|
27
|
+
setEnvironmentEntry(out, SESSION_CHILD_ENV, '1', platform)
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Whether `env` carries the marker: this process was started, directly or not, by a running session. */
|
|
32
|
+
export function startedBySession(
|
|
33
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
34
|
+
platform: NodeJS.Platform = process.platform,
|
|
35
|
+
): boolean {
|
|
36
|
+
const found = readEnvironmentEntry(env, SESSION_CHILD_ENV, platform)
|
|
37
|
+
return found !== undefined && found[1] !== '' && found[1] !== '0'
|
|
38
|
+
}
|