返回 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 "runtime"
25 "slices"
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: execution.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 return completePluginHookExecutionConfig(h, root, goos, mode)
400 }
401
402 func expandPluginRoot(value, root string) string {
403 // Plugin hook manifests are host configuration, not platform-native shell
404 // scripts. Scan the manifest value once so text inside the resolved root is
405 // never mistaken for another placeholder and expanded recursively.
406 lastWrite := 0
407 replaced := false
408 var out strings.Builder
409 for i := 0; i < len(value); {
410 tokenLen := pluginRootTokenLen(value[i:])
411 if tokenLen == 0 {
412 i++
413 continue
414 }
415 if !replaced {
416 out.Grow(len(value) - tokenLen + len(root))
417 replaced = true
418 }
419 out.WriteString(value[lastWrite:i])
420 out.WriteString(root)
421 i += tokenLen
422 lastWrite = i
423 }
424 if !replaced {
425 return value
426 }
427 out.WriteString(value[lastWrite:])
428 return out.String()
429 }
430
431 var pluginRootTokens = [...]struct {
432 value string
433 needsBoundary bool
434 }{
435 {value: "${CLAUDE_PLUGIN_ROOT}"},
436 {value: "$CLAUDE_PLUGIN_ROOT", needsBoundary: true},
437 {value: "%CLAUDE_PLUGIN_ROOT%"},
438 {value: "${REASONIX_PLUGIN_ROOT}"},
439 {value: "$REASONIX_PLUGIN_ROOT", needsBoundary: true},
440 {value: "%REASONIX_PLUGIN_ROOT%"},
441 }
442
443 func pluginRootTokenLen(value string) int {
444 for _, token := range pluginRootTokens {
445 if !strings.HasPrefix(value, token.value) {
446 continue
447 }
448 if token.needsBoundary && len(value) > len(token.value) && isShellVariableNameByte(value[len(token.value)]) {
449 continue
450 }
451 return len(token.value)
452 }
453 return 0
454 }
455
456 func isShellVariableNameByte(c byte) bool {
457 return c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9'
458 }
459
460 func validEvent(event Event) bool {
461 return slices.Contains(Events, event)
462 }
463
464 func cloneEnv(in map[string]string) map[string]string {
465 out := map[string]string{}
466 for k, v := range in {
467 if strings.TrimSpace(k) != "" {
468 out[k] = v
469 }
470 }
471 return out
472 }
473
474 // claudeAgentSpawningTools are every Reasonix tool that spawns a subagent and
475 // so corresponds to Claude's single "Agent" tool: the general task delegator
476 // (task/read_only_task/parallel_tasks) and the dedicated named wrappers
477 // around a runAs=subagent skill (BuiltinSubagentTools in
478 // internal/skill/tools.go — each is a distinct, directly-callable tool, not
479 // routed through run_skill). A Claude "Agent" safety matcher must see all of
480 // them, or a hook scoped to it silently misses whichever entry point wasn't
481 // mapped.
482 var claudeAgentSpawningTools = []string{
483 "task", "read_only_task", "parallel_tasks",
484 "explore", "research", "review", "security_review",
485 }
486
487 // claudeAgentDefaultDescriptions fill Claude Agent's required description
488 // field when the corresponding Reasonix tool does not expose one or the model
489 // omitted Reasonix's optional description. These are stable operation labels;
490 // the complete task remains in prompt for hook policy decisions.
491 var claudeAgentDefaultDescriptions = map[string]string{
492 "task": "Run delegated subagent task",
493 "read_only_task": "Run read-only research task",
494 "parallel_tasks": "Run parallel subagent tasks",
495 "explore": "Explore the codebase",
496 "research": "Research external references",
497 "review": "Review the current changes",
498 "security_review": "Review security risks",
499 }
500
501 // claudeToolNames maps Reasonix's own tool names to the *current* Claude Code
502 // built-in tool name (https://code.claude.com/docs/en/tools-reference) — what
503 // an imported hook's emitted tool_name payload field shows, and a script's own
504 // tool_name check is written against. MCP tool names already share the
505 // mcp__<server>__<tool> convention in both systems.
506 var claudeToolNames = buildClaudeToolNames()
507
508 func buildClaudeToolNames() map[string]string {
509 out := map[string]string{
510 "bash": "Bash",
511 "pwsh": "Bash",
512 "read_file": "Read",
513 "write_file": "Write",
514 "edit_file": "Edit",
515 "multi_edit": "MultiEdit",
516 "glob": "Glob",
517 "grep": "Grep",
518 "web_fetch": "WebFetch",
519 "ask": "AskUserQuestion",
520 "run_skill": "Skill",
521 "read_only_skill": "Skill",
522 "todo_write": "TodoWrite",
523 "notebook_edit": "NotebookEdit",
524 "bash_output": "TaskOutput",
525 "job_output": "TaskOutput",
526 "wait": "TaskOutput",
527 "kill_shell": "TaskStop",
528 "job_kill": "TaskStop",
529 }
530 for _, name := range claudeAgentSpawningTools {
531 out[name] = "Agent"
532 }
533 return out
534 }
535
536 // claudeToolMatchAliases lists every tool name — current and legacy — an
537 // imported hook's matcher may have been authored against for a Reasonix
538 // tool, so a matcher written against an older Claude Code tool name keeps
539 // firing after Claude renames the tool (Task became Agent; BashOutput/KillShell
540 // became TaskOutput/TaskStop). claudeFacingToolName (the emitted tool_name
541 // payload) always reports the current name; only matcher evaluation considers
542 // aliases.
543 var claudeToolMatchAliases = buildClaudeToolMatchAliases()
544
545 func buildClaudeToolMatchAliases() map[string][]string {
546 out := map[string][]string{}
547 for _, name := range claudeAgentSpawningTools {
548 out[name] = []string{"Agent", "Task"}
549 }
550 out["bash_output"] = []string{"TaskOutput", "BashOutput"}
551 out["job_output"] = []string{"TaskOutput", "BashOutput"}
552 out["wait"] = []string{"TaskOutput", "BashOutput"}
553 out["kill_shell"] = []string{"TaskStop", "KillShell"}
554 out["job_kill"] = []string{"TaskStop", "KillShell"}
555 return out
556 }
557
558 // claudeMatchNames returns every name an imported hook's matcher should be
559 // tried against for a Reasonix tool call.
560 func claudeMatchNames(name string) []string {
561 if aliases, ok := claudeToolMatchAliases[name]; ok {
562 return aliases
563 }
564 return []string{claudeFacingToolName(name)}
565 }
566
567 // claudeFacingToolName returns the current Claude tool name a Claude-imported
568 // hook's tool_name payload field should see for a Reasonix tool call.
569 // Reasonix-only tools (wait, code_index, move_file, ...) have no Claude
570 // equivalent and pass through unchanged — an imported hook can't have been
571 // authored against a name Claude never had.
572 func claudeFacingToolName(name string) string {
573 if mapped, ok := claudeToolNames[name]; ok {
574 return mapped
575 }
576 return name
577 }
578
579 // claudeToolInputKeyRenames maps, per Reasonix tool name, JSON keys in its
580 // tool-call arguments that must be renamed to Claude's own tool_input field
581 // name — Reasonix's file tools use "path", Claude's use "file_path" — so a
582 // hook script reading e.g. ".tool_input.file_path" sees the value instead of
583 // failing open on an empty field. Only tools whose Reasonix schema differs
584 // from Claude's by a plain key rename are listed: Bash's "command",
585 // Glob/Grep's "pattern"/"path", web_fetch's "url", ask's "questions",
586 // todo_write's "todos", and task/read_only_task's "prompt"/"description"
587 // already use Claude's field names. Agent description can still be absent and
588 // is filled separately below. NotebookEdit's cell_number (a
589 // 0-based index) has no Claude field — Claude targets cells only by the
590 // opaque cell_id, which Reasonix also accepts — so it passes through as an
591 // extra key. parallel_tasks is a structural mismatch handled separately in
592 // claudeFacingToolInput.
593 var claudeToolInputKeyRenames = map[string]map[string]string{
594 "read_file": {"path": "file_path"},
595 "write_file": {"path": "file_path"},
596 "edit_file": {"path": "file_path"},
597 "multi_edit": {"path": "file_path"},
598 "notebook_edit": {"path": "notebook_path"},
599 "run_skill": {"name": "skill", "arguments": "args"},
600 "read_only_skill": {"name": "skill", "arguments": "args"},
601 "bash_output": {"job_id": "task_id"},
602 "job_output": {"job_id": "task_id"},
603 "kill_shell": {"job_id": "task_id"},
604 "job_kill": {"job_id": "task_id"},
605 // The dedicated subagent wrappers take their task text as "task";
606 // Claude's Agent tool calls the same thing "prompt".
607 "explore": {"task": "prompt"},
608 "research": {"task": "prompt"},
609 "review": {"task": "prompt"},
610 "security_review": {"task": "prompt"},
611 }
612
613 // claudeAbsolutePathInputKeys are the translated tool_input keys whose Claude
614 // schema demands an absolute path ("must be absolute, not relative" on
615 // Read/Write/Edit/NotebookEdit). Reasonix's file tools accept relative paths
616 // and resolve them against the workspace root (resolveIn in
617 // internal/tool/builtin/workspace.go); the payload resolves against
618 // payload.Cwd — the same root — so a prefix-matching guard inspects the path
619 // the tool actually accesses, not a relative spelling it never compares.
620 var claudeAbsolutePathInputKeys = []string{"file_path", "notebook_path"}
621
622 // claudeFacingToolInput adapts tool-call arguments to the tool_input a
623 // Claude-authored hook script was written against: keys are renamed per
624 // claudeToolInputKeyRenames, file paths are made absolute, current TaskOutput
625 // fields and required Agent/AskUserQuestion/TodoWrite fields are supplied, and
626 // parallel_tasks synthesizes Agent's "prompt". Args needing no translation, or
627 // that aren't a JSON object, pass through unchanged.
628 func claudeFacingToolInput(toolName string, args json.RawMessage, cwd string) json.RawMessage {
629 renames := claudeToolInputKeyRenames[toolName]
630 defaultAgentDescription, isAgent := claudeAgentDefaultDescriptions[toolName]
631 if len(renames) == 0 && !isAgent && toolName != "ask" && toolName != "todo_write" && toolName != "wait" {
632 return args
633 }
634 if len(args) == 0 {
635 return args
636 }
637 var obj map[string]json.RawMessage
638 if err := json.Unmarshal(args, &obj); err != nil {
639 return args
640 }
641 changed := false
642 for from, to := range renames {
643 if v, exists := obj[from]; exists {
644 obj[to] = v
645 delete(obj, from)
646 changed = true
647 }
648 }
649 if toolName == "notebook_edit" {
650 if _, exists := obj["new_source"]; !exists {
651 for _, alias := range []string{"content", "source", "new_string"} {
652 var value string
653 if err := json.Unmarshal(obj[alias], &value); err == nil && value != "" {
654 obj["new_source"] = obj[alias]
655 break
656 }
657 }
658 if _, exists := obj["new_source"]; !exists {
659 obj["new_source"] = json.RawMessage(`""`)
660 }
661 changed = true
662 }
663 }
664 if toolName == "bash_output" {
665 obj["block"] = json.RawMessage("false")
666 obj["timeout"] = json.RawMessage("0")
667 changed = true
668 }
669 if toolName == "wait" {
670 obj["block"] = json.RawMessage("true")
671 var jobIDs []string
672 if err := json.Unmarshal(obj["job_ids"], &jobIDs); err == nil && len(jobIDs) == 1 {
673 if body, err := json.Marshal(jobIDs[0]); err == nil {
674 obj["task_id"] = body
675 }
676 }
677 // An unbounded Reasonix wait omits TaskOutput's optional timeout
678 // entirely: in Claude's schema timeout is the maximum wait in ms, so
679 // claiming 0 would read as "don't wait" — the opposite of the call.
680 var timeoutSeconds int64
681 if err := json.Unmarshal(obj["timeout_seconds"], &timeoutSeconds); err == nil && timeoutSeconds > 0 && timeoutSeconds <= (1<<63-1)/1000 {
682 if body, err := json.Marshal(timeoutSeconds * 1000); err == nil {
683 obj["timeout"] = body
684 }
685 }
686 changed = true
687 }
688 if toolName == "ask" && fillClaudeAskDefaults(obj) {
689 changed = true
690 }
691 // parallel_tasks maps to Claude's Agent tool but carries an array of
692 // sub-tasks where Agent has a single prompt — a structural difference no
693 // key rename bridges. Synthesize "prompt" from every sub-task's prompt
694 // (the original "tasks" array stays alongside) so an Agent-scoped guard
695 // reading .tool_input.prompt inspects all dispatched work instead of
696 // failing open on a missing field.
697 if toolName == "parallel_tasks" {
698 if prompt := joinedParallelTaskPrompts(obj["tasks"]); prompt != "" {
699 if v, err := json.Marshal(prompt); err == nil {
700 obj["prompt"] = v
701 changed = true
702 }
703 }
704 }
705 if isAgent {
706 var prompt string
707 _ = json.Unmarshal(obj["prompt"], &prompt)
708 if strings.TrimSpace(prompt) != "" {
709 var description string
710 _ = json.Unmarshal(obj["description"], &description)
711 if strings.TrimSpace(description) == "" {
712 if v, err := json.Marshal(defaultAgentDescription); err == nil {
713 obj["description"] = v
714 changed = true
715 }
716 }
717 }
718 }
719 for _, key := range claudeAbsolutePathInputKeys {
720 v, exists := obj[key]
721 if !exists || cwd == "" {
722 continue
723 }
724 var p string
725 if err := json.Unmarshal(v, &p); err != nil || p == "" || filepath.IsAbs(p) {
726 continue
727 }
728 if abs, err := json.Marshal(filepath.Join(cwd, p)); err == nil {
729 obj[key] = abs
730 changed = true
731 }
732 }
733 if !changed {
734 return args
735 }
736 out, err := json.Marshal(obj)
737 if err != nil {
738 return args
739 }
740 return out
741 }
742
743 // fillClaudeAskDefaults supplies fields Claude requires but Reasonix treats as
744 // optional. Empty option descriptions are honest (Reasonix has no explanation
745 // to add), and omitted multiSelect has the same false default in both systems.
746 func fillClaudeAskDefaults(obj map[string]json.RawMessage) bool {
747 var questions []map[string]json.RawMessage
748 if err := json.Unmarshal(obj["questions"], &questions); err != nil {
749 return false
750 }
751 changed := false
752 for _, question := range questions {
753 if _, exists := question["multiSelect"]; !exists {
754 question["multiSelect"] = json.RawMessage("false")
755 changed = true
756 }
757 var options []map[string]json.RawMessage
758 if err := json.Unmarshal(question["options"], &options); err != nil {
759 continue
760 }
761 optionsChanged := false
762 for _, option := range options {
763 if _, exists := option["description"]; !exists {
764 option["description"] = json.RawMessage(`""`)
765 optionsChanged = true
766 changed = true
767 }
768 }
769 if optionsChanged {
770 body, err := json.Marshal(options)
771 if err != nil {
772 return false
773 }
774 question["options"] = body
775 }
776 }
777 if !changed {
778 return false
779 }
780 body, err := json.Marshal(questions)
781 if err != nil {
782 return false
783 }
784 obj["questions"] = body
785 return true
786 }
787
788 // joinedParallelTaskPrompts flattens a parallel_tasks "tasks" array into one
789 // prompt string, blank-line separated. Malformed or empty input yields "".
790 func joinedParallelTaskPrompts(tasks json.RawMessage) string {
791 if len(tasks) == 0 {
792 return ""
793 }
794 var items []struct {
795 Prompt string `json:"prompt"`
796 }
797 if err := json.Unmarshal(tasks, &items); err != nil {
798 return ""
799 }
800 var prompts []string
801 for _, item := range items {
802 if s := strings.TrimSpace(item.Prompt); s != "" {
803 prompts = append(prompts, s)
804 }
805 }
806 return strings.Join(prompts, "\n\n")
807 }
808
809 // Payload is the JSON envelope written to a hook's stdin.
810 type Payload struct {
811 Event Event `json:"event"`
812 SessionID string `json:"sessionId,omitempty"`
813 Cwd string `json:"cwd"`
814 ToolName string `json:"toolName,omitempty"`
815 ToolArgs json.RawMessage `json:"toolArgs,omitempty"`
816 Subject string `json:"subject,omitempty"`
817 ToolResult string `json:"toolResult,omitempty"`
818 Prompt string `json:"prompt,omitempty"`
819 LastAssistant string `json:"lastAssistantText,omitempty"`
820 Turn int `json:"turn,omitempty"`
821 Message string `json:"message,omitempty"` // Notification: what needs attention
822 Trigger string `json:"trigger,omitempty"` // PreCompact: "auto" | "manual"
823 Reasoning string `json:"reasoning,omitempty"` // PostLLMCall: the model's raw reasoning text
824 Error string `json:"error,omitempty"`
825 Source string `json:"source,omitempty"`
826 Reason string `json:"reason,omitempty"`
827 NotificationType string `json:"notificationType,omitempty"`
828 IsInterrupt bool `json:"isInterrupt,omitempty"`
829 }
830
831 // Decision is a single hook invocation's verdict.
832 type Decision string
833
834 const (
835 DecisionPass Decision = "pass"
836 DecisionBlock Decision = "block"
837 DecisionWarn Decision = "warn"
838 DecisionError Decision = "error" // spawn failed (ENOENT, EACCES, …)
839 )
840
841 // Outcome records one hook invocation.
842 type Outcome struct {
843 Hook ResolvedHook
844 Decision Decision
845 ExitCode int // -1 when unknown (killed / spawn error)
846 Stdout string
847 Stderr string
848 TimedOut bool
849 Truncated bool
850 Duration time.Duration
851 }
852
853 // Report aggregates the outcomes of running an event's hooks.
854 type Report struct {
855 Event Event
856 Outcomes []Outcome
857 Blocked bool // at least one outcome blocked (only meaningful on gating events)
858 // Allowed is set when a Claude-imported PermissionRequest hook returned an
859 // explicit JSON "allow" decision on exit 0 (see claudeJSONAllow) — the
860 // caller should treat this as an auto-approval instead of prompting.
861 Allowed bool
862 }
863
864 // HookOutput is the parsed, model-facing part of a successful hook stdout.
865 type HookOutput struct {
866 AdditionalContext string
867 // Deny and DenyReason carry a Claude-style JSON deny decision returned on
868 // exit 0: hookSpecificOutput.permissionDecision for PreToolUse,
869 // hookSpecificOutput.decision.behavior for PermissionRequest, or a
870 // top-level decision:"block" for UserPromptSubmit. Claude hooks commonly
871 // deny this way instead of exiting 2; see
872 // https://code.claude.com/docs/en/hooks.
873 Deny bool
874 DenyReason string
875 // Allow carries a Claude PermissionRequest "allow" decision
876 // (hookSpecificOutput.decision.behavior == "allow"): the hook answers the
877 // permission dialog on the user's behalf instead of only observing it.
878 Allow bool
879 }
880
881 type hookJSONOutput struct {
882 // Decision and Reason are UserPromptSubmit's (and Stop/SubagentStop's)
883 // top-level deny shape: {"decision":"block","reason":"..."}.
884 Decision string `json:"decision"`
885 Reason string `json:"reason"`
886 HookSpecificOutput struct {
887 HookEventName Event `json:"hookEventName"`
888 AdditionalContext string `json:"additionalContext"`
889 PermissionDecision string `json:"permissionDecision"`
890 PermissionDecisionReason string `json:"permissionDecisionReason"`
891 Decision struct {
892 Behavior string `json:"behavior"`
893 } `json:"decision"`
894 } `json:"hookSpecificOutput"`
895 }
896
897 // ParseOutput extracts hook-specific context from stdout. Plain text is accepted
898 // for SessionStart compatibility; JSON output must identify the current event.
899 func ParseOutput(event Event, stdout string) (HookOutput, []string) {
900 stdout = strings.TrimSpace(stdout)
901 if stdout == "" {
902 return HookOutput{}, nil
903 }
904 if !strings.HasPrefix(stdout, "{") {
905 if event == SessionStart {
906 return HookOutput{AdditionalContext: stdout}, nil
907 }
908 return HookOutput{}, nil
909 }
910 var parsed hookJSONOutput
911 if err := json.Unmarshal([]byte(stdout), &parsed); err != nil {
912 return HookOutput{}, []string{fmt.Sprintf("hook %s returned invalid JSON stdout: %v", event, err)}
913 }
914 spec := parsed.HookSpecificOutput
915 topLevelDeny := event == UserPromptSubmit && strings.EqualFold(parsed.Decision, "block")
916 deny := strings.EqualFold(spec.PermissionDecision, "deny") || strings.EqualFold(spec.Decision.Behavior, "deny") || topLevelDeny
917 allow := event == PermissionRequest && strings.EqualFold(spec.Decision.Behavior, "allow")
918 if spec.HookEventName == "" && strings.TrimSpace(spec.AdditionalContext) == "" && !deny && !allow {
919 return HookOutput{}, nil
920 }
921 if spec.HookEventName != "" && spec.HookEventName != event {
922 return HookOutput{}, []string{fmt.Sprintf("hook output event %q does not match current event %q", spec.HookEventName, event)}
923 }
924 out := HookOutput{AdditionalContext: strings.TrimSpace(spec.AdditionalContext)}
925 if deny {
926 out.Deny = true
927 reason := spec.PermissionDecisionReason
928 if topLevelDeny {
929 reason = parsed.Reason
930 }
931 out.DenyReason = strings.TrimSpace(reason)
932 }
933 out.Allow = allow
934 return out, nil
935 }
936
937 // decideOutcome maps a spawn result to a verdict for hook h.
938 func decideOutcome(h ResolvedHook, r SpawnResult) Decision {
939 blocking := IsBlocking(h.Event) || claudePermissionBlocking(h)
940 switch {
941 case r.SpawnErr != nil:
942 return DecisionError
943 case r.TimedOut:
944 if blocking {
945 return DecisionBlock
946 }
947 return DecisionWarn
948 case r.ExitCode == 0:
949 return DecisionPass
950 case r.ExitCode == 2 && blocking:
951 return DecisionBlock
952 default:
953 return DecisionWarn
954 }
955 }
956
957 // claudeJSONDeny reports whether a Claude-format hook's exit-0 stdout still
958 // carries a JSON deny decision (see HookOutput.Deny). Reasonix must honor it
959 // for the events it claims Claude hook compatibility for, or a plugin's
960 // "block this dangerous command" hook silently no-ops whenever the script
961 // signals deny via JSON instead of exit code 2. UserPromptSubmit uses a
962 // top-level decision:"block" instead of PreToolUse/PermissionRequest's
963 // hookSpecificOutput shape; ParseOutput handles both.
964 func claudeJSONDeny(event Event, stdout string) (bool, string) {
965 if event != PreToolUse && event != PermissionRequest && event != UserPromptSubmit {
966 return false, ""
967 }
968 out, _ := ParseOutput(event, stdout)
969 return out.Deny, out.DenyReason
970 }
971
972 // claudeJSONAllow reports whether a Claude-format PermissionRequest hook's
973 // exit-0 stdout carries an explicit "allow" decision
974 // (hookSpecificOutput.decision.behavior == "allow"): the hook answers the
975 // permission dialog on the user's behalf, same as an exit-2 deny preempts it.
976 func claudeJSONAllow(event Event, stdout string) bool {
977 if event != PermissionRequest {
978 return false
979 }
980 out, _ := ParseOutput(event, stdout)
981 return out.Allow
982 }
983
984 // SpawnInput / SpawnResult / Spawner are the test seam around the real spawn.
985 type SpawnInput struct {
986 Command string
987 Args []string
988 Mode ExecutionMode
989 Shell string
990 Cwd string
991 Env map[string]string
992 Stdin string
993 Timeout time.Duration
994 }
995
996 // RuntimeOptions carries resolved host dependencies into Hook execution.
997 // It is runtime-only and never changes persisted Hook configuration.
998 type RuntimeOptions struct {
999 BashPath string
1000 }
1001
1002 // RuntimeOptionsForShell carries an explicitly configured Bash path into Hook
1003 // execution while leaving other interpreter preferences independent.
1004 func RuntimeOptionsForShell(prefer, path string) RuntimeOptions {
1005 if !strings.EqualFold(strings.TrimSpace(prefer), "bash") {
1006 return RuntimeOptions{}
1007 }
1008 return RuntimeOptions{BashPath: strings.TrimSpace(path)}
1009 }
1010
1011 // RuntimeIssue identifies one plugin Hook whose host dependency is unavailable.
1012 type RuntimeIssue struct {
1013 Event Event
1014 Description string
1015 Err error
1016 }
1017
1018 // CheckPackageRuntime validates every Hook exported by a plugin package without
1019 // launching commands.
1020 func CheckPackageRuntime(pkg pluginpkg.Package, options RuntimeOptions) []RuntimeIssue {
1021 events := make([]string, 0, len(pkg.Manifest.Hooks))
1022 for event := range pkg.Manifest.Hooks {
1023 events = append(events, event)
1024 }
1025 sort.Strings(events)
1026 var issues []RuntimeIssue
1027 for _, eventName := range events {
1028 for _, h := range pkg.Manifest.Hooks[eventName] {
1029 if err := CheckRuntime(pluginHookExecutionConfig(h, pkg.Root), options); err != nil {
1030 issues = append(issues, RuntimeIssue{
1031 Event: Event(eventName), Description: h.Description, Err: err,
1032 })
1033 }
1034 }
1035 }
1036 return issues
1037 }
1038
1039 type SpawnResult struct {
1040 ExitCode int
1041 Stdout string
1042 Stderr string
1043 TimedOut bool
1044 SpawnErr error
1045 Truncated bool
1046 }
1047
1048 type Spawner func(ctx context.Context, in SpawnInput) SpawnResult
1049
1050 // outputCapBytes bounds per-stream capture so a runaway child can't blow up the
1051 // heap between spawn and timeout.
1052 const outputCapBytes = 256 * 1024
1053
1054 // Run executes the hooks matching payload.Event (and, for tool events, the tool
1055 // name), feeding each the JSON payload on stdin. It stops at the first block so
1056 // a gating hook can prevent later hooks running against a phantom success.
1057 func Run(ctx context.Context, payload Payload, hooks []ResolvedHook, spawner Spawner) Report {
1058 if spawner == nil {
1059 spawner = DefaultSpawner
1060 }
1061 event := payload.Event
1062 report := Report{Event: event}
1063 for _, h := range hooks {
1064 if h.Event != event || !MatchesTool(h, payload.ToolName) {
1065 continue
1066 }
1067 cwd := h.Cwd
1068 if cwd == "" {
1069 cwd = payload.Cwd
1070 }
1071 timeout := h.timeout()
1072 stdin := marshalPayload(payload, h.PayloadFormat)
1073 input := SpawnInput{
1074 Command: h.Command,
1075 Args: h.Argv,
1076 Mode: h.ExecutionMode,
1077 Shell: h.Shell,
1078 Cwd: cwd,
1079 Env: h.Env,
1080 Stdin: stdin,
1081 Timeout: timeout,
1082 }
1083 if h.Async {
1084 asyncCtx := context.WithoutCancel(ctx)
1085 go runResolvedHook(asyncCtx, h, input, spawner)
1086 report.Outcomes = append(report.Outcomes, Outcome{Hook: h, Decision: DecisionPass})
1087 continue
1088 }
1089 start := time.Now()
1090 r := runResolvedHook(ctx, h, input, spawner)
1091 decision := decideOutcome(h, r)
1092 if decision == DecisionPass && h.PayloadFormat == "claude" {
1093 if deny, reason := claudeJSONDeny(event, r.Stdout); deny {
1094 decision = DecisionBlock
1095 if reason != "" {
1096 r.Stdout = reason
1097 }
1098 } else if claudeJSONAllow(event, r.Stdout) {
1099 report.Allowed = true
1100 }
1101 }
1102 report.Outcomes = append(report.Outcomes, Outcome{
1103 Hook: h,
1104 Decision: decision,
1105 ExitCode: r.ExitCode,
1106 Stdout: r.Stdout,
1107 Stderr: stderrFor(r, timeout),
1108 TimedOut: r.TimedOut,
1109 Truncated: r.Truncated,
1110 Duration: time.Since(start),
1111 })
1112 if decision == DecisionBlock {
1113 report.Blocked = true
1114 break
1115 }
1116 }
1117 return report
1118 }
1119
1120 func marshalPayload(payload Payload, format string) string {
1121 var body []byte
1122 if format == "claude" {
1123 claude := map[string]any{
1124 "hook_event_name": payload.Event,
1125 "session_id": payload.SessionID,
1126 "cwd": payload.Cwd,
1127 "tool_name": claudeFacingToolName(payload.ToolName),
1128 "tool_input": claudeFacingToolInput(payload.ToolName, payload.ToolArgs, payload.Cwd),
1129 "tool_response": claudeToolResponse(payload),
1130 "prompt": payload.Prompt,
1131 "last_assistant_message": payload.LastAssistant,
1132 "source": payload.Source,
1133 "reason": payload.Reason,
1134 "notification_type": payload.NotificationType,
1135 "message": payload.Message,
1136 "trigger": payload.Trigger,
1137 "error": payload.Error,
1138 "is_interrupt": payload.IsInterrupt,
1139 }
1140 body, _ = json.Marshal(claude)
1141 } else {
1142 body, _ = json.Marshal(payload)
1143 }
1144 return string(body) + "\n"
1145 }
1146
1147 // claudeToolResponse adapts a Reasonix tool result to the tool_response a
1148 // Claude-authored PostToolUse hook reads. Claude's Bash response is an object
1149 // — {stdout, stderr, interrupted}, the fields the official security-guidance
1150 // plugin's commit/push checks read (a non-object response is treated as empty
1151 // and the check silently passes) — while Reasonix's bash returns one combined
1152 // output string, so it is wrapped with the failure error as stderr. Other
1153 // tools' results pass through as before: raw JSON when the result is a JSON
1154 // document, else the plain string.
1155 func claudeToolResponse(p Payload) any {
1156 if (p.Event == PostToolUse || p.Event == PostToolUseFailure) && claudeFacingToolName(p.ToolName) == "Bash" {
1157 return map[string]any{
1158 "stdout": p.ToolResult,
1159 "stderr": p.Error,
1160 "interrupted": p.IsInterrupt,
1161 }
1162 }
1163 trimmed := strings.TrimSpace(p.ToolResult)
1164 if trimmed == "" || !json.Valid([]byte(trimmed)) {
1165 return p.ToolResult
1166 }
1167 return json.RawMessage(trimmed)
1168 }
1169
1170 func runResolvedHook(ctx context.Context, h ResolvedHook, in SpawnInput, spawner Spawner) SpawnResult {
1171 if h.Scope == ScopePlugin && h.ContextFile != "" {
1172 return readContextFile(h.ContextFile)
1173 }
1174 return spawner(ctx, in)
1175 }
1176
1177 func readContextFile(path string) SpawnResult {
1178 body, err := fileencoding.ReadFileUTF8(path)
1179 if err != nil {
1180 return SpawnResult{ExitCode: -1, SpawnErr: err}
1181 }
1182 truncated := false
1183 if len(body) > outputCapBytes {
1184 body = body[:outputCapBytes]
1185 truncated = true
1186 }
1187 return SpawnResult{ExitCode: 0, Stdout: string(body), Truncated: truncated}
1188 }
1189
1190 // stderrFor returns the best human message for an outcome: real stderr, else a
1191 // spawn-error message, else a timeout note.
1192 func stderrFor(r SpawnResult, timeout time.Duration) string {
1193 if r.Stderr != "" {
1194 return r.Stderr
1195 }
1196 if r.SpawnErr != nil {
1197 return r.SpawnErr.Error()
1198 }
1199 if r.TimedOut {
1200 return fmt.Sprintf("hook timed out after %s", timeout)
1201 }
1202 return ""
1203 }
1204
1205 // DefaultSpawner executes the hook according to its explicit execution
1206 // contract, with the payload on stdin, capped output, and both per-hook timeout
1207 // and parent-context cancellation.
1208 func DefaultSpawner(ctx context.Context, in SpawnInput) SpawnResult {
1209 return defaultSpawner(ctx, in, RuntimeOptions{})
1210 }
1211
1212 // NewDefaultSpawner returns the standard Hook spawner with effective host
1213 // runtime paths supplied by boot configuration.
1214 func NewDefaultSpawner(options RuntimeOptions) Spawner {
1215 return func(ctx context.Context, in SpawnInput) SpawnResult {
1216 return defaultSpawner(ctx, in, options)
1217 }
1218 }
1219
1220 func defaultSpawner(ctx context.Context, in SpawnInput, options RuntimeOptions) SpawnResult {
1221 in = normalizeWindowsHookSpawnInputForPlatform(in, runtime.GOOS)
1222 cctx, cancel := context.WithTimeout(ctx, in.Timeout)
1223 defer cancel()
1224
1225 cmd, spawnErr := spawnCommand(cctx, in.Command, in.Mode, in.Shell, in.Args, options)
1226 if spawnErr != nil {
1227 return SpawnResult{ExitCode: -1, SpawnErr: spawnErr}
1228 }
1229 proc.HideWindow(cmd)
1230 cmd.Dir = in.Cwd
1231 env := secrets.ProcessEnv()
1232 if len(in.Env) > 0 {
1233 keys := make([]string, 0, len(in.Env))
1234 for k := range in.Env {
1235 keys = append(keys, k)
1236 }
1237 sort.Strings(keys)
1238 for _, k := range keys {
1239 env = append(env, k+"="+in.Env[k])
1240 }
1241 }
1242 cmd.Env = env
1243 cmd.Stdin = strings.NewReader(in.Stdin)
1244 var outBuf, errBuf cappedBuffer
1245 cmd.Stdout = &outBuf
1246 cmd.Stderr = &errBuf
1247 // WaitDelay bounds Wait even if a grandchild keeps a pipe open after the
1248 // shell is killed on timeout/cancel.
1249 cmd.WaitDelay = 500 * time.Millisecond
1250
1251 err := cmd.Run()
1252 res := SpawnResult{
1253 ExitCode: -1,
1254 Stdout: decodeHookOutput(outBuf.Bytes(), outBuf.truncated),
1255 Stderr: decodeHookOutput(errBuf.Bytes(), errBuf.truncated),
1256 Truncated: outBuf.truncated || errBuf.truncated,
1257 }
1258 switch {
1259 case cctx.Err() == context.DeadlineExceeded:
1260 res.TimedOut = true
1261 case cctx.Err() == context.Canceled:
1262 res.SpawnErr = cctx.Err()
1263 case err != nil:
1264 var exitErr *exec.ExitError
1265 if errors.As(err, &exitErr) {
1266 res.ExitCode = exitErr.ExitCode()
1267 } else {
1268 res.SpawnErr = err
1269 }
1270 default:
1271 res.ExitCode = 0
1272 }
1273 return res
1274 }
1275
1276 // spawnCommand picks the execution vehicle from the manifest contract.
1277 // Explicit exec-form hooks pass their argv directly to the executable;
1278 // explicit shell-form hooks pass the raw command to the selected interpreter.
1279 // Legacy settings retain Reasonix's historical shell behavior and repairs.
1280 func spawnCommand(ctx context.Context, command string, mode ExecutionMode, shell string, args []string, options RuntimeOptions) (*exec.Cmd, error) {
1281 switch mode {
1282 case ExecutionExec:
1283 return spawnExecCommand(ctx, command, args, options)
1284 case ExecutionShell:
1285 return spawnShellCommand(ctx, command, shell, options)
1286 case ExecutionLegacy:
1287 return spawnLegacyCommand(ctx, command, args, options)
1288 default:
1289 return nil, fmt.Errorf("unsupported hook execution mode %q", mode)
1290 }
1291 }
1292
1293 func spawnExecCommand(ctx context.Context, command string, args []string, options RuntimeOptions) (*exec.Cmd, error) {
1294 if runtime.GOOS == "windows" {
1295 if cmd, matched := windowsBatchArgvCommand(ctx, command, args); matched {
1296 return cmd, nil
1297 }
1298 if resolvedShell, resolvedArgs, matched, err := windowsPOSIXShellArgvInvocationWith(command, args, func() (string, error) {
1299 return resolveWindowsHookBash(options.BashPath)
1300 }); matched {
1301 if err != nil {
1302 return nil, err
1303 }
1304 return proc.CommandContext(ctx, resolvedShell, resolvedArgs...), nil
1305 }
1306 }
1307 return proc.CommandContext(ctx, command, args...), nil
1308 }
1309
1310 // spawnLegacyCommand preserves the pre-contract behavior:
1311 // - a command this call just repaired (its broken quoting means it never
1312 // worked through a shell, so there is no expansion behavior to preserve);
1313 // - on Windows, a recognized node -e stdin-hook command: `cmd /c` mangles
1314 // quoted JS (&, %, nested quotes), which is the breakage this repair
1315 // exists for, and cmd performs no POSIX-style $ expansion to preserve.
1316 // - on Windows, an explicit `sh -c` / `bash -c` command: Git Bash is often
1317 // installed outside cmd.exe's PATH, and direct exec preserves its quoting.
1318 //
1319 // POSIX commands that were already well-formed keep their shell semantics
1320 // verbatim — normalizeStaticNodeEval's rendering escapes $ and backticks, so
1321 // even repaired commands re-entering here behave identically under sh -c.
1322 func spawnLegacyCommand(ctx context.Context, command string, args []string, options RuntimeOptions) (*exec.Cmd, error) {
1323 if args != nil {
1324 return spawnExecCommand(ctx, command, args, options)
1325 }
1326 if node, flag, script, ok := repairableNodeEvalArgs(command); ok {
1327 return proc.CommandContext(ctx, node, flag, script), nil
1328 }
1329 if powershell, args, ok := repairablePowerShellFileArgs(command); ok {
1330 return proc.CommandContext(ctx, powershell, args...), nil
1331 }
1332 if runtime.GOOS == "windows" {
1333 if cmd, matched := windowsBatchCommand(ctx, command); matched {
1334 return cmd, nil
1335 }
1336 if shell, args, matched, err := windowsPOSIXShellInvocationWith(command, func() (string, error) {
1337 return resolveWindowsHookBash(options.BashPath)
1338 }); matched {
1339 if err != nil {
1340 return nil, err
1341 }
1342 return proc.CommandContext(ctx, shell, args...), nil
1343 }
1344 if node, flag, script, ok := directNodeEvalArgs(command); ok {
1345 return proc.CommandContext(ctx, node, flag, script), nil
1346 }
1347 if cmd, ok := windowsCmdShellCommand(ctx, command); ok {
1348 return cmd, nil
1349 }
1350 }
1351 name, args := shellInvocation(command)
1352 return proc.CommandContext(ctx, name, args...), nil
1353 }
1354
1355 func spawnShellCommand(ctx context.Context, command, preferred string, options RuntimeOptions) (*exec.Cmd, error) {
1356 preferred = strings.ToLower(strings.TrimSpace(preferred))
1357 switch preferred {
1358 case "", "auto":
1359 if runtime.GOOS == "windows" {
1360 // Retain the established #6668 compatibility path for the common
1361 // quoted .cmd/.bat hook shape. More complex scripts continue to
1362 // the selected shell without being parsed or re-rendered.
1363 if cmd, matched := windowsBatchCommand(ctx, command); matched {
1364 return cmd, nil
1365 }
1366 sh, err := cachedWindowsDefaultHookShell()
1367 if err != nil {
1368 return nil, err
1369 }
1370 return rawShellCommand(ctx, sh, command)
1371 }
1372 return proc.CommandContext(ctx, "sh", "-c", command), nil
1373 case "bash":
1374 if runtime.GOOS == "windows" {
1375 path, err := resolveWindowsHookBash(options.BashPath)
1376 if err != nil {
1377 return nil, err
1378 }
1379 return proc.CommandContext(ctx, path, "-c", command), nil
1380 }
1381 return proc.CommandContext(ctx, "bash", "-c", command), nil
1382 case "powershell", "pwsh":
1383 sh := sandbox.ResolveShell(preferred, "", nil)
1384 if sh.Kind != sandbox.ShellPowerShell {
1385 return nil, fmt.Errorf("hook requires %s, but no usable PowerShell was found", preferred)
1386 }
1387 path, err := resolvedHookShellPath(sh)
1388 if err != nil {
1389 return nil, err
1390 }
1391 return powerShellCommand(ctx, path, command), nil
1392 case "cmd":
1393 if cmd, ok := windowsCmdShellCommand(ctx, command); ok {
1394 return cmd, nil
1395 }
1396 return nil, errors.New("hook shell \"cmd\" is only available on Windows")
1397 default:
1398 return nil, fmt.Errorf("unsupported hook shell %q", preferred)
1399 }
1400 }
1401
1402 // CheckRuntime reports an unavailable host dependency without running a Hook.
1403 func CheckRuntime(config HookConfig, options RuntimeOptions) error {
1404 return checkRuntimeForPlatform(config, options, runtime.GOOS, resolveWindowsHookBash)
1405 }
1406
1407 func checkRuntimeForPlatform(config HookConfig, options RuntimeOptions, goos string, resolveBash func(string) (string, error)) error {
1408 if goos != "windows" || !requiresWindowsBash(config) {
1409 return nil
1410 }
1411 _, err := resolveBash(options.BashPath)
1412 return err
1413 }
1414
1415 func requiresWindowsBash(config HookConfig) bool {
1416 return requiresWindowsBashForHook(config)
1417 }
1418
1419 func rawShellCommand(ctx context.Context, sh sandbox.Shell, command string) (*exec.Cmd, error) {
1420 path, err := resolvedHookShellPath(sh)
1421 if err != nil {
1422 return nil, err
1423 }
1424 if sh.Kind == sandbox.ShellPowerShell {
1425 return powerShellCommand(ctx, path, command), nil
1426 }
1427 return proc.CommandContext(ctx, path, "-c", command), nil
1428 }
1429
1430 func powerShellCommand(ctx context.Context, path, command string) *exec.Cmd {
1431 // PowerShell's native command-line parser does not follow
1432 // CommandLineToArgvW consistently for a complex -Command argument.
1433 // -EncodedCommand transports the exact script as UTF-16LE and avoids a
1434 // second layer of quote/backslash interpretation. Force captured output to
1435 // UTF-8 before encoding so Windows PowerShell does not emit the host console
1436 // code page into Reasonix's stdout/stderr text contract.
1437 command = sandbox.PowerShellUTF8Script(command)
1438 codeUnits := utf16.Encode([]rune(command))
1439 raw := make([]byte, len(codeUnits)*2)
1440 for i, unit := range codeUnits {
1441 raw[i*2] = byte(unit)
1442 raw[i*2+1] = byte(unit >> 8)
1443 }
1444 encoded := base64.StdEncoding.EncodeToString(raw)
1445 return proc.CommandContext(ctx, path, "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded)
1446 }
1447
1448 func shellInvocation(command string) (string, []string) {
1449 if runtime.GOOS == "windows" {
1450 return "cmd", []string{"/c", command}
1451 }
1452 return "sh", []string{"-c", command}
1453 }
1454
1455 // cappedBuffer is an io.Writer that stops storing after outputCapBytes and
1456 // records that it truncated, but keeps reporting full writes so the child never
1457 // sees a short-write error.
1458 type cappedBuffer struct {
1459 buf bytes.Buffer
1460 truncated bool
1461 }
1462
1463 func (c *cappedBuffer) Write(p []byte) (int, error) {
1464 remaining := outputCapBytes - c.buf.Len()
1465 if remaining <= 0 {
1466 c.truncated = true
1467 return len(p), nil
1468 }
1469 if len(p) > remaining {
1470 c.buf.Write(p[:remaining])
1471 c.truncated = true
1472 return len(p), nil
1473 }
1474 c.buf.Write(p)
1475 return len(p), nil
1476 }
1477
1478 func (c *cappedBuffer) Bytes() []byte { return c.buf.Bytes() }
1479 func (c *cappedBuffer) String() string { return c.buf.String() }
1480
1481 func reasonixHome(override string) string {
1482 if override != "" {
1483 return filepath.Join(override, SettingsDirname)
1484 }
1485 if dir := config.ReasonixHomeDir(); dir != "" {
1486 return dir
1487 }
1488 if h, err := os.UserHomeDir(); err == nil {
1489 return filepath.Join(h, SettingsDirname)
1490 }
1491 return ""
1492 }
1493
1494 func reasonixHomeForOptions(opts LoadOptions) string {
1495 if dir := strings.TrimSpace(opts.ReasonixHomeDir); dir != "" {
1496 return filepath.Clean(dir)
1497 }
1498 return reasonixHome(opts.HomeDir)
1499 }
1500
1501 func legacyGlobalSettingsPath(homeDir string) string {
1502 dir := legacyReasonixHome(homeDir)
1503 if dir == "" {
1504 return ""
1505 }
1506 return filepath.Join(dir, SettingsFilename)
1507 }
1508
1509 func legacyReasonixHome(override string) string {
1510 if override != "" {
1511 return ""
1512 }
1513 if config.IsolatedHomeDir() != "" {
1514 return ""
1515 }
1516 home, err := os.UserHomeDir()
1517 if err != nil || home == "" {
1518 return ""
1519 }
1520 legacy := filepath.Join(home, SettingsDirname)
1521 if sameCleanPath(legacy, reasonixHome("")) {
1522 return ""
1523 }
1524 return legacy
1525 }
1526
1527 func sameCleanPath(a, b string) bool {
1528 if strings.TrimSpace(a) == "" || strings.TrimSpace(b) == "" {
1529 return false
1530 }
1531 if aa, err := filepath.Abs(a); err == nil {
1532 a = aa
1533 }
1534 if bb, err := filepath.Abs(b); err == nil {
1535 b = bb
1536 }
1537 return filepath.Clean(a) == filepath.Clean(b)
1538 }
1539
1539 lines GO