summaryrefslogtreecommitdiff
path: root/internal/sandbox/guard.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/sandbox/guard.go')
-rw-r--r--internal/sandbox/guard.go79
1 files changed, 79 insertions, 0 deletions
diff --git a/internal/sandbox/guard.go b/internal/sandbox/guard.go
new file mode 100644
index 0000000..03cb964
--- /dev/null
+++ b/internal/sandbox/guard.go
@@ -0,0 +1,79 @@
+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{}}
+}