Skip to content

Commit a457b47

Browse files
intel352claude
andcommitted
fix: address 16 code review issues (3 critical, security + concurrency)
Critical fixes: - executor: nil TrustEngine now defaults to DenyAllTrustEvaluator (fail-closed, not open) - executor: ActionAsk with no Approver configured now denies (fail-safe); with Approver calls Approver.Request then blocks on WaitForResolution instead of returning error - orchestrator: ContainerManager.EnsureContainer now accepts executor.SandboxConfig so *ContainerManager satisfies executor.ContainerExecutor (type mismatch resolved) Security / correctness: - executor: Action is now a type alias for policy.Action (compile-time parity, no drift) - executor: Approver interface gains Request() method for creating approvals before waiting - orchestrator: bind mount Source/Target validated with filepath.IsAbs + path traversal check - orchestrator: EnsureContainer called before ExecInContainer in sandbox path policy/store.go: - Grant: DELETE + INSERT wrapped in a single transaction (atomic upsert) - Check: real DB errors logged separately from "no rows found" - List: time.Parse errors logged instead of silently discarded orchestrator/container_manager.go: - Init command failures now logged (were silently dropped) - rows.Err() checked after container cache load loop Tests added: - TestDenyAllTrustEvaluator (fail-closed default) - TestTrustEngineDataRace (concurrent SetMode/Evaluate under -race) - TestExecute_ActionAsk_NoApprover_Denies - TestExecute_ActionAsk_WithApprover_Blocks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 482b7b8 commit a457b47

11 files changed

Lines changed: 281 additions & 69 deletions

