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