bb7892c0c2
- M2: stop echoing the matched pattern name in the user-visible [BLOCKED: ...] message returned by the firewall. The pattern (and the matched secret class) still appear in the operator log, but the string sent back into the prompt is now generic. - H1: document Rule.Pattern semantics on the Rule type and pin them with a regression test. Pattern is a case-sensitive, exact substring match against the JSON-serialised tool arguments — not a glob, regex, or whitespace-insensitive match. The new test exercises both matches and the documented gotchas (double-space, case drift, tab). - H3: every code path in CommandExecutor.Execute that converts a hook failure into Allow via FailOpen now emits a WARN naming the hook and the failure mode (timeout / launch_error / parse_error), so chronic hook failure or abuse is visible in operator logs. Also tightens errcheck on permission/rule.go (Printer.Print on a strings.Builder cannot error in practice; make the intent explicit).
78 lines
2.3 KiB
Go
78 lines
2.3 KiB
Go
package hook
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os/exec"
|
|
"time"
|
|
)
|
|
|
|
// failOpenAction returns Allow when FailOpen is set, Deny otherwise, and logs
|
|
// a WARN naming the hook and the underlying reason so that abuse or chronic
|
|
// hook failure is visible in operator logs (audit H3).
|
|
func (c *CommandExecutor) failOpenAction(reason string, cause error) Action {
|
|
if c.def.FailOpen {
|
|
slog.Warn("hook fail-open: allowing tool call despite hook failure",
|
|
"hook", c.def.Name,
|
|
"reason", reason,
|
|
"error", cause,
|
|
)
|
|
return Allow
|
|
}
|
|
return Deny
|
|
}
|
|
|
|
// CommandExecutor runs a shell command and interprets its stdin/stdout.
|
|
type CommandExecutor struct {
|
|
def HookDef
|
|
}
|
|
|
|
// NewCommandExecutor constructs a CommandExecutor for the given definition.
|
|
func NewCommandExecutor(def HookDef) *CommandExecutor {
|
|
return &CommandExecutor{def: def}
|
|
}
|
|
|
|
// Execute runs the hook command, pipes payload to stdin, and reads stdout.
|
|
func (c *CommandExecutor) Execute(ctx context.Context, payload []byte) (HookResult, error) {
|
|
timeout := c.def.timeout()
|
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
start := time.Now()
|
|
// Exec is a resolved binary path (see plugin/loader.go). No shell wrapping —
|
|
// shell metacharacters in the path are treated as literal filename bytes.
|
|
cmd := exec.CommandContext(ctx, c.def.Exec)
|
|
cmd.Stdin = bytes.NewReader(payload)
|
|
|
|
var stdout bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
|
|
runErr := cmd.Run()
|
|
duration := time.Since(start)
|
|
|
|
// Determine exit code and whether it was a timeout.
|
|
exitCode := 0
|
|
if runErr != nil {
|
|
if ctx.Err() != nil {
|
|
timeoutErr := fmt.Errorf("hook %q: timed out after %v", c.def.Name, timeout)
|
|
return HookResult{Action: c.failOpenAction("timeout", timeoutErr), Duration: duration}, timeoutErr
|
|
}
|
|
if exitErr, ok := runErr.(*exec.ExitError); ok {
|
|
exitCode = exitErr.ExitCode()
|
|
} else {
|
|
launchErr := fmt.Errorf("hook %q: %w", c.def.Name, runErr)
|
|
return HookResult{Action: c.failOpenAction("launch_error", launchErr), Duration: duration}, launchErr
|
|
}
|
|
}
|
|
|
|
action, transformed, err := ParseHookOutput(stdout.Bytes(), exitCode)
|
|
if err != nil {
|
|
parseErr := fmt.Errorf("hook %q: %w", c.def.Name, err)
|
|
return HookResult{Action: c.failOpenAction("parse_error", parseErr), Duration: duration}, parseErr
|
|
}
|
|
|
|
return HookResult{Action: action, Output: transformed, Duration: duration}, nil
|
|
}
|