mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* Edit safety: file version tracking, conflict detection, hook wiring
Phase 1 — FileTracker (internal/tools/file_tracker.go): per-session
map[absPath]{sha256, size, mtime}; CheckConflict flags drift only against
a known baseline, so first-touch writes are unaffected. nil = no-op.
Phase 2 — external-modification detection: read_file records the
whole-file baseline; edit_file and write_file --overwrite refuse to
clobber a file whose on-disk content drifted from it and tell the model
to re-read first, then re-baseline to what they wrote. apply_patch
forgets the files it rewrote (git apply has its own context guard).
Threaded via RunOptions/agent.Options FileTracker, built once per session
in the exec, spec, and TUI paths.
Phase 3 — post-apply validation via hooks: the dormant hooks.Dispatcher
is wired into executeToolCall. beforeTool hooks can veto a call
(DenialHookBlocked); afterTool hook output is scrubbed and surfaced to
the model. DispatchOutcome now collects hook stdout/stderr. Per-session
dispatcher built from merged user+project hooks.json; fails open and runs
nothing until a hook is configured.
* edit-safety: address review — fail closed on read, redact hook reason
- write_file overwrite: if a tracked file cannot be re-read to verify it,
fail CLOSED (refuse the overwrite) instead of silently proceeding — the
read error previously skipped the conflict check and could clobber an
externally modified or replaced file.
- blockedByHookResult: scrub outcome.Reason through redaction before placing
it in model-visible ToolResult.Output; this intercepted path bypassed the
registry's normal output-redaction boundary.
- TestHashContentIsStableAndDistinguishing: compare separately stored hashes
so the stability check is not a same-expression comparison (SA4000).
* edit-safety: flag ToolResult.Redacted when hook output is scrubbed
appendHookFeedback now reports whether scrubbing changed the afterTool
feedback, and blockedByHookResult reports whether it scrubbed the reason;
both set ToolResult.Redacted so the hook-intercepted paths match the
registry's redaction contract. Adds tests asserting a secret-bearing hook
reason/feedback is scrubbed and flips Redacted.
279 lines
8.9 KiB
Go
279 lines
8.9 KiB
Go
package hooks
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// defaultHookTimeout bounds a single hook command so a hung or slow hook cannot
|
|
// stall the agent indefinitely.
|
|
const defaultHookTimeout = 30 * time.Second
|
|
|
|
// DispatchInput describes one lifecycle point at which hooks may run.
|
|
type DispatchInput struct {
|
|
Event Event
|
|
ToolName string // for beforeTool/afterTool matching
|
|
ToolCallID string
|
|
// Payload is serialized to JSON and written to each hook command's stdin so a
|
|
// hook can inspect the tool call, its result, or session context.
|
|
Payload any
|
|
}
|
|
|
|
// DispatchOutcome reports what happened for one Dispatch call.
|
|
type DispatchOutcome struct {
|
|
Ran int // hooks that executed
|
|
Blocked bool // a blocking-event hook exited non-zero, vetoing the action
|
|
BlockedBy string // ID of the hook that blocked (empty unless Blocked)
|
|
Reason string // the blocking hook's stderr/stdout, for surfacing to the model
|
|
// Messages collects the output (stdout, else stderr) of each hook that
|
|
// produced any, in run order. afterTool validators use this to feed results
|
|
// (e.g. a formatter diff or vet warning) back to the model on the tool result.
|
|
Messages []string
|
|
}
|
|
|
|
type commandResult struct {
|
|
ExitCode int
|
|
Stdout string
|
|
Stderr string
|
|
Err error // set when the command could not be executed (not a non-zero exit)
|
|
TimedOut bool // the hook started but its deadline/cancellation fired before it returned
|
|
}
|
|
|
|
// commandRunner executes one hook command. It is injectable so the dispatch
|
|
// logic can be tested without spawning processes.
|
|
type commandRunner func(ctx context.Context, command string, args []string, stdin []byte, cwd string, env []string) commandResult
|
|
|
|
// DispatcherOptions configures a Dispatcher.
|
|
type DispatcherOptions struct {
|
|
Config Config
|
|
Audit *AuditStore // optional; when set, every run is recorded
|
|
Cwd string // working directory for hook commands
|
|
Env []string // extra environment entries appended to the process env
|
|
Timeout time.Duration // per-command timeout (defaults to defaultHookTimeout)
|
|
now func() time.Time
|
|
run commandRunner
|
|
}
|
|
|
|
// Dispatcher selects and runs the hooks configured for a lifecycle event,
|
|
// recording each run to the audit store. A beforeTool hook that exits non-zero
|
|
// blocks the tool; hooks for other events are advisory (failures are recorded
|
|
// but do not interrupt the run).
|
|
type Dispatcher struct {
|
|
config Config
|
|
audit *AuditStore
|
|
cwd string
|
|
env []string
|
|
timeout time.Duration
|
|
now func() time.Time
|
|
run commandRunner
|
|
}
|
|
|
|
// NewDispatcher builds a Dispatcher. A zero/empty config yields a dispatcher that
|
|
// runs nothing, so callers can always construct one unconditionally.
|
|
func NewDispatcher(options DispatcherOptions) *Dispatcher {
|
|
timeout := options.Timeout
|
|
if timeout <= 0 {
|
|
timeout = defaultHookTimeout
|
|
}
|
|
now := options.now
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
run := options.run
|
|
if run == nil {
|
|
run = execCommandRunner
|
|
}
|
|
return &Dispatcher{
|
|
config: options.Config,
|
|
audit: options.Audit,
|
|
cwd: options.Cwd,
|
|
env: options.Env,
|
|
timeout: timeout,
|
|
now: now,
|
|
run: run,
|
|
}
|
|
}
|
|
|
|
// blocksOn reports whether a non-zero exit for this event should veto the action.
|
|
// Only beforeTool gates the tool; other events are observational.
|
|
func blocksOn(event Event) bool {
|
|
return event == EventBeforeTool
|
|
}
|
|
|
|
// Dispatch runs every enabled hook configured for the input event (and matcher,
|
|
// for tool events). It returns once all hooks have run, or early if a blocking
|
|
// hook vetoes the action.
|
|
func (dispatcher *Dispatcher) Dispatch(ctx context.Context, input DispatchInput) DispatchOutcome {
|
|
outcome := DispatchOutcome{}
|
|
if dispatcher == nil {
|
|
return outcome
|
|
}
|
|
selected := Select(dispatcher.config, SelectInput{Event: input.Event, ToolName: input.ToolName})
|
|
if len(selected) == 0 {
|
|
return outcome
|
|
}
|
|
|
|
payload, err := json.Marshal(input.Payload)
|
|
if err != nil {
|
|
payload = nil
|
|
}
|
|
|
|
for _, hook := range selected {
|
|
command := Command{Command: hook.Command, Args: hook.Args}
|
|
dispatcher.recordStarted(hook, input, command)
|
|
|
|
started := dispatcher.now()
|
|
result := dispatcher.runWithTimeout(ctx, hook, payload)
|
|
durationMs := int(dispatcher.now().Sub(started) / time.Millisecond)
|
|
outcome.Ran++
|
|
|
|
status, blocked := classifyResult(input.Event, result)
|
|
dispatcher.recordCompleted(hook, input, status, result, durationMs)
|
|
|
|
if message := hookMessage(result); message != "" {
|
|
outcome.Messages = append(outcome.Messages, message)
|
|
}
|
|
|
|
if blocked {
|
|
outcome.Blocked = true
|
|
outcome.BlockedBy = hook.ID
|
|
outcome.Reason = blockReason(result)
|
|
// Stop on the first veto: the action is already denied.
|
|
return outcome
|
|
}
|
|
}
|
|
return outcome
|
|
}
|
|
|
|
func (dispatcher *Dispatcher) runWithTimeout(ctx context.Context, hook Definition, stdin []byte) commandResult {
|
|
runCtx, cancel := context.WithTimeout(ctx, dispatcher.timeout)
|
|
defer cancel()
|
|
env := os.Environ()
|
|
if len(dispatcher.env) > 0 {
|
|
env = append(env, dispatcher.env...)
|
|
}
|
|
result := dispatcher.run(runCtx, hook.Command, hook.Args, stdin, dispatcher.cwd, env)
|
|
// A deadline or cancellation that fired while the hook was running is distinct
|
|
// from a launch failure: the hook DID start, we just never got its verdict.
|
|
// classifyResult must fail CLOSED on this for a blocking event — a hung policy
|
|
// hook cannot be allowed to wave the tool through.
|
|
if runCtx.Err() != nil {
|
|
result.TimedOut = true
|
|
}
|
|
return result
|
|
}
|
|
|
|
// classifyResult maps a command result to an audit status and whether it vetoes
|
|
// the action. A command that ran and exited non-zero blocks a beforeTool hook; a
|
|
// command that could not be executed at all is an error but never blocks (a
|
|
// missing hook binary must not wedge every tool call).
|
|
func classifyResult(event Event, result commandResult) (AuditStatus, bool) {
|
|
// A hook that started but was killed by its deadline (or a cancellation) never
|
|
// returned a verdict. For a blocking event we must fail CLOSED and veto: a
|
|
// hung beforeTool policy hook cannot be treated as approval. (A launch failure
|
|
// below still fails OPEN, so a misconfigured hook doesn't wedge every tool.)
|
|
if result.TimedOut {
|
|
if blocksOn(event) {
|
|
return AuditBlocked, true
|
|
}
|
|
return AuditError, false
|
|
}
|
|
if result.Err != nil {
|
|
return AuditError, false
|
|
}
|
|
if result.ExitCode != 0 {
|
|
if blocksOn(event) {
|
|
return AuditBlocked, true
|
|
}
|
|
return AuditError, false
|
|
}
|
|
return AuditCompleted, false
|
|
}
|
|
|
|
// hookMessage returns the output worth surfacing from a hook run: stdout when
|
|
// present, else stderr. Empty when the hook produced no output.
|
|
func hookMessage(result commandResult) string {
|
|
if trimmed := strings.TrimSpace(result.Stdout); trimmed != "" {
|
|
return trimmed
|
|
}
|
|
return strings.TrimSpace(result.Stderr)
|
|
}
|
|
|
|
func blockReason(result commandResult) string {
|
|
if result.TimedOut {
|
|
if trimmed := strings.TrimSpace(result.Stderr); trimmed != "" {
|
|
return "hook timed out: " + trimmed
|
|
}
|
|
return "hook timed out before returning a verdict"
|
|
}
|
|
for _, candidate := range []string{result.Stderr, result.Stdout} {
|
|
if trimmed := strings.TrimSpace(candidate); trimmed != "" {
|
|
return trimmed
|
|
}
|
|
}
|
|
return "hook exited non-zero"
|
|
}
|
|
|
|
func (dispatcher *Dispatcher) recordStarted(hook Definition, input DispatchInput, command Command) {
|
|
if dispatcher.audit == nil {
|
|
return
|
|
}
|
|
_, _ = dispatcher.audit.AppendStarted(AppendStartedInput{
|
|
HookID: hook.ID,
|
|
Event: input.Event,
|
|
Matcher: hook.Matcher,
|
|
ToolCallID: input.ToolCallID,
|
|
Commands: []AuditCommand{command},
|
|
})
|
|
}
|
|
|
|
func (dispatcher *Dispatcher) recordCompleted(hook Definition, input DispatchInput, status AuditStatus, result commandResult, durationMs int) {
|
|
if dispatcher.audit == nil {
|
|
return
|
|
}
|
|
_, _ = dispatcher.audit.AppendCompleted(AppendCompletedInput{
|
|
HookID: hook.ID,
|
|
Event: input.Event,
|
|
Matcher: hook.Matcher,
|
|
ToolCallID: input.ToolCallID,
|
|
Status: status,
|
|
Results: []AuditResult{{ExitCode: result.ExitCode, Stdout: result.Stdout, Stderr: result.Stderr}},
|
|
DurationMs: durationMs,
|
|
})
|
|
}
|
|
|
|
// execCommandRunner runs a hook command directly (no shell), feeding the JSON
|
|
// payload on stdin and capturing stdout/stderr. A non-zero exit is reported via
|
|
// ExitCode (not Err); Err is reserved for commands that could not be launched.
|
|
func execCommandRunner(ctx context.Context, command string, args []string, stdin []byte, cwd string, env []string) commandResult {
|
|
cmd := exec.CommandContext(ctx, command, args...)
|
|
cmd.Dir = cwd
|
|
cmd.Env = env
|
|
if len(stdin) > 0 {
|
|
cmd.Stdin = bytes.NewReader(stdin)
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
err := cmd.Run()
|
|
result := commandResult{Stdout: stdout.String(), Stderr: stderr.String()}
|
|
if err == nil {
|
|
return result
|
|
}
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) {
|
|
result.ExitCode = exitErr.ExitCode()
|
|
return result
|
|
}
|
|
// Could not launch (binary missing, timeout, etc.).
|
|
result.ExitCode = -1
|
|
result.Err = err
|
|
return result
|
|
}
|