executor/executor.go

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ func Execute(ctx context.Context, cfg Config, systemPrompt, userTask, agentID st
105105
if cfg.RequestTimeout <= 0 {
106106
cfg.RequestTimeout = 60 * time.Minute
107107
}
108+
// Track whether the caller configured a real Approver. A nil Approver means no
109+
// approval system — trust-ask actions must be denied rather than auto-approved.
110+
hasRealApprover := cfg.Approver != nil
108111
if cfg.Approver == nil {
109112
cfg.Approver = &NullApprover{}
110113
}
@@ -120,10 +123,10 @@ func Execute(ctx context.Context, cfg Config, systemPrompt, userTask, agentID st
120123
if cfg.Memory == nil {
121124
cfg.Memory = &NullMemoryStore{}
122125
}
123-
// TrustEngine defaults to NullTrustEvaluator (fail-open / allow all).
124-
// This is intentional for dev/testing. Replace with a real TrustEvaluator in production.
126+
// TrustEngine defaults to DenyAllTrustEvaluator when nil — fail-closed, not open.
127+
// Set cfg.TrustEngine = &NullTrustEvaluator{} explicitly for dev/testing environments.
125128
if cfg.TrustEngine == nil {
126-
cfg.TrustEngine = &NullTrustEvaluator{}
129+
cfg.TrustEngine = &DenyAllTrustEvaluator{}
127130
}
128131

129132
if cfg.Provider == nil {
@@ -332,23 +335,43 @@ func Execute(ctx context.Context, cfg Config, systemPrompt, userTask, agentID st
332335
Content: fmt.Sprintf("[TRUST DENY] %s by %s", tc.Name, agentID),
333336
})
334337
case ActionAsk:
335-
// Block on human approval before allowing execution.
336-
approvalID := uuid.New().String()
337-
_ = cfg.Transcript.Record(ctx, TranscriptEntry{
338-
ID: approvalID,
339-
AgentID: agentID,
340-
TaskID: cfg.TaskID,
341-
ProjectID: cfg.ProjectID,
342-
Iteration: iterCount,
343-
Role: provider.RoleUser,
344-
Content: fmt.Sprintf("[TRUST ASK] %s by %s — waiting for human approval", tc.Name, agentID),
345-
})
346-
record, waitErr := cfg.Approver.WaitForResolution(ctx, approvalID, cfg.ApprovalTimeout)
347-
if waitErr != nil || record == nil || record.Status != ApprovalApproved {
348-
resultStr = fmt.Sprintf("Tool %q requires human approval (trust policy: ask) — not approved", tc.Name)
338+
// Deny immediately when no real Approver is configured — fail-safe.
339+
if !hasRealApprover {
340+
resultStr = fmt.Sprintf("Tool %q denied: trust policy requires user approval but no Approver is configured", tc.Name)
349341
isError = true
342+
_ = cfg.Transcript.Record(ctx, TranscriptEntry{
343+
ID: uuid.New().String(),
344+
AgentID: agentID,
345+
TaskID: cfg.TaskID,
346+
ProjectID: cfg.ProjectID,
347+
Iteration: iterCount,
348+
Role: provider.RoleUser,
349+
Content: fmt.Sprintf("[TRUST DENY] %s by %s (ask, no approver)", tc.Name, agentID),
350+
})
351+
} else {
352+
// Create an approval entry and block until a human resolves it.
353+
approvalID, reqErr := cfg.Approver.Request(ctx, tc.Name, tc.Arguments)
354+
if reqErr != nil {
355+
resultStr = fmt.Sprintf("Tool %q denied: could not create approval request: %v", tc.Name, reqErr)
356+
isError = true
357+
} else {
358+
_ = cfg.Transcript.Record(ctx, TranscriptEntry{
359+
ID: uuid.New().String(),
360+
AgentID: agentID,
361+
TaskID: cfg.TaskID,
362+
ProjectID: cfg.ProjectID,
363+
Iteration: iterCount,
364+
Role: provider.RoleUser,
365+
Content: fmt.Sprintf("[TRUST ASK] %s by %s — waiting for human approval (id=%s)", tc.Name, agentID, approvalID),
366+
})
367+
record, waitErr := cfg.Approver.WaitForResolution(ctx, approvalID, cfg.ApprovalTimeout)
368+
if waitErr != nil || record == nil || record.Status != ApprovalApproved {
369+
resultStr = fmt.Sprintf("Tool %q requires human approval (trust policy: ask) — not approved", tc.Name)
370+
isError = true
371+
}
372+
// If approved, fall through to normal tool execution.
373+
}
350374
}
351-
// If approved, fall through to normal tool execution.
352375
}
353376
}
354377

executor/executor_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"testing"
7+
"time"
78

89
"github.com/GoCodeAlone/workflow-plugin-agent/provider"
910
"github.com/GoCodeAlone/workflow-plugin-agent/tools"
@@ -129,6 +130,7 @@ func TestExecute_ToolExecution(t *testing.T) {
129130
cfg := Config{
130131
Provider: p,
131132
ToolRegistry: reg,
133+
TrustEngine: &NullTrustEvaluator{}, // allow all tools in this unit test
132134
}
133135
result, err := Execute(context.Background(), cfg, "sys", "task", "agent-1")
134136
if err != nil {
@@ -528,6 +530,7 @@ func TestExecute_ToolArgsEventIsCopy(t *testing.T) {
528530
cfg := Config{
529531
Provider: p,
530532
ToolRegistry: reg,
533+
TrustEngine: &NullTrustEvaluator{}, // allow all tools in this unit test
531534
OnEvent: func(e Event) {
532535
// Mutate the event's ToolArgs — this should NOT affect tool execution.
533536
if e.Type == EventToolCallStart && e.ToolArgs != nil {
@@ -768,3 +771,117 @@ func (s *simpleTool) Definition() provider.ToolDef { return s.def }
768771
func (s *simpleTool) Execute(ctx context.Context, args map[string]any) (any, error) {
769772
return s.fn(ctx, args)
770773
}
774+
775+
// TestExecute_ActionAsk_NoApprover_Denies verifies that when no Approver is configured,
776+
// a trust ActionAsk tool call is denied rather than auto-approved (fail-safe behavior).
777+
func TestExecute_ActionAsk_NoApprover_Denies(t *testing.T) {
778+
var toolExecuted bool
779+
reg := tools.NewRegistry()
780+
reg.Register(&simpleTool{
781+
name: "risky_tool",
782+
def: provider.ToolDef{Name: "risky_tool", Description: "risky"},
783+
fn: func(_ context.Context, _ map[string]any) (any, error) {
784+
toolExecuted = true
785+
return "executed", nil
786+
},
787+
})
788+
789+
callN := 0
790+
p := &callCountProvider{
791+
onChat: func() (*provider.Response, error) {
792+
callN++
793+
if callN == 1 {
794+
return &provider.Response{
795+
ToolCalls: []provider.ToolCall{
796+
{ID: "tc-1", Name: "risky_tool", Arguments: map[string]any{}},
797+
},
798+
}, nil
799+
}
800+
return &provider.Response{Content: "done"}, nil
801+
},
802+
}
803+
804+
// TrustEngine returns Ask for risky_tool; Approver is nil (not configured).
805+
te := &mockTrustEvaluator{toolAction: ActionAsk}
806+
cfg := Config{
807+
Provider: p,
808+
ToolRegistry: reg,
809+
TrustEngine: te,
810+
// Approver intentionally nil — should default to deny behavior
811+
}
812+
813+
_, err := Execute(context.Background(), cfg, "sys", "task", "agent-1")
814+
if err != nil {
815+
t.Fatalf("Execute: %v", err)
816+
}
817+
if toolExecuted {
818+
t.Error("risky_tool should NOT have been executed when no Approver is configured and trust policy is ask")
819+
}
820+
}
821+
822+
// TestExecute_ActionAsk_WithApprover_Blocks verifies that when an Approver is configured,
823+
// a trust ActionAsk call blocks until the Approver resolves it.
824+
func TestExecute_ActionAsk_WithApprover_Blocks(t *testing.T) {
825+
var toolExecuted bool
826+
var requestReceived bool
827+
reg := tools.NewRegistry()
828+
reg.Register(&simpleTool{
829+
name: "reviewed_tool",
830+
def: provider.ToolDef{Name: "reviewed_tool", Description: "needs approval"},
831+
fn: func(_ context.Context, _ map[string]any) (any, error) {
832+
toolExecuted = true
833+
return "executed", nil
834+
},
835+
})
836+
837+
callN := 0
838+
p := &callCountProvider{
839+
onChat: func() (*provider.Response, error) {
840+
callN++
841+
if callN == 1 {
842+
return &provider.Response{
843+
ToolCalls: []provider.ToolCall{
844+
{ID: "tc-1", Name: "reviewed_tool", Arguments: map[string]any{}},
845+
},
846+
}, nil
847+
}
848+
return &provider.Response{Content: "done"}, nil
849+
},
850+
}
851+
852+
approver := &recordingApprover{status: ApprovalApproved}
853+
te := &mockTrustEvaluator{toolAction: ActionAsk}
854+
cfg := Config{
855+
Provider: p,
856+
ToolRegistry: reg,
857+
TrustEngine: te,
858+
Approver: approver,
859+
}
860+
861+
_, err := Execute(context.Background(), cfg, "sys", "task", "agent-1")
862+
if err != nil {
863+
t.Fatalf("Execute: %v", err)
864+
}
865+
requestReceived = approver.requested
866+
if !requestReceived {
867+
t.Error("Approver.Request should have been called for ActionAsk")
868+
}
869+
if !toolExecuted {
870+
t.Error("reviewed_tool should have been executed after approval")
871+
}
872+
}
873+
874+
// recordingApprover records whether Request was called and auto-resolves with a fixed status.
875+
type recordingApprover struct {
876+
requested bool
877+
status ApprovalStatus
878+
}
879+
880+
func (r *recordingApprover) Request(_ context.Context, _ string, _ map[string]any) (string, error) {
881+
r.requested = true
882+
return "test-approval-id", nil
883+
}
884+
885+
func (r *recordingApprover) WaitForResolution(_ context.Context, _ string, _ time.Duration) (*ApprovalRecord, error) {
886+
return &ApprovalRecord{Status: r.status}, nil
887+
}

executor/interfaces.go

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import (
55
"context"
66
"time"
77

8+
"github.com/GoCodeAlone/workflow-plugin-agent/policy"
89
"github.com/GoCodeAlone/workflow-plugin-agent/provider"
10+
"github.com/google/uuid"
911
)
1012

1113
// TranscriptEntry is a single recorded message in the agent conversation.
@@ -46,6 +48,10 @@ type ApprovalRecord struct {
4648

4749
// Approver manages approval gate blocking.
4850
type Approver interface {
51+
// Request creates a pending approval for the given tool call and returns its ID.
52+
// Implementers should persist the pending approval so WaitForResolution can query it.
53+
Request(ctx context.Context, toolName string, args map[string]any) (approvalID string, err error)
54+
4955
// WaitForResolution blocks until the approval with the given ID is resolved
5056
// or the timeout elapses.
5157
WaitForResolution(ctx context.Context, approvalID string, timeout time.Duration) (*ApprovalRecord, error)
@@ -120,9 +126,13 @@ type NullTranscript struct{}
120126

121127
func (n *NullTranscript) Record(_ context.Context, _ TranscriptEntry) error { return nil }
122128

123-
// NullApprover is a no-op Approver — always returns "approved".
129+
// NullApprover is a no-op Approver for dev/testing — auto-approves every request.
124130
type NullApprover struct{}
125131

132+
func (n *NullApprover) Request(_ context.Context, _ string, _ map[string]any) (string, error) {
133+
return uuid.New().String(), nil
134+
}
135+
126136
func (n *NullApprover) WaitForResolution(_ context.Context, _ string, _ time.Duration) (*ApprovalRecord, error) {
127137
return &ApprovalRecord{Status: ApprovalApproved}, nil
128138
}
@@ -154,13 +164,14 @@ func (n *NullSecretRedactor) Redact(text string) string { return text }
154164

155165
func (n *NullSecretRedactor) CheckAndRedact(_ *provider.Message) {}
156166

157-
// Action mirrors policy.Action to avoid circular imports.
158-
type Action string
167+
// Action is a type alias for policy.Action, providing compile-time parity between
168+
// the executor and policy packages with no string duplication.
169+
type Action = policy.Action
159170

160171
const (
161-
ActionAllow Action = "allow"
162-
ActionDeny Action = "deny"
163-
ActionAsk Action = "ask"
172+
ActionAllow Action = policy.Allow
173+
ActionDeny Action = policy.Deny
174+
ActionAsk Action = policy.Ask
164175
)
165176

166177
// TrustEvaluator checks whether a tool call is permitted.
@@ -182,13 +193,13 @@ type ContainerExecutor interface {
182193

183194
// SandboxConfig holds per-agent Docker sandbox settings.
184195
type SandboxConfig struct {
185-
Enabled bool `json:"enabled" yaml:"enabled"`
186-
Image string `json:"image" yaml:"image"`
187-
Network string `json:"network" yaml:"network"`
188-
Memory string `json:"memory" yaml:"memory"`
189-
CPU float64 `json:"cpu" yaml:"cpu"`
196+
Enabled bool `json:"enabled" yaml:"enabled"`
197+
Image string `json:"image" yaml:"image"`
198+
Network string `json:"network" yaml:"network"`
199+
Memory string `json:"memory" yaml:"memory"`
200+
CPU float64 `json:"cpu" yaml:"cpu"`
190201
Mounts []SandboxMount `json:"mounts" yaml:"mounts"`
191-
InitCommands []string `json:"init" yaml:"init"`
202+
InitCommands []string `json:"init" yaml:"init"`
192203
}
193204

194205
// SandboxMount is a bind mount for a sandbox container.
@@ -198,11 +209,23 @@ type SandboxMount struct {
198209
ReadOnly bool `json:"readonly" yaml:"readonly"`
199210
}
200211

201-
// NullTrustEvaluator allows everything (no trust enforcement).
212+
// NullTrustEvaluator allows everything — intended for dev/testing only.
213+
// In production, configure a real TrustEvaluator. When Config.TrustEngine is nil,
214+
// the executor defaults to DenyAllTrustEvaluator for fail-safe behavior.
202215
type NullTrustEvaluator struct{}
203216

204217
func (n *NullTrustEvaluator) Evaluate(_ context.Context, _ string, _ map[string]any) Action {
205218
return ActionAllow
206219
}
207220
func (n *NullTrustEvaluator) EvaluateCommand(_ string) Action { return ActionAllow }
208221
func (n *NullTrustEvaluator) EvaluatePath(_ string) Action { return ActionAllow }
222+
223+
// DenyAllTrustEvaluator denies every tool call. Used as the default when no TrustEngine
224+
// is configured, so missing configuration fails closed rather than open.
225+
type DenyAllTrustEvaluator struct{}
226+
227+
func (d *DenyAllTrustEvaluator) Evaluate(_ context.Context, _ string, _ map[string]any) Action {
228+
return ActionDeny
229+
}
230+
func (d *DenyAllTrustEvaluator) EvaluateCommand(_ string) Action { return ActionDeny }
231+
func (d *DenyAllTrustEvaluator) EvaluatePath(_ string) Action { return ActionDeny }

executor/mesh_support_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ func TestMeshSupport_EndToEnd(t *testing.T) {
7474
cfg := Config{
7575
Provider: p,
7676
ToolRegistry: reg,
77+
TrustEngine: &NullTrustEvaluator{}, // allow all tools in this unit test
7778
MaxIterations: 10,
7879
Inbox: inbox,
7980
OnEvent: func(e Event) {

executor/trust_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,19 @@ func TestNullTrustEvaluator(t *testing.T) {
1515
}
1616
}
1717

18+
func TestDenyAllTrustEvaluator(t *testing.T) {
19+
te := &DenyAllTrustEvaluator{}
20+
if te.Evaluate(context.Background(), "file_write", nil) != ActionDeny {
21+
t.Error("DenyAllTrustEvaluator should always deny")
22+
}
23+
if te.EvaluateCommand("echo hello") != ActionDeny {
24+
t.Error("DenyAllTrustEvaluator should always deny commands")
25+
}
26+
if te.EvaluatePath("/tmp/file") != ActionDeny {
27+
t.Error("DenyAllTrustEvaluator should always deny paths")
28+
}
29+
}
30+
1831
type mockTrustEvaluator struct {
1932
toolAction Action
2033
cmdAction Action

0 commit comments

Comments
 (0)