package sandbox import ( "context" "fmt" ) // Hook is a pre-tool-use guardrail check. Implementations inspect a // proposed run_bash command or write_file path and return a non-nil error // to reject it; the error's message becomes the human-readable reason // surfaced back to the model. Hooks are stateless checks — Guarded is // responsible for turning a rejection into a RejectionError so the // agentloop can feed it back to the model as ordinary (non-fatal) tool // content instead of aborting the whole execution. type Hook interface { CheckBash(ctx context.Context, command string) error CheckWrite(ctx context.Context, path string) error } // RejectionError is returned by Guarded.RunBash/WriteFile when a Hook // rejects the call. internal/agentloop/tools.go specifically recognizes // this type (via errors.As) and, instead of aborting the run the way an // ordinary Sandbox error does, feeds Reason back to the model as normal // tool-result content — a rejected command becomes something the model can // read and self-correct around, not a crashed execution. type RejectionError struct { // Op is the tool that was rejected: "run_bash" or "write_file". Op string // Reason is the Hook's error message explaining the rejection. Reason string } func (e *RejectionError) Error() string { return fmt.Sprintf("%s rejected by guardrail: %s", e.Op, e.Reason) } // Guarded wraps a Sandbox with a set of Hooks that run before RunBash/ // WriteFile delegate to the wrapped Sandbox. All other Sandbox methods // (ReadFile, Glob, GitClone, GitPush, WorkDir, Cleanup) pass straight // through unchanged — guardrails only gate the two tools capable of taking // destructive or exfiltrating action (shell execution and file writes). type Guarded struct { Sandbox Hooks []Hook } var _ Sandbox = (*Guarded)(nil) // RunBash runs every Hook.CheckBash before delegating to the wrapped // Sandbox. On rejection it returns a *RejectionError instead of calling the // wrapped Sandbox at all — the command never executes. func (g *Guarded) RunBash(ctx context.Context, command string) (stdout, stderr string, exitCode int, err error) { for _, h := range g.Hooks { if rejectErr := h.CheckBash(ctx, command); rejectErr != nil { return "", "", 0, &RejectionError{Op: "run_bash", Reason: rejectErr.Error()} } } return g.Sandbox.RunBash(ctx, command) } // WriteFile runs every Hook.CheckWrite before delegating to the wrapped // Sandbox. On rejection it returns a *RejectionError instead of calling the // wrapped Sandbox at all — the file is never written. func (g *Guarded) WriteFile(ctx context.Context, path, content string) error { for _, h := range g.Hooks { if rejectErr := h.CheckWrite(ctx, path); rejectErr != nil { return &RejectionError{Op: "write_file", Reason: rejectErr.Error()} } } return g.Sandbox.WriteFile(ctx, path, content) } // DefaultHooks returns the standard guardrail hook set applied uniformly to // every NativeRunner sandbox (Host or Docker) regardless of provider — see // internal/executor/nativerunner.go. There is no per-role configurability // yet; that lands with the role/config model in a later phase. func DefaultHooks() []Hook { return []Hook{&DenylistBashHook{}, &ProtectedPathHook{}} }