返回 DeepSeek-Reasonix
hook.go
根目录 / internal / hook / hook.go
1 // Package hook runs user-configured shell-command hooks around the agent loop:
2 // PreToolUse / PostToolUse fire around each tool call, PermissionRequest fires
3 // before a tool approval prompt is shown, UserPromptSubmit before a turn, Stop
4 // after it. Hooks come from settings.json — a project
5 // (.reasonix/settings.json, only when the project is trusted) and a global
6 // (<Reasonix home>/settings.json) file. A hook's exit
7 // code is its verdict: 0 = pass, 2 = block (only on the gating events), other =
8 // warn. The payload is delivered as JSON on stdin; output is captured (capped)
9 // and surfaced to the user. This package only loads, matches, and runs hooks;
10 // the agent and controller decide what a block means (see internal/agent,
11 // internal/control).
12 package hook
13
14 import (
15 "bytes"
16 "context"
17 "encoding/base64"
18 "encoding/json"
19 "errors"
20 "fmt"
21 "os"
22 "os/exec"
23 "path/filepath"
24 "regexp"
25 "runtime"
26 "sort"
27 "strings"
28 "time"
29 "unicode/utf16"
30
31 "reasonix/internal/config"
32 fileencoding "reasonix/internal/fileutil/encoding"
33 "reasonix/internal/pluginpkg"
34 "reasonix/internal/proc"
35 "reasonix/internal/sandbox"
36 "reasonix/internal/secrets"
37 )
38
39 // Event is a point in the agent loop a hook can fire at.
40 type Event string
41
42 const (
43 PreToolUse Event = "PreToolUse"
44 PostToolUse Event = "PostToolUse"
45 PostToolUseFailure Event = "PostToolUseFailure"
46 PermissionRequest Event = "PermissionRequest"
47 UserPromptSubmit Event = "UserPromptSubmit"
48 Stop Event = "Stop"
49 StopFailure Event = "StopFailure"
50 // PostLLMCall fires after every model turn completes (streaming finishes) but
51 // before the reasoning_content is stored in the session. The hook receives the
52 // raw reasoning text in the payload; its stdout, if non-empty on exit 0,
53 // replaces the reasoning stored and displayed to the user. It can't block — a
54 // non-zero exit or empty stdout leaves the reasoning unchanged.
55 PostLLMCall Event = "PostLLMCall"
56 // SessionStart fires once when a session becomes active (fresh, resumed, or
57 // after /new). SessionEnd fires when it is closed or rotated. SubagentStop
58 // fires when a `task` sub-agent finishes. Notification fires when the agent
59 // needs the user's attention (e.g. a pending approval). PreCompact fires just
60 // before a compaction pass; its stdout is injected as extra summary guidance.
61 SessionStart Event = "SessionStart"
62 SessionEnd Event = "SessionEnd"
63 SubagentStop Event = "SubagentStop"
64 Notification Event = "Notification"
65 PreCompact Event = "PreCompact"
66 )
67
68 // Events is every event, in a stable order — drives loading and `/hooks`.
69 var Events = []Event{
70 PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, UserPromptSubmit, Stop, StopFailure,
71 PostLLMCall,
72 SessionStart, SessionEnd, SubagentStop, Notification, PreCompact,
73 }
74
75 // IsBlocking reports whether a non-zero/exit-2 (or timed-out) hook on this event
76 // can block the loop. Only the gating events qualify. (PreCompact does not block;
77 // it only contributes guidance via stdout.) This governs native Reasonix hooks;
78 // see claudePermissionBlocking for the Claude-imported PermissionRequest case.
79 func IsBlocking(e Event) bool { return e == PreToolUse || e == UserPromptSubmit }
80
81 // claudePermissionBlocking reports whether exit code 2 (or a timeout) on h
82 // aborts the action even though PermissionRequest is not one of Reasonix's own
83 // blocking events (docs/DESKTOP_HOOKS.md: "只有 PreToolUse 和 UserPromptSubmit
84 // 是阻塞型事件"). Claude's own PermissionRequest contract denies the permission
85 // on exit 2 the same way PreToolUse does (https://code.claude.com/docs/en/hooks),
86 // so an imported Claude hook (PayloadFormat "claude") honors that instead of
87 // silently downgrading to a notification.
88 func claudePermissionBlocking(h ResolvedHook) bool {
89 return h.Event == PermissionRequest && h.PayloadFormat == "claude"
90 }
91
92 // defaultTimeout is the per-event timeout when a hook sets none. Tool/prompt
93 // hooks gate progress, so they're tight; post/stop hooks get more room.
94 func defaultTimeout(e Event) time.Duration {
95 switch e {
96 case PreToolUse, PermissionRequest, UserPromptSubmit:
97 return 5 * time.Second
98 default:
99 return 30 * time.Second
100 }
101 }
102
103 // Scope records which settings.json a hook came from. Project hooks fire before
104 // global ones.
105 type Scope string
106
107 const (
108 ScopeProject Scope = "project"
109 ScopePlugin Scope = "plugin"
110 ScopeGlobal Scope = "global"
111 )
112
113 // ExecutionMode is the contract between a hook manifest and its process
114 // launcher. The zero value is the legacy Reasonix settings behavior, where a
115 // command string is interpreted by the platform shell after compatibility
116 // repairs. Plugin manifests can opt into an unambiguous exec or shell form.
117 type ExecutionMode string
118
119 const (
120 ExecutionLegacy ExecutionMode = ""
121 ExecutionExec ExecutionMode = "exec"
122 ExecutionShell ExecutionMode = "shell"
123 )
124
125 // HookConfig is one hook as written in settings.json.
126 type HookConfig struct {
127 // Match is an anchored regex selecting tools (Pre/PostToolUse and
128 // PermissionRequest only); "" or "*" = every tool. Anchored: "file" won't
129 // match "read_file" — use ".*file".
130 Match string `json:"match,omitempty"`
131 // Command is the executable, shell script, or legacy shell command to run,
132 // according to ExecutionMode.
133 Command string `json:"command"`
134 // Argv is the literal argument vector for exec-form plugin hooks.
135 Argv []string `json:"-"`
136 // ExecutionMode and Shell are internal plugin-package metadata. Native
137 // Reasonix settings retain their legacy shell-command behavior.
138 ExecutionMode ExecutionMode `json:"-"`
139 Shell string `json:"-"`
140 // ContextFile is an internal plugin-package helper: when set, the hook reads
141 // this file and treats it as stdout instead of spawning a shell command.
142 ContextFile string `json:"contextFile,omitempty"`
143 // Description is an optional human label surfaced in `/hooks`.
144 Description string `json:"description,omitempty"`
145 // Timeout overrides the per-event default, in milliseconds.
146 Timeout int `json:"timeout,omitempty"`
147 // Cwd overrides the working directory (defaults to the payload's cwd).
148 Cwd string `json:"cwd,omitempty"`
149 // Env adds environment variables for this hook invocation.
150 Env map[string]string `json:"env,omitempty"`
151 // Async and PayloadFormat are internal compatibility metadata populated for
152 // imported Claude hooks. Native Reasonix settings keep their old behavior.
153 Async bool `json:"-"`
154 PayloadFormat string `json:"-"`
155 }
156
157 // Settings is the shape of a settings.json (only hooks for now).
158 type Settings struct {
159 Hooks map[Event][]HookConfig `json:"hooks"`
160 }
161
162 // ResolvedHook is a loaded hook with its origin baked in.
163 type ResolvedHook struct {
164 HookConfig
165 Event Event
166 Scope Scope
167 Source string // absolute path to the settings.json it came from
168 }
169
170 func (h ResolvedHook) timeout() time.Duration {
171 if h.Timeout > 0 {
172 return time.Duration(h.Timeout) * time.Millisecond
173 }
174 return defaultTimeout(h.Event)
175 }
176
177 // SettingsDirname / SettingsFilename locate a scope's settings.json.
178 const (
179 SettingsDirname = ".reasonix"
180 SettingsFilename = "settings.json"
181 )
182
183 // GlobalSettingsPath is <Reasonix home>/settings.json (homeDir overrides ~ for
184 // tests and legacy callers).
185 func GlobalSettingsPath(homeDir string) string {
186 return filepath.Join(reasonixHome(homeDir), SettingsFilename)
187 }
188
189 // ProjectSettingsPath is <root>/.reasonix/settings.json.
190 func ProjectSettingsPath(projectRoot string) string {
191 return filepath.Join(projectRoot, SettingsDirname, SettingsFilename)
192 }
193
194 // ContextFileUsable reports whether a plugin contextFile can take the same
195 // execution path as readContextFile. Keep machine status and diagnostics on
196 // this shared predicate so a path that merely exists (for example, a
197 // directory) is not advertised as runnable.
198 func ContextFileUsable(path string) bool {
199 path = strings.TrimSpace(path)
200 if path == "" {
201 return false
202 }
203 info, err := os.Stat(path)
204 if err != nil || !info.Mode().IsRegular() {
205 return false
206 }
207 file, err := os.Open(path)
208 if err != nil {
209 return false
210 }
211 return file.Close() == nil
212 }
213
214 // LoadOptions configure Load.
215 type LoadOptions struct {
216 ProjectRoot string
217 // HomeDir overrides the OS user home used by legacy callers and tests. The
218 // derived global path is <HomeDir>/.reasonix unless ReasonixHomeDir is set.
219 HomeDir string
220 // ReasonixHomeDir is the exact current Reasonix home (settings.json lives
221 // directly under it). When set, it takes precedence over HomeDir for global
222 // settings and plugin hooks so Windows %APPDATA%/reasonix and REASONIX_HOME
223 // isolation stay consistent across hook/doctor/capdiag (#7411, #7331).
224 ReasonixHomeDir string
225 // Trusted is retained for source compatibility. Project hooks are enabled
226 // automatically now, so callers no longer need to set it.
227 Trusted bool
228 }
229
230 // Load resolves hooks: project first, then global; within a scope,
231 // settings.json array order. A malformed file yields no hooks (never an error
232 // — a typo shouldn't take down the CLI).
233 func Load(opts LoadOptions) []ResolvedHook {
234 var out []ResolvedHook
235 if opts.ProjectRoot != "" {
236 p := ProjectSettingsPath(opts.ProjectRoot)
237 if s := readSettings(p); s != nil {
238 appendResolved(&out, s, ScopeProject, p)
239 }
240 }
241 reasonixHomeDir := reasonixHomeForOptions(opts)
242 appendPluginHooks(&out, reasonixHomeDir, opts.ProjectRoot)
243 g := filepath.Join(reasonixHomeDir, SettingsFilename)
244 if reasonixHomeDir == "" {
245 g = GlobalSettingsPath(opts.HomeDir)
246 }
247 if s := readSettings(g); s != nil {
248 appendResolved(&out, s, ScopeGlobal, g)
249 } else if !pathExists(g) {
250 if legacy := legacyGlobalSettingsPath(opts.HomeDir); legacy != "" {
251 if s := readSettings(legacy); s != nil {
252 appendResolved(&out, s, ScopeGlobal, legacy)
253 }
254 }
255 }
256 return out
257 }
258
259 // ProjectDefinesHooks reports whether a project's settings.json exists and
260 // declares at least one hook.
261 func ProjectDefinesHooks(projectRoot string) bool {
262 s := readSettings(ProjectSettingsPath(projectRoot))
263 if s == nil {
264 return false
265 }
266 for _, e := range Events {
267 for _, cfg := range s.Hooks[e] {
268 if strings.TrimSpace(cfg.Command) != "" {
269 return true
270 }
271 }
272 }
273 return false
274 }
275
276 func readSettings(path string) *Settings {
277 b, err := fileencoding.ReadFileUTF8(path)
278 if err != nil {
279 return nil
280 }
281 var s Settings
282 if err := json.Unmarshal(b, &s); err != nil {
283 return nil // malformed → treat as no hooks, don't crash
284 }
285 return &s
286 }
287
288 func pathExists(path string) bool {
289 if strings.TrimSpace(path) == "" {
290 return false
291 }
292 _, err := os.Stat(path)
293 return err == nil || !os.IsNotExist(err)
294 }
295
296 func appendResolved(out *[]ResolvedHook, s *Settings, scope Scope, source string) {
297 if s.Hooks == nil {
298 return
299 }
300 for _, event := range Events {
301 for _, cfg := range s.Hooks[event] {
302 if strings.TrimSpace(cfg.Command) == "" {
303 continue
304 }
305 cfg.Command = NormalizeCommand(cfg.Command)
306 *out = append(*out, ResolvedHook{HookConfig: cfg, Event: event, Scope: scope, Source: source})
307 }
308 }
309 }
310
311 func appendPluginHooks(out *[]ResolvedHook, reasonixHomeDir, projectRoot string) {
312 if strings.TrimSpace(reasonixHomeDir) == "" {
313 return
314 }
315 installed, _ := pluginpkg.LoadInstalled(reasonixHomeDir)
316 for _, item := range installed {
317 pkg := item.Package
318 events := make([]string, 0, len(pkg.Manifest.Hooks))
319 for event := range pkg.Manifest.Hooks {
320 events = append(events, event)
321 }
322 sort.Strings(events)
323 for _, eventName := range events {
324 event := Event(eventName)
325 if !validEvent(event) {
326 continue
327 }
328 for _, h := range pkg.Manifest.Hooks[eventName] {
329 execution := pluginHookExecutionConfig(h, pkg.Root)
330 contextFile := expandPluginRoot(h.ContextFile, pkg.Root)
331 if contextFile != "" {
332 contextFile = filepath.FromSlash(contextFile)
333 if !filepath.IsAbs(contextFile) {
334 contextFile = filepath.Join(pkg.Root, contextFile)
335 } else {
336 contextFile = filepath.Clean(contextFile)
337 }
338 }
339 cwd := expandPluginRoot(h.Cwd, pkg.Root)
340 if cwd == "" {
341 cwd = pkg.Root
342 } else {
343 cwd = filepath.FromSlash(cwd)
344 if !filepath.IsAbs(cwd) {
345 cwd = filepath.Join(pkg.Root, cwd)
346 } else {
347 cwd = filepath.Clean(cwd)
348 }
349 }
350 env := cloneEnv(h.Env)
351 for key, value := range env {
352 env[key] = expandPluginRoot(value, pkg.Root)
353 }
354 env["REASONIX_PLUGIN_ROOT"] = pkg.Root
355 env["REASONIX_PLUGIN_NAME"] = item.Installed.Name
356 env["REASONIX_HOME"] = reasonixHomeDir
357 env["REASONIX_WORKSPACE_ROOT"] = projectRoot
358 env["CLAUDE_PROJECT_DIR"] = projectRoot
359 env["CLAUDE_PLUGIN_ROOT"] = pkg.Root
360 if item.Installed.Version != "" {
361 env["REASONIX_PLUGIN_VERSION"] = item.Installed.Version
362 }
363 *out = append(*out, ResolvedHook{
364 HookConfig: HookConfig{
365 Match: h.Match,
366 Command: execution.Command,
367 Argv: execution.Argv,
368 ExecutionMode: execution.ExecutionMode,
369 Shell: h.Shell,
370 ContextFile: contextFile,
371 Description: h.Description,
372 Timeout: h.Timeout,
373 Cwd: cwd,
374 Env: env,
375 Async: h.Async,
376 PayloadFormat: h.PayloadFormat,
377 },
378 Event: event,
379 Scope: ScopePlugin,
380 Source: filepath.Join(pkg.Root, pluginpkg.ManifestPath(pkg.ManifestKind)),
381 })
382 }
383 }
384 }
385 }
386
387 func pluginHookExecutionConfig(h pluginpkg.Hook, root string) HookConfig {
388 return pluginHookExecutionConfigForPlatform(h, root, runtime.GOOS)
389 }
390
391 func pluginHookExecutionConfigForPlatform(h pluginpkg.Hook, root, goos string) HookConfig {
392 mode := ExecutionLegacy
393 switch {
394 case h.ArgsSet:
395 mode = ExecutionExec
396 case h.ShellCommand:
397 mode = ExecutionShell
398 }
399 expansionRoot := root
400 if goos == "windows" && mode == ExecutionShell && strings.EqualFold(strings.TrimSpace(h.Shell), "bash") {
401 expansionRoot = strings.ReplaceAll(root, `\`, "/")
402 }
403 command := expandPluginRoot(h.Command, expansionRoot)
404 resolveFromPluginRoot := mode != ExecutionShell &&
405 !(mode == ExecutionExec && h.PayloadFormat == "claude")
406 if command != "" && resolveFromPluginRoot && !filepath.IsAbs(command) {
407 command = filepath.Join(root, filepath.FromSlash(command))
408 }
409 if mode == ExecutionLegacy {
410 command = NormalizeCommand(command)
411 }
412 var argv []string
413 if h.ArgsSet {
414 argv = make([]string, 0, len(h.Args))
415 }
416 for _, arg := range h.Args {
417 argv = append(argv, expandPluginRoot(arg, root))
418 }
419 return HookConfig{
420 Command: command,
421 Argv: argv,
422 ExecutionMode: mode,
423 Shell: h.Shell,
424 }
425 }
426
427 func expandPluginRoot(value, root string) string {
428 // Plugin hook manifests are host configuration, not platform-native shell
429 // scripts. Scan the manifest value once so text inside the resolved root is
430 // never mistaken for another placeholder and expanded recursively.
431 lastWrite := 0
432 replaced := false
433 var out strings.Builder
434 for i := 0; i < len(value); {
435 tokenLen := pluginRootTokenLen(value[i:])
436 if tokenLen == 0 {
437 i++
438 continue
439 }
440 if !replaced {
441 out.Grow(len(value) - tokenLen + len(root))
442 replaced = true
443 }
444 out.WriteString(value[lastWrite:i])
445 out.WriteString(root)
446 i += tokenLen
447 lastWrite = i
448 }
449 if !replaced {
450 return value
451 }
452 out.WriteString(value[lastWrite:])
453 return out.String()
454 }
455
456 var pluginRootTokens = [...]struct {
457 value string
458 needsBoundary bool
459 }{
460 {value: "${CLAUDE_PLUGIN_ROOT}"},
461 {value: "$CLAUDE_PLUGIN_ROOT", needsBoundary: true},
462 {value: "%CLAUDE_PLUGIN_ROOT%"},
463 {value: "${REASONIX_PLUGIN_ROOT}"},
464 {value: "$REASONIX_PLUGIN_ROOT", needsBoundary: true},
465 {value: "%REASONIX_PLUGIN_ROOT%"},
466 }
467
468 func pluginRootTokenLen(value string) int {
469 for _, token := range pluginRootTokens {
470 if !strings.HasPrefix(value, token.value) {
471 continue
472 }
473 if token.needsBoundary && len(value) > len(token.value) && isShellVariableNameByte(value[len(token.value)]) {
474 continue
475 }
476 return len(token.value)
477 }
478 return 0
479 }
480
481 func isShellVariableNameByte(c byte) bool {
482 return c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9'
483 }
484
485 func validEvent(event Event) bool {
486 for _, e := range Events {
487 if e == event {
488 return true
489 }
490 }
491 return false
492 }
493
494 func cloneEnv(in map[string]string) map[string]string {
495 out := map[string]string{}
496 for k, v := range in {
497 if strings.TrimSpace(k) != "" {
498 out[k] = v
499 }
500 }
501 return out
502 }
503
504 // MatchesTool reports whether a hook applies to toolName. The match field is an
505 // anchored regex; non-tool events always match. A malformed regex never fires
506 // (safer than firing on everything).
507 func MatchesTool(h ResolvedHook, toolName string) bool {
508 if !UsesToolMatcher(h.Event) {
509 return true
510 }
511 m := h.Match
512 if m == "" || m == "*" {
513 return true
514 }
515 re, err := regexp.Compile("^(?:" + m + ")$")
516 if err != nil {
517 return false
518 }
519 if h.PayloadFormat != "claude" {
520 return re.MatchString(toolName)
521 }
522 for _, candidate := range claudeMatchNames(toolName) {
523 if re.MatchString(candidate) {
524 return true
525 }
526 }
527 return false
528 }
529
530 // claudeAgentSpawningTools are every Reasonix tool that spawns a subagent and
531 // so corresponds to Claude's single "Agent" tool: the general task delegator
532 // (task/read_only_task/parallel_tasks) and the dedicated named wrappers
533 // around a runAs=subagent skill (BuiltinSubagentTools in
534 // internal/skill/tools.go — each is a distinct, directly-callable tool, not
535 // routed through run_skill). A Claude "Agent" safety matcher must see all of
536 // them, or a hook scoped to it silently misses whichever entry point wasn't
537 // mapped.
538 var claudeAgentSpawningTools = []string{
539 "task", "read_only_task", "parallel_tasks",
540 "explore", "research", "review", "security_review",
541 }
542
543 // claudeAgentDefaultDescriptions fill Claude Agent's required description
544 // field when the corresponding Reasonix tool does not expose one or the model
545 // omitted Reasonix's optional description. These are stable operation labels;
546 // the complete task remains in prompt for hook policy decisions.
547 var claudeAgentDefaultDescriptions = map[string]string{
548 "task": "Run delegated subagent task",
549 "read_only_task": "Run read-only research task",
550 "parallel_tasks": "Run parallel subagent tasks",
551 "explore": "Explore the codebase",
552 "research": "Research external references",
553 "review": "Review the current changes",
554 "security_review": "Review security risks",
555 }
556
557 // claudeToolNames maps Reasonix's own tool names to the *current* Claude Code
558 // built-in tool name (https://code.claude.com/docs/en/tools-reference) — what
559 // an imported hook's emitted tool_name payload field shows, and a script's own
560 // tool_name check is written against. MCP tool names already share the
561 // mcp__<server>__<tool> convention in both systems.
562 var claudeToolNames = buildClaudeToolNames()
563
564 func buildClaudeToolNames() map[string]string {
565 out := map[string]string{
566 "bash": "Bash",
567 "read_file": "Read",
568 "write_file": "Write",
569 "edit_file": "Edit",
570 "multi_edit": "MultiEdit",
571 "glob": "Glob",
572 "grep": "Grep",
573 "web_fetch": "WebFetch",
574 "ask": "AskUserQuestion",
575 "run_skill": "Skill",
576 "read_only_skill": "Skill",
577 "todo_write": "TodoWrite",
578 "notebook_edit": "NotebookEdit",
579 "bash_output": "TaskOutput",
580 "wait": "TaskOutput",
581 "kill_shell": "TaskStop",
582 }
583 for _, name := range claudeAgentSpawningTools {
584 out[name] = "Agent"
585 }
586 return out
587 }
588
589 // claudeToolMatchAliases lists every tool name — current and legacy — an
590 // imported hook's matcher may have been authored against for a Reasonix
591 // tool, so a matcher written against an older Claude Code tool name keeps
592 // firing after Claude renames the tool (Task became Agent; BashOutput/KillShell
593 // became TaskOutput/TaskStop). claudeFacingToolName (the emitted tool_name
594 // payload) always reports the current name; only matcher evaluation considers
595 // aliases.
596 var claudeToolMatchAliases = buildClaudeToolMatchAliases()
597
598 func buildClaudeToolMatchAliases() map[string][]string {
599 out := map[string][]string{}
600 for _, name := range claudeAgentSpawningTools {
601 out[name] = []string{"Agent", "Task"}
602 }
603 out["bash_output"] = []string{"TaskOutput", "BashOutput"}
604 out["wait"] = []string{"TaskOutput", "BashOutput"}
605 out["kill_shell"] = []string{"TaskStop", "KillShell"}
606 return out
607 }
608
609 // claudeMatchNames returns every name an imported hook's matcher should be
610 // tried against for a Reasonix tool call.
611 func claudeMatchNames(name string) []string {
612 if aliases, ok := claudeToolMatchAliases[name]; ok {
613 return aliases
614 }
615 return []string{claudeFacingToolName(name)}
616 }
617
618 // claudeFacingToolName returns the current Claude tool name a Claude-imported
619 // hook's tool_name payload field should see for a Reasonix tool call.
620 // Reasonix-only tools (wait, code_index, move_file, ...) have no Claude
621 // equivalent and pass through unchanged — an imported hook can't have been
622 // authored against a name Claude never had.
623 func claudeFacingToolName(name string) string {
624 if mapped, ok := claudeToolNames[name]; ok {
625 return mapped
626 }
627 return name
628 }
629
630 // claudeToolInputKeyRenames maps, per Reasonix tool name, JSON keys in its
631 // tool-call arguments that must be renamed to Claude's own tool_input field
632 // name — Reasonix's file tools use "path", Claude's use "file_path" — so a
633 // hook script reading e.g. ".tool_input.file_path" sees the value instead of
634 // failing open on an empty field. Only tools whose Reasonix schema differs
635 // from Claude's by a plain key rename are listed: Bash's "command",
636 // Glob/Grep's "pattern"/"path", web_fetch's "url", ask's "questions",
637 // todo_write's "todos", and task/read_only_task's "prompt"/"description"
638 // already use Claude's field names. Agent description can still be absent and
639 // is filled separately below. NotebookEdit's cell_number (a
640 // 0-based index) has no Claude field — Claude targets cells only by the
641 // opaque cell_id, which Reasonix also accepts — so it passes through as an
642 // extra key. parallel_tasks is a structural mismatch handled separately in
643 // claudeFacingToolInput.
644 var claudeToolInputKeyRenames = map[string]map[string]string{
645 "read_file": {"path": "file_path"},
646 "write_file": {"path": "file_path"},
647 "edit_file": {"path": "file_path"},
648 "multi_edit": {"path": "file_path"},
649 "notebook_edit": {"path": "notebook_path"},
650 "run_skill": {"name": "skill", "arguments": "args"},
651 "read_only_skill": {"name": "skill", "arguments": "args"},
652 "bash_output": {"job_id": "task_id"},
653 "kill_shell": {"job_id": "task_id"},
654 // The dedicated subagent wrappers take their task text as "task";
655 // Claude's Agent tool calls the same thing "prompt".
656 "explore": {"task": "prompt"},
657 "research": {"task": "prompt"},
658 "review": {"task": "prompt"},
659 "security_review": {"task": "prompt"},
660 }
661
662 // claudeAbsolutePathInputKeys are the translated tool_input keys whose Claude
663 // schema demands an absolute path ("must be absolute, not relative" on
664 // Read/Write/Edit/NotebookEdit). Reasonix's file tools accept relative paths
665 // and resolve them against the workspace root (resolveIn in
666 // internal/tool/builtin/workspace.go); the payload resolves against
667 // payload.Cwd — the same root — so a prefix-matching guard inspects the path
668 // the tool actually accesses, not a relative spelling it never compares.
669 var claudeAbsolutePathInputKeys = []string{"file_path", "notebook_path"}
670
671 // claudeFacingToolInput adapts tool-call arguments to the tool_input a
672 // Claude-authored hook script was written against: keys are renamed per
673 // claudeToolInputKeyRenames, file paths are made absolute, current TaskOutput
674 // fields and required Agent/AskUserQuestion/TodoWrite fields are supplied, and
675 // parallel_tasks synthesizes Agent's "prompt". Args needing no translation, or
676 // that aren't a JSON object, pass through unchanged.
677 func claudeFacingToolInput(toolName string, args json.RawMessage, cwd string) json.RawMessage {
678 renames := claudeToolInputKeyRenames[toolName]
679 defaultAgentDescription, isAgent := claudeAgentDefaultDescriptions[toolName]
680 if len(renames) == 0 && !isAgent && toolName != "ask" && toolName != "todo_write" && toolName != "wait" {
681 return args
682 }
683 if len(args) == 0 {
684 return args
685 }
686 var obj map[string]json.RawMessage
687 if err := json.Unmarshal(args, &obj); err != nil {
688 return args
689 }
690 changed := false
691 for from, to := range renames {
692 if v, exists := obj[from]; exists {
693 obj[to] = v
694 delete(obj, from)
695 changed = true
696 }
697 }
698 if toolName == "notebook_edit" {
699 if _, exists := obj["new_source"]; !exists {
700 for _, alias := range []string{"content", "source", "new_string"} {
701 var value string
702 if err := json.Unmarshal(obj[alias], &value); err == nil && value != "" {
703 obj["new_source"] = obj[alias]
704 break
705 }
706 }
707 if _, exists := obj["new_source"]; !exists {
708 obj["new_source"] = json.RawMessage(`""`)
709 }
710 changed = true
711 }
712 }
713 if toolName == "bash_output" {
714 obj["block"] = json.RawMessage("false")
715 obj["timeout"] = json.RawMessage("0")
716 changed = true
717 }
718 if toolName == "wait" {
719 obj["block"] = json.RawMessage("true")
720 var jobIDs []string
721 if err := json.Unmarshal(obj["job_ids"], &jobIDs); err == nil && len(jobIDs) == 1 {
722 if body, err := json.Marshal(jobIDs[0]); err == nil {
723 obj["task_id"] = body
724 }
725 }
726 // An unbounded Reasonix wait omits TaskOutput's optional timeout
727 // entirely: in Claude's schema timeout is the maximum wait in ms, so
728 // claiming 0 would read as "don't wait" — the opposite of the call.
729 var timeoutSeconds int64
730 if err := json.Unmarshal(obj["timeout_seconds"], &timeoutSeconds); err == nil && timeoutSeconds > 0 && timeoutSeconds <= (1<<63-1)/1000 {
731 if body, err := json.Marshal(timeoutSeconds * 1000); err == nil {
732 obj["timeout"] = body
733 }
734 }
735 changed = true
736 }
737 if toolName == "ask" && fillClaudeAskDefaults(obj) {
738 changed = true
739 }
740 if toolName == "todo_write" && fillClaudeTodoDefaults(obj) {
741 changed = true
742 }
743 // parallel_tasks maps to Claude's Agent tool but carries an array of
744 // sub-tasks where Agent has a single prompt — a structural difference no
745 // key rename bridges. Synthesize "prompt" from every sub-task's prompt
746 // (the original "tasks" array stays alongside) so an Agent-scoped guard
747 // reading .tool_input.prompt inspects all dispatched work instead of
748 // failing open on a missing field.
749 if toolName == "parallel_tasks" {
750 if prompt := joinedParallelTaskPrompts(obj["tasks"]); prompt != "" {
751 if v, err := json.Marshal(prompt); err == nil {
752 obj["prompt"] = v
753 changed = true
754 }
755 }
756 }
757 if isAgent {
758 var prompt string
759 _ = json.Unmarshal(obj["prompt"], &prompt)
760 if strings.TrimSpace(prompt) != "" {
761 var description string
762 _ = json.Unmarshal(obj["description"], &description)
763 if strings.TrimSpace(description) == "" {
764 if v, err := json.Marshal(defaultAgentDescription); err == nil {
765 obj["description"] = v
766 changed = true
767 }
768 }
769 }
770 }
771 for _, key := range claudeAbsolutePathInputKeys {
772 v, exists := obj[key]
773 if !exists || cwd == "" {
774 continue
775 }
776 var p string
777 if err := json.Unmarshal(v, &p); err != nil || p == "" || filepath.IsAbs(p) {
778 continue
779 }
780 if abs, err := json.Marshal(filepath.Join(cwd, p)); err == nil {
781 obj[key] = abs
782 changed = true
783 }
784 }
785 if !changed {
786 return args
787 }
788 out, err := json.Marshal(obj)
789 if err != nil {
790 return args
791 }
792 return out
793 }
794
795 // fillClaudeAskDefaults supplies fields Claude requires but Reasonix treats as
796 // optional. Empty option descriptions are honest (Reasonix has no explanation
797 // to add), and omitted multiSelect has the same false default in both systems.
798 func fillClaudeAskDefaults(obj map[string]json.RawMessage) bool {
799 var questions []map[string]json.RawMessage
800 if err := json.Unmarshal(obj["questions"], &questions); err != nil {
801 return false
802 }
803 changed := false
804 for _, question := range questions {
805 if _, exists := question["multiSelect"]; !exists {
806 question["multiSelect"] = json.RawMessage("false")
807 changed = true
808 }
809 var options []map[string]json.RawMessage
810 if err := json.Unmarshal(question["options"], &options); err != nil {
811 continue
812 }
813 optionsChanged := false
814 for _, option := range options {
815 if _, exists := option["description"]; !exists {
816 option["description"] = json.RawMessage(`""`)
817 optionsChanged = true
818 changed = true
819 }
820 }
821 if optionsChanged {
822 body, err := json.Marshal(options)
823 if err != nil {
824 return false
825 }
826 question["options"] = body
827 }
828 }
829 if !changed {
830 return false
831 }
832 body, err := json.Marshal(questions)
833 if err != nil {
834 return false
835 }
836 obj["questions"] = body
837 return true
838 }
839
840 // fillClaudeTodoDefaults supplies Claude's required activeForm label from the
841 // Reasonix task content when the caller omitted it.
842 func fillClaudeTodoDefaults(obj map[string]json.RawMessage) bool {
843 var todos []map[string]json.RawMessage
844 if err := json.Unmarshal(obj["todos"], &todos); err != nil {
845 return false
846 }
847 changed := false
848 for _, todo := range todos {
849 var activeForm string
850 _ = json.Unmarshal(todo["activeForm"], &activeForm)
851 if strings.TrimSpace(activeForm) != "" {
852 continue
853 }
854 var content string
855 if err := json.Unmarshal(todo["content"], &content); err != nil || strings.TrimSpace(content) == "" {
856 continue
857 }
858 body, err := json.Marshal(content)
859 if err != nil {
860 return false
861 }
862 todo["activeForm"] = body
863 changed = true
864 }
865 if !changed {
866 return false
867 }
868 body, err := json.Marshal(todos)
869 if err != nil {
870 return false
871 }
872 obj["todos"] = body
873 return true
874 }
875
876 // joinedParallelTaskPrompts flattens a parallel_tasks "tasks" array into one
877 // prompt string, blank-line separated. Malformed or empty input yields "".
878 func joinedParallelTaskPrompts(tasks json.RawMessage) string {
879 if len(tasks) == 0 {
880 return ""
881 }
882 var items []struct {
883 Prompt string `json:"prompt"`
884 }
885 if err := json.Unmarshal(tasks, &items); err != nil {
886 return ""
887 }
888 var prompts []string
889 for _, item := range items {
890 if s := strings.TrimSpace(item.Prompt); s != "" {
891 prompts = append(prompts, s)
892 }
893 }
894 return strings.Join(prompts, "\n\n")
895 }
896
897 // Payload is the JSON envelope written to a hook's stdin.
898 type Payload struct {
899 Event Event `json:"event"`
900 SessionID string `json:"sessionId,omitempty"`
901 Cwd string `json:"cwd"`
902 ToolName string `json:"toolName,omitempty"`
903 ToolArgs json.RawMessage `json:"toolArgs,omitempty"`
904 Subject string `json:"subject,omitempty"`
905 ToolResult string `json:"toolResult,omitempty"`
906 Prompt string `json:"prompt,omitempty"`
907 LastAssistant string `json:"lastAssistantText,omitempty"`
908 Turn int `json:"turn,omitempty"`
909 Message string `json:"message,omitempty"` // Notification: what needs attention
910 Trigger string `json:"trigger,omitempty"` // PreCompact: "auto" | "manual"
911 Reasoning string `json:"reasoning,omitempty"` // PostLLMCall: the model's raw reasoning text
912 Error string `json:"error,omitempty"`
913 Source string `json:"source,omitempty"`
914 Reason string `json:"reason,omitempty"`
915 NotificationType string `json:"notificationType,omitempty"`
916 IsInterrupt bool `json:"isInterrupt,omitempty"`
917 }
918
919 // Decision is a single hook invocation's verdict.
920 type Decision string
921
922 const (
923 DecisionPass Decision = "pass"
924 DecisionBlock Decision = "block"
925 DecisionWarn Decision = "warn"
926 DecisionError Decision = "error" // spawn failed (ENOENT, EACCES, …)
927 )
928
929 // Outcome records one hook invocation.
930 type Outcome struct {
931 Hook ResolvedHook
932 Decision Decision
933 ExitCode int // -1 when unknown (killed / spawn error)
934 Stdout string
935 Stderr string
936 TimedOut bool
937 Truncated bool
938 Duration time.Duration
939 }
940
941 // Report aggregates the outcomes of running an event's hooks.
942 type Report struct {
943 Event Event
944 Outcomes []Outcome
945 Blocked bool // at least one outcome blocked (only meaningful on gating events)
946 // Allowed is set when a Claude-imported PermissionRequest hook returned an
947 // explicit JSON "allow" decision on exit 0 (see claudeJSONAllow) — the
948 // caller should treat this as an auto-approval instead of prompting.
949 Allowed bool
950 }
951
952 // HookOutput is the parsed, model-facing part of a successful hook stdout.
953 type HookOutput struct {
954 AdditionalContext string
955 // Deny and DenyReason carry a Claude-style JSON deny decision returned on
956 // exit 0: hookSpecificOutput.permissionDecision for PreToolUse,
957 // hookSpecificOutput.decision.behavior for PermissionRequest, or a
958 // top-level decision:"block" for UserPromptSubmit. Claude hooks commonly
959 // deny this way instead of exiting 2; see
960 // https://code.claude.com/docs/en/hooks.
961 Deny bool
962 DenyReason string
963 // Allow carries a Claude PermissionRequest "allow" decision
964 // (hookSpecificOutput.decision.behavior == "allow"): the hook answers the
965 // permission dialog on the user's behalf instead of only observing it.
966 Allow bool
967 }
968
969 type hookJSONOutput struct {
970 // Decision and Reason are UserPromptSubmit's (and Stop/SubagentStop's)
971 // top-level deny shape: {"decision":"block","reason":"..."}.
972 Decision string `json:"decision"`
973 Reason string `json:"reason"`
974 HookSpecificOutput struct {
975 HookEventName Event `json:"hookEventName"`
976 AdditionalContext string `json:"additionalContext"`
977 PermissionDecision string `json:"permissionDecision"`
978 PermissionDecisionReason string `json:"permissionDecisionReason"`
979 Decision struct {
980 Behavior string `json:"behavior"`
981 } `json:"decision"`
982 } `json:"hookSpecificOutput"`
983 }
984
985 // ParseOutput extracts hook-specific context from stdout. Plain text is accepted
986 // for SessionStart compatibility; JSON output must identify the current event.
987 func ParseOutput(event Event, stdout string) (HookOutput, []string) {
988 stdout = strings.TrimSpace(stdout)
989 if stdout == "" {
990 return HookOutput{}, nil
991 }
992 if !strings.HasPrefix(stdout, "{") {
993 if event == SessionStart {
994 return HookOutput{AdditionalContext: stdout}, nil
995 }
996 return HookOutput{}, nil
997 }
998 var parsed hookJSONOutput
999 if err := json.Unmarshal([]byte(stdout), &parsed); err != nil {
1000 return HookOutput{}, []string{fmt.Sprintf("hook %s returned invalid JSON stdout: %v", event, err)}
1001 }
1002 spec := parsed.HookSpecificOutput
1003 topLevelDeny := event == UserPromptSubmit && strings.EqualFold(parsed.Decision, "block")
1004 deny := strings.EqualFold(spec.PermissionDecision, "deny") || strings.EqualFold(spec.Decision.Behavior, "deny") || topLevelDeny
1005 allow := event == PermissionRequest && strings.EqualFold(spec.Decision.Behavior, "allow")
1006 if spec.HookEventName == "" && strings.TrimSpace(spec.AdditionalContext) == "" && !deny && !allow {
1007 return HookOutput{}, nil
1008 }
1009 if spec.HookEventName != "" && spec.HookEventName != event {
1010 return HookOutput{}, []string{fmt.Sprintf("hook output event %q does not match current event %q", spec.HookEventName, event)}
1011 }
1012 out := HookOutput{AdditionalContext: strings.TrimSpace(spec.AdditionalContext)}
1013 if deny {
1014 out.Deny = true
1015 reason := spec.PermissionDecisionReason
1016 if topLevelDeny {
1017 reason = parsed.Reason
1018 }
1019 out.DenyReason = strings.TrimSpace(reason)
1020 }
1021 out.Allow = allow
1022 return out, nil
1023 }
1024
1025 // decideOutcome maps a spawn result to a verdict for hook h.
1026 func decideOutcome(h ResolvedHook, r SpawnResult) Decision {
1027 blocking := IsBlocking(h.Event) || claudePermissionBlocking(h)
1028 switch {
1029 case r.SpawnErr != nil:
1030 return DecisionError
1031 case r.TimedOut:
1032 if blocking {
1033 return DecisionBlock
1034 }
1035 return DecisionWarn
1036 case r.ExitCode == 0:
1037 return DecisionPass
1038 case r.ExitCode == 2 && blocking:
1039 return DecisionBlock
1040 default:
1041 return DecisionWarn
1042 }
1043 }
1044
1045 // claudeJSONDeny reports whether a Claude-format hook's exit-0 stdout still
1046 // carries a JSON deny decision (see HookOutput.Deny). Reasonix must honor it
1047 // for the events it claims Claude hook compatibility for, or a plugin's
1048 // "block this dangerous command" hook silently no-ops whenever the script
1049 // signals deny via JSON instead of exit code 2. UserPromptSubmit uses a
1050 // top-level decision:"block" instead of PreToolUse/PermissionRequest's
1051 // hookSpecificOutput shape; ParseOutput handles both.
1052 func claudeJSONDeny(event Event, stdout string) (bool, string) {
1053 if event != PreToolUse && event != PermissionRequest && event != UserPromptSubmit {
1054 return false, ""
1055 }
1056 out, _ := ParseOutput(event, stdout)
1057 return out.Deny, out.DenyReason
1058 }
1059
1060 // claudeJSONAllow reports whether a Claude-format PermissionRequest hook's
1061 // exit-0 stdout carries an explicit "allow" decision
1062 // (hookSpecificOutput.decision.behavior == "allow"): the hook answers the
1063 // permission dialog on the user's behalf, same as an exit-2 deny preempts it.
1064 func claudeJSONAllow(event Event, stdout string) bool {
1065 if event != PermissionRequest {
1066 return false
1067 }
1068 out, _ := ParseOutput(event, stdout)
1069 return out.Allow
1070 }
1071
1072 // SpawnInput / SpawnResult / Spawner are the test seam around the real spawn.
1073 type SpawnInput struct {
1074 Command string
1075 Args []string
1076 Mode ExecutionMode
1077 Shell string
1078 Cwd string
1079 Env map[string]string
1080 Stdin string
1081 Timeout time.Duration
1082 }
1083
1084 // RuntimeOptions carries resolved host dependencies into Hook execution.
1085 // It is runtime-only and never changes persisted Hook configuration.
1086 type RuntimeOptions struct {
1087 BashPath string
1088 }
1089
1090 // RuntimeOptionsForShell carries an explicitly configured Bash path into Hook
1091 // execution while leaving other interpreter preferences independent.
1092 func RuntimeOptionsForShell(prefer, path string) RuntimeOptions {
1093 if !strings.EqualFold(strings.TrimSpace(prefer), "bash") {
1094 return RuntimeOptions{}
1095 }
1096 return RuntimeOptions{BashPath: strings.TrimSpace(path)}
1097 }
1098
1099 // RuntimeIssue identifies one plugin Hook whose host dependency is unavailable.
1100 type RuntimeIssue struct {
1101 Event Event
1102 Description string
1103 Err error
1104 }
1105
1106 // CheckPackageRuntime validates every Hook exported by a plugin package without
1107 // launching commands.
1108 func CheckPackageRuntime(pkg pluginpkg.Package, options RuntimeOptions) []RuntimeIssue {
1109 events := make([]string, 0, len(pkg.Manifest.Hooks))
1110 for event := range pkg.Manifest.Hooks {
1111 events = append(events, event)
1112 }
1113 sort.Strings(events)
1114 var issues []RuntimeIssue
1115 for _, eventName := range events {
1116 for _, h := range pkg.Manifest.Hooks[eventName] {
1117 if err := CheckRuntime(pluginHookExecutionConfig(h, pkg.Root), options); err != nil {
1118 issues = append(issues, RuntimeIssue{
1119 Event: Event(eventName), Description: h.Description, Err: err,
1120 })
1121 }
1122 }
1123 }
1124 return issues
1125 }
1126
1127 type SpawnResult struct {
1128 ExitCode int
1129 Stdout string
1130 Stderr string
1131 TimedOut bool
1132 SpawnErr error
1133 Truncated bool
1134 }
1135
1136 type Spawner func(ctx context.Context, in SpawnInput) SpawnResult
1137
1138 // outputCapBytes bounds per-stream capture so a runaway child can't blow up the
1139 // heap between spawn and timeout.
1140 const outputCapBytes = 256 * 1024
1141
1142 // Run executes the hooks matching payload.Event (and, for tool events, the tool
1143 // name), feeding each the JSON payload on stdin. It stops at the first block so
1144 // a gating hook can prevent later hooks running against a phantom success.
1145 func Run(ctx context.Context, payload Payload, hooks []ResolvedHook, spawner Spawner) Report {
1146 if spawner == nil {
1147 spawner = DefaultSpawner
1148 }
1149 event := payload.Event
1150 report := Report{Event: event}
1151 for _, h := range hooks {
1152 if h.Event != event || !MatchesTool(h, payload.ToolName) {
1153 continue
1154 }
1155 cwd := h.Cwd
1156 if cwd == "" {
1157 cwd = payload.Cwd
1158 }
1159 timeout := h.timeout()
1160 stdin := marshalPayload(payload, h.PayloadFormat)
1161 input := SpawnInput{
1162 Command: h.Command,
1163 Args: h.Argv,
1164 Mode: h.ExecutionMode,
1165 Shell: h.Shell,
1166 Cwd: cwd,
1167 Env: h.Env,
1168 Stdin: stdin,
1169 Timeout: timeout,
1170 }
1171 if h.Async {
1172 asyncCtx := context.WithoutCancel(ctx)
1173 go runResolvedHook(asyncCtx, h, input, spawner)
1174 report.Outcomes = append(report.Outcomes, Outcome{Hook: h, Decision: DecisionPass})
1175 continue
1176 }
1177 start := time.Now()
1178 r := runResolvedHook(ctx, h, input, spawner)
1179 decision := decideOutcome(h, r)
1180 if decision == DecisionPass && h.PayloadFormat == "claude" {
1181 if deny, reason := claudeJSONDeny(event, r.Stdout); deny {
1182 decision = DecisionBlock
1183 if reason != "" {
1184 r.Stdout = reason
1185 }
1186 } else if claudeJSONAllow(event, r.Stdout) {
1187 report.Allowed = true
1188 }
1189 }
1190 report.Outcomes = append(report.Outcomes, Outcome{
1191 Hook: h,
1192 Decision: decision,
1193 ExitCode: r.ExitCode,
1194 Stdout: r.Stdout,
1195 Stderr: stderrFor(r, timeout),
1196 TimedOut: r.TimedOut,
1197 Truncated: r.Truncated,
1198 Duration: time.Since(start),
1199 })
1200 if decision == DecisionBlock {
1201 report.Blocked = true
1202 break
1203 }
1204 }
1205 return report
1206 }
1207
1208 func marshalPayload(payload Payload, format string) string {
1209 var body []byte
1210 if format == "claude" {
1211 claude := map[string]any{
1212 "hook_event_name": payload.Event,
1213 "session_id": payload.SessionID,
1214 "cwd": payload.Cwd,
1215 "tool_name": claudeFacingToolName(payload.ToolName),
1216 "tool_input": claudeFacingToolInput(payload.ToolName, payload.ToolArgs, payload.Cwd),
1217 "tool_response": claudeToolResponse(payload),
1218 "prompt": payload.Prompt,
1219 "last_assistant_message": payload.LastAssistant,
1220 "source": payload.Source,
1221 "reason": payload.Reason,
1222 "notification_type": payload.NotificationType,
1223 "message": payload.Message,
1224 "trigger": payload.Trigger,
1225 "error": payload.Error,
1226 "is_interrupt": payload.IsInterrupt,
1227 }
1228 body, _ = json.Marshal(claude)
1229 } else {
1230 body, _ = json.Marshal(payload)
1231 }
1232 return string(body) + "\n"
1233 }
1234
1235 // claudeToolResponse adapts a Reasonix tool result to the tool_response a
1236 // Claude-authored PostToolUse hook reads. Claude's Bash response is an object
1237 // — {stdout, stderr, interrupted}, the fields the official security-guidance
1238 // plugin's commit/push checks read (a non-object response is treated as empty
1239 // and the check silently passes) — while Reasonix's bash returns one combined
1240 // output string, so it is wrapped with the failure error as stderr. Other
1241 // tools' results pass through as before: raw JSON when the result is a JSON
1242 // document, else the plain string.
1243 func claudeToolResponse(p Payload) any {
1244 if (p.Event == PostToolUse || p.Event == PostToolUseFailure) && claudeFacingToolName(p.ToolName) == "Bash" {
1245 return map[string]any{
1246 "stdout": p.ToolResult,
1247 "stderr": p.Error,
1248 "interrupted": p.IsInterrupt,
1249 }
1250 }
1251 trimmed := strings.TrimSpace(p.ToolResult)
1252 if trimmed == "" || !json.Valid([]byte(trimmed)) {
1253 return p.ToolResult
1254 }
1255 return json.RawMessage(trimmed)
1256 }
1257
1258 func runResolvedHook(ctx context.Context, h ResolvedHook, in SpawnInput, spawner Spawner) SpawnResult {
1259 if h.Scope == ScopePlugin && h.ContextFile != "" {
1260 return readContextFile(h.ContextFile)
1261 }
1262 return spawner(ctx, in)
1263 }
1264
1265 func readContextFile(path string) SpawnResult {
1266 body, err := fileencoding.ReadFileUTF8(path)
1267 if err != nil {
1268 return SpawnResult{ExitCode: -1, SpawnErr: err}
1269 }
1270 truncated := false
1271 if len(body) > outputCapBytes {
1272 body = body[:outputCapBytes]
1273 truncated = true
1274 }
1275 return SpawnResult{ExitCode: 0, Stdout: string(body), Truncated: truncated}
1276 }
1277
1278 // stderrFor returns the best human message for an outcome: real stderr, else a
1279 // spawn-error message, else a timeout note.
1280 func stderrFor(r SpawnResult, timeout time.Duration) string {
1281 if r.Stderr != "" {
1282 return r.Stderr
1283 }
1284 if r.SpawnErr != nil {
1285 return r.SpawnErr.Error()
1286 }
1287 if r.TimedOut {
1288 return fmt.Sprintf("hook timed out after %s", timeout)
1289 }
1290 return ""
1291 }
1292
1293 // DefaultSpawner executes the hook according to its explicit execution
1294 // contract, with the payload on stdin, capped output, and both per-hook timeout
1295 // and parent-context cancellation.
1296 func DefaultSpawner(ctx context.Context, in SpawnInput) SpawnResult {
1297 return defaultSpawner(ctx, in, RuntimeOptions{})
1298 }
1299
1300 // NewDefaultSpawner returns the standard Hook spawner with effective host
1301 // runtime paths supplied by boot configuration.
1302 func NewDefaultSpawner(options RuntimeOptions) Spawner {
1303 return func(ctx context.Context, in SpawnInput) SpawnResult {
1304 return defaultSpawner(ctx, in, options)
1305 }
1306 }
1307
1308 func defaultSpawner(ctx context.Context, in SpawnInput, options RuntimeOptions) SpawnResult {
1309 cctx, cancel := context.WithTimeout(ctx, in.Timeout)
1310 defer cancel()
1311
1312 cmd, spawnErr := spawnCommand(cctx, in.Command, in.Mode, in.Shell, in.Args, options)
1313 if spawnErr != nil {
1314 return SpawnResult{ExitCode: -1, SpawnErr: spawnErr}
1315 }
1316 proc.HideWindow(cmd)
1317 cmd.Dir = in.Cwd
1318 env := secrets.ProcessEnv()
1319 if len(in.Env) > 0 {
1320 keys := make([]string, 0, len(in.Env))
1321 for k := range in.Env {
1322 keys = append(keys, k)
1323 }
1324 sort.Strings(keys)
1325 for _, k := range keys {
1326 env = append(env, k+"="+in.Env[k])
1327 }
1328 }
1329 cmd.Env = env
1330 cmd.Stdin = strings.NewReader(in.Stdin)
1331 var outBuf, errBuf cappedBuffer
1332 cmd.Stdout = &outBuf
1333 cmd.Stderr = &errBuf
1334 // WaitDelay bounds Wait even if a grandchild keeps a pipe open after the
1335 // shell is killed on timeout/cancel.
1336 cmd.WaitDelay = 500 * time.Millisecond
1337
1338 err := cmd.Run()
1339 res := SpawnResult{
1340 ExitCode: -1,
1341 Stdout: decodeHookOutput(outBuf.Bytes(), outBuf.truncated),
1342 Stderr: decodeHookOutput(errBuf.Bytes(), errBuf.truncated),
1343 Truncated: outBuf.truncated || errBuf.truncated,
1344 }
1345 switch {
1346 case cctx.Err() == context.DeadlineExceeded:
1347 res.TimedOut = true
1348 case cctx.Err() == context.Canceled:
1349 res.SpawnErr = cctx.Err()
1350 case err != nil:
1351 var exitErr *exec.ExitError
1352 if errors.As(err, &exitErr) {
1353 res.ExitCode = exitErr.ExitCode()
1354 } else {
1355 res.SpawnErr = err
1356 }
1357 default:
1358 res.ExitCode = 0
1359 }
1360 return res
1361 }
1362
1363 // spawnCommand picks the execution vehicle from the manifest contract.
1364 // Explicit exec-form hooks pass their argv directly to the executable;
1365 // explicit shell-form hooks pass the raw command to the selected interpreter.
1366 // Legacy settings retain Reasonix's historical shell behavior and repairs.
1367 func spawnCommand(ctx context.Context, command string, mode ExecutionMode, shell string, args []string, options RuntimeOptions) (*exec.Cmd, error) {
1368 switch mode {
1369 case ExecutionExec:
1370 return spawnExecCommand(ctx, command, args, options)
1371 case ExecutionShell:
1372 return spawnShellCommand(ctx, command, shell, options)
1373 case ExecutionLegacy:
1374 return spawnLegacyCommand(ctx, command, args, options)
1375 default:
1376 return nil, fmt.Errorf("unsupported hook execution mode %q", mode)
1377 }
1378 }
1379
1380 func spawnExecCommand(ctx context.Context, command string, args []string, options RuntimeOptions) (*exec.Cmd, error) {
1381 if runtime.GOOS == "windows" {
1382 if cmd, matched := windowsBatchArgvCommand(ctx, command, args); matched {
1383 return cmd, nil
1384 }
1385 if resolvedShell, resolvedArgs, matched, err := windowsPOSIXShellArgvInvocationWith(command, args, func() (string, error) {
1386 return resolveWindowsHookBash(options.BashPath)
1387 }); matched {
1388 if err != nil {
1389 return nil, err
1390 }
1391 return exec.CommandContext(ctx, resolvedShell, resolvedArgs...), nil
1392 }
1393 }
1394 return exec.CommandContext(ctx, command, args...), nil
1395 }
1396
1397 // spawnLegacyCommand preserves the pre-contract behavior:
1398 // - a command this call just repaired (its broken quoting means it never
1399 // worked through a shell, so there is no expansion behavior to preserve);
1400 // - on Windows, a recognized node -e stdin-hook command: `cmd /c` mangles
1401 // quoted JS (&, %, nested quotes), which is the breakage this repair
1402 // exists for, and cmd performs no POSIX-style $ expansion to preserve.
1403 // - on Windows, an explicit `sh -c` / `bash -c` command: Git Bash is often
1404 // installed outside cmd.exe's PATH, and direct exec preserves its quoting.
1405 //
1406 // POSIX commands that were already well-formed keep their shell semantics
1407 // verbatim — normalizeStaticNodeEval's rendering escapes $ and backticks, so
1408 // even repaired commands re-entering here behave identically under sh -c.
1409 func spawnLegacyCommand(ctx context.Context, command string, args []string, options RuntimeOptions) (*exec.Cmd, error) {
1410 if args != nil {
1411 return spawnExecCommand(ctx, command, args, options)
1412 }
1413 if node, flag, script, ok := repairableNodeEvalArgs(command); ok {
1414 return exec.CommandContext(ctx, node, flag, script), nil
1415 }
1416 if powershell, args, ok := repairablePowerShellFileArgs(command); ok {
1417 return exec.CommandContext(ctx, powershell, args...), nil
1418 }
1419 if runtime.GOOS == "windows" {
1420 if cmd, matched := windowsBatchCommand(ctx, command); matched {
1421 return cmd, nil
1422 }
1423 if shell, args, matched, err := windowsPOSIXShellInvocationWith(command, func() (string, error) {
1424 return resolveWindowsHookBash(options.BashPath)
1425 }); matched {
1426 if err != nil {
1427 return nil, err
1428 }
1429 return exec.CommandContext(ctx, shell, args...), nil
1430 }
1431 if node, flag, script, ok := directNodeEvalArgs(command); ok {
1432 return exec.CommandContext(ctx, node, flag, script), nil
1433 }
1434 if cmd, ok := windowsCmdShellCommand(ctx, command); ok {
1435 return cmd, nil
1436 }
1437 }
1438 name, args := shellInvocation(command)
1439 return exec.CommandContext(ctx, name, args...), nil
1440 }
1441
1442 func spawnShellCommand(ctx context.Context, command, preferred string, options RuntimeOptions) (*exec.Cmd, error) {
1443 preferred = strings.ToLower(strings.TrimSpace(preferred))
1444 switch preferred {
1445 case "", "auto":
1446 if runtime.GOOS == "windows" {
1447 // Retain the established #6668 compatibility path for the common
1448 // quoted .cmd/.bat hook shape. More complex scripts continue to
1449 // the selected shell without being parsed or re-rendered.
1450 if cmd, matched := windowsBatchCommand(ctx, command); matched {
1451 return cmd, nil
1452 }
1453 sh, err := cachedWindowsDefaultHookShell()
1454 if err != nil {
1455 return nil, err
1456 }
1457 return rawShellCommand(ctx, sh, command)
1458 }
1459 return exec.CommandContext(ctx, "sh", "-c", command), nil
1460 case "bash":
1461 if runtime.GOOS == "windows" {
1462 path, err := resolveWindowsHookBash(options.BashPath)
1463 if err != nil {
1464 return nil, err
1465 }
1466 return exec.CommandContext(ctx, path, "-c", command), nil
1467 }
1468 return exec.CommandContext(ctx, "bash", "-c", command), nil
1469 case "powershell", "pwsh":
1470 sh := sandbox.ResolveShell(preferred, "", nil)
1471 if sh.Kind != sandbox.ShellPowerShell {
1472 return nil, fmt.Errorf("hook requires %s, but no usable PowerShell was found", preferred)
1473 }
1474 path, err := resolvedHookShellPath(sh)
1475 if err != nil {
1476 return nil, err
1477 }
1478 return powerShellCommand(ctx, path, command), nil
1479 case "cmd":
1480 if cmd, ok := windowsCmdShellCommand(ctx, command); ok {
1481 return cmd, nil
1482 }
1483 return nil, errors.New("hook shell \"cmd\" is only available on Windows")
1484 default:
1485 return nil, fmt.Errorf("unsupported hook shell %q", preferred)
1486 }
1487 }
1488
1489 // CheckRuntime reports an unavailable host dependency without running a Hook.
1490 func CheckRuntime(config HookConfig, options RuntimeOptions) error {
1491 return checkRuntimeForPlatform(config, options, runtime.GOOS, resolveWindowsHookBash)
1492 }
1493
1494 func checkRuntimeForPlatform(config HookConfig, options RuntimeOptions, goos string, resolveBash func(string) (string, error)) error {
1495 if goos != "windows" || !requiresWindowsBash(config) {
1496 return nil
1497 }
1498 _, err := resolveBash(options.BashPath)
1499 return err
1500 }
1501
1502 func requiresWindowsBash(config HookConfig) bool {
1503 switch config.ExecutionMode {
1504 case ExecutionShell:
1505 return strings.EqualFold(strings.TrimSpace(config.Shell), "bash")
1506 case ExecutionExec:
1507 return isBarePOSIXShellWord(config.Command) && hasCommandStringFlag(config.Argv)
1508 case ExecutionLegacy:
1509 if config.Argv != nil {
1510 return isBarePOSIXShellWord(config.Command) && hasCommandStringFlag(config.Argv)
1511 }
1512 fields, _, _, ok := parseSimpleHookCommandFields(config.Command)
1513 return ok && len(fields) >= 3 && isBarePOSIXShellWord(fields[0]) && hasCommandStringFlag(fields[1:])
1514 default:
1515 return false
1516 }
1517 }
1518
1519 func rawShellCommand(ctx context.Context, sh sandbox.Shell, command string) (*exec.Cmd, error) {
1520 path, err := resolvedHookShellPath(sh)
1521 if err != nil {
1522 return nil, err
1523 }
1524 if sh.Kind == sandbox.ShellPowerShell {
1525 return powerShellCommand(ctx, path, command), nil
1526 }
1527 return exec.CommandContext(ctx, path, "-c", command), nil
1528 }
1529
1530 func powerShellCommand(ctx context.Context, path, command string) *exec.Cmd {
1531 // PowerShell's native command-line parser does not follow
1532 // CommandLineToArgvW consistently for a complex -Command argument.
1533 // -EncodedCommand transports the exact script as UTF-16LE and avoids a
1534 // second layer of quote/backslash interpretation. Force captured output to
1535 // UTF-8 before encoding so Windows PowerShell does not emit the host console
1536 // code page into Reasonix's stdout/stderr text contract.
1537 command = sandbox.PowerShellUTF8Script(command)
1538 codeUnits := utf16.Encode([]rune(command))
1539 raw := make([]byte, len(codeUnits)*2)
1540 for i, unit := range codeUnits {
1541 raw[i*2] = byte(unit)
1542 raw[i*2+1] = byte(unit >> 8)
1543 }
1544 encoded := base64.StdEncoding.EncodeToString(raw)
1545 return exec.CommandContext(ctx, path, "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded)
1546 }
1547
1548 func shellInvocation(command string) (string, []string) {
1549 if runtime.GOOS == "windows" {
1550 return "cmd", []string{"/c", command}
1551 }
1552 return "sh", []string{"-c", command}
1553 }
1554
1555 // cappedBuffer is an io.Writer that stops storing after outputCapBytes and
1556 // records that it truncated, but keeps reporting full writes so the child never
1557 // sees a short-write error.
1558 type cappedBuffer struct {
1559 buf bytes.Buffer
1560 truncated bool
1561 }
1562
1563 func (c *cappedBuffer) Write(p []byte) (int, error) {
1564 remaining := outputCapBytes - c.buf.Len()
1565 if remaining <= 0 {
1566 c.truncated = true
1567 return len(p), nil
1568 }
1569 if len(p) > remaining {
1570 c.buf.Write(p[:remaining])
1571 c.truncated = true
1572 return len(p), nil
1573 }
1574 c.buf.Write(p)
1575 return len(p), nil
1576 }
1577
1578 func (c *cappedBuffer) Bytes() []byte { return c.buf.Bytes() }
1579 func (c *cappedBuffer) String() string { return c.buf.String() }
1580
1581 func reasonixHome(override string) string {
1582 if override != "" {
1583 return filepath.Join(override, SettingsDirname)
1584 }
1585 if dir := config.ReasonixHomeDir(); dir != "" {
1586 return dir
1587 }
1588 if h, err := os.UserHomeDir(); err == nil {
1589 return filepath.Join(h, SettingsDirname)
1590 }
1591 return ""
1592 }
1593
1594 func reasonixHomeForOptions(opts LoadOptions) string {
1595 if dir := strings.TrimSpace(opts.ReasonixHomeDir); dir != "" {
1596 return filepath.Clean(dir)
1597 }
1598 return reasonixHome(opts.HomeDir)
1599 }
1600
1601 func legacyGlobalSettingsPath(homeDir string) string {
1602 dir := legacyReasonixHome(homeDir)
1603 if dir == "" {
1604 return ""
1605 }
1606 return filepath.Join(dir, SettingsFilename)
1607 }
1608
1609 func legacyReasonixHome(override string) string {
1610 if override != "" {
1611 return ""
1612 }
1613 if config.IsolatedHomeDir() != "" {
1614 return ""
1615 }
1616 home, err := os.UserHomeDir()
1617 if err != nil || home == "" {
1618 return ""
1619 }
1620 legacy := filepath.Join(home, SettingsDirname)
1621 if sameCleanPath(legacy, reasonixHome("")) {
1622 return ""
1623 }
1624 return legacy
1625 }
1626
1627 func sameCleanPath(a, b string) bool {
1628 if strings.TrimSpace(a) == "" || strings.TrimSpace(b) == "" {
1629 return false
1630 }
1631 if aa, err := filepath.Abs(a); err == nil {
1632 a = aa
1633 }
1634 if bb, err := filepath.Abs(b); err == nil {
1635 b = bb
1636 }
1637 return filepath.Clean(a) == filepath.Clean(b)
1638 }
1639
1639 lines GO