summaryrefslogtreecommitdiff
path: root/internal/sandbox/guard.go
blob: 03cb964ba93ec6fb32a0cee97c9834d0103d0e88 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
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{}}
}