Skip to content
106 changes: 104 additions & 2 deletions pkg/runtime/agent_delegation_test.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package runtime

import (
"context"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/docker/docker-agent/pkg/agent"
"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/permissions"
"github.com/docker/docker-agent/pkg/runtime/toolexec"
"github.com/docker/docker-agent/pkg/session"
"github.com/docker/docker-agent/pkg/team"
"github.com/docker/docker-agent/pkg/tools"
Expand Down Expand Up @@ -270,6 +274,48 @@ func TestSubSessionWithoutAttachedFilesOmitsBlock(t *testing.T) {
}
}

func TestSubSessionInheritsPermissions(t *testing.T) {
t.Parallel()

perms := &session.PermissionsConfig{
Allow: []string{"read_*"},
Deny: []string{"write_*"},
Ask: []string{"edit_*"},
}
parent := session.New(session.WithPermissions(perms))

childAgent := agent.New("worker", "")
cfg := SubSessionConfig{
Task: "refactor",
AgentName: "worker",
Title: "Refactor",
Permissions: parent.ClonePermissions(),
}

s := newSubSession(parent, cfg, childAgent)

require.NotNil(t, s.Permissions)
assert.Equal(t, perms.Allow, s.Permissions.Allow)
assert.Equal(t, perms.Deny, s.Permissions.Deny)
assert.Equal(t, perms.Ask, s.Permissions.Ask)
Comment on lines +297 to +300

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This checks field copying but not that inheritance changes dispatch behaviour. Consider driving the approval path instead: a parent Deny of write_* should still deny in the child, and in particular the background-agent case (ToolsApproved: true) where the inherited Deny currently has no effect. That case would document the gap flagged in agent_delegation.go.


// Verify the gap flagged in PR review: even if ToolsApproved is true (yolo flag),
// the inherited Deny should correctly override the yolo flag during dispatch.
s.ToolsApproved = true

checker := permissions.NewChecker(&latest.PermissionsConfig{
Allow: s.Permissions.Allow,
Ask: s.Permissions.Ask,
Deny: s.Permissions.Deny,
})
namedCheckers := []toolexec.NamedChecker{
{Checker: checker, Source: "session permissions"},
}

decision := toolexec.Decide(s.ToolsApproved, namedCheckers, "write_file", map[string]any{"path": "foo"}, false)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, not blocking: this builds a permissions.Checker by hand from s.Permissions and calls toolexec.Decide directly. That's a real improvement over asserting field copies only, but it still bypasses the actual wiring in permissionCheckers / rt.processToolCalls. Consider complementing it with an end-to-end case through RunAgent/runCollecting with a real background sub-session, similar to TestDenyOverridesYoloMode, so the real dispatcher path is covered too.

assert.Equal(t, toolexec.OutcomeDeny, decision.Outcome, "Inherited Deny should override ToolsApproved: true (yolo)")
}

func TestNewSubSession_PermissionsIsolation(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -364,7 +410,8 @@ func TestRunAgent_InheritsParentPermissions(t *testing.T) {
agent.WithSubAgents(worker)(root)

tm := team.New(team.WithAgents(root, worker))
rt, err := NewLocalRuntime(t.Context(), tm,
rt, err := NewLocalRuntime(
t.Context(), tm,
WithSessionCompaction(false),
WithModelStore(mockModelStore{}),
)
Expand Down Expand Up @@ -409,6 +456,60 @@ func TestRunAgent_InheritsParentPermissions(t *testing.T) {
"parent permissions must be isolated from child mutations")
}

func TestRunAgent_EndToEndPermissions(t *testing.T) {
t.Parallel()

var executed bool
agentTools := []tools.Tool{{
Name: "dangerous_tool",
Parameters: map[string]any{},
Handler: func(_ context.Context, _ tools.ToolCall, _ tools.Runtime) (*tools.ToolCallResult, error) {
executed = true
return tools.ResultSuccess("executed"), nil
},
}}

workerStream := newStreamBuilder().
AddToolCallName("call_1", "dangerous_tool").
AddToolCallArguments("call_1", "{}").
Build()
parentProv := &mockProvider{id: "test/mock-model", stream: &mockStream{}}
workerProv := &mockProvider{id: "test/mock-model", stream: workerStream}

worker := agent.New("worker", "Worker agent",
agent.WithModel(workerProv),
agent.WithToolSets(newStubToolSet(nil, agentTools, nil)),
)
root := agent.New("root", "Root agent", agent.WithModel(parentProv))
agent.WithSubAgents(worker)(root)

tm := team.New(team.WithAgents(root, worker))
rt, err := NewLocalRuntime(
t.Context(), tm,
WithSessionCompaction(false),
WithModelStore(mockModelStore{}),
)
require.NoError(t, err)

parentPerms := &session.PermissionsConfig{
Allow: []string{"safe_tool"},
Deny: []string{"dangerous_tool"},
}
parentSession := session.New(
session.WithUserMessage("Test"),
session.WithToolsApproved(true),
session.WithPermissions(parentPerms),
)

rt.RunAgent(t.Context(), agenttool.RunParams{
AgentName: "worker",
Task: "do something",
ParentSession: parentSession,
})

require.False(t, executed, "expected dangerous_tool to NOT be executed because it is denied by inherited permissions")
}

func TestTransferTask_PropagatesPermissions(t *testing.T) {
t.Parallel()

Expand All @@ -420,7 +521,8 @@ func TestTransferTask_PropagatesPermissions(t *testing.T) {
agent.WithSubAgents(librarian)(root)

tm := team.New(team.WithAgents(root, librarian))
rt, err := NewLocalRuntime(t.Context(), tm,
rt, err := NewLocalRuntime(
t.Context(), tm,
WithSessionCompaction(false),
WithModelStore(mockModelStore{}),
)
Expand Down
21 changes: 13 additions & 8 deletions pkg/runtime/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2517,7 +2517,8 @@ func TestTransferTaskPersistsSubSessionOnError(t *testing.T) {
"SubSessionCompletedEvent must fire on the error path so observers persist the sub-session")
}

func TestYoloMode_OverridesPermissionsDeny(t *testing.T) {
// TestDenyOverridesYoloMode verifies that Deny permissions take precedence over the yolo flag.
func TestDenyOverridesYoloMode(t *testing.T) {
t.Parallel()

// Test that --yolo flag takes precedence over deny permissions
Expand Down Expand Up @@ -2561,10 +2562,11 @@ func TestYoloMode_OverridesPermissionsDeny(t *testing.T) {
rt.processToolCalls(t.Context(), sess, calls, agentTools, NewChannelSink(events))
close(events)

// With --yolo, the tool should execute despite deny permission
require.True(t, executed, "expected tool to be executed in --yolo mode despite deny permission")
// With --yolo and Deny/ForceAsk precedence, the tool should NOT execute.
require.False(t, executed, "expected tool to NOT be executed in --yolo mode because Deny wins")
}

// TestYoloMode_OverridesForceAsk verifies that the yolo flag takes precedence over ForceAsk permissions.
func TestYoloMode_OverridesForceAsk(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -2597,6 +2599,7 @@ func TestYoloMode_OverridesForceAsk(t *testing.T) {
require.NoError(t, err)

sess := session.New(session.WithUserMessage("Test"), session.WithToolsApproved(true))
sess.NonInteractive = true
require.True(t, sess.ToolsApproved)

calls := []tools.ToolCall{{
Expand All @@ -2609,11 +2612,13 @@ func TestYoloMode_OverridesForceAsk(t *testing.T) {
rt.processToolCalls(t.Context(), sess, calls, agentTools, NewChannelSink(events))
close(events)

// With --yolo, the tool should execute without asking
require.True(t, executed, "expected tool to be executed in --yolo mode despite ForceAsk permission")
// YOLO overrides ForceAsk: the checker's ForceAsk verdict is bypassed
// and the tool executes automatically.
require.True(t, executed, "expected tool to be executed in --yolo mode because YOLO wins over ForceAsk")
}

func TestYoloMode_OverridesSessionDeny(t *testing.T) {
// TestSessionDenyOverridesYoloMode verifies that session-level Deny permissions take precedence over the yolo flag.
func TestSessionDenyOverridesYoloMode(t *testing.T) {
t.Parallel()

// Test that --yolo flag takes precedence over session-level deny
Expand Down Expand Up @@ -2656,8 +2661,8 @@ func TestYoloMode_OverridesSessionDeny(t *testing.T) {
rt.processToolCalls(t.Context(), sess, calls, agentTools, NewChannelSink(events))
close(events)

// With --yolo, the tool should execute despite session deny
require.True(t, executed, "expected tool to be executed in --yolo mode despite session deny permission")
// With --yolo and Deny/ForceAsk precedence, the tool should NOT execute.
require.False(t, executed, "expected tool to NOT be executed in --yolo mode because session Deny wins")
}

func TestStripImageContent(t *testing.T) {
Expand Down
19 changes: 11 additions & 8 deletions pkg/runtime/toolexec/permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,11 @@ type PermissionDecision struct {
// Decide resolves the final permission outcome for a tool call by walking
// the configured pipeline in priority order:
//
// 1. yoloApproved (--yolo) — auto-allow everything.
// 2. checkers (in order; typically session-level first, then team-level)
// 1. checkers (in order; typically session-level first, then team-level)
// — the first checker that returns Allow / Deny / ForceAsk wins.
// ForceAsk produces [OutcomeAsk]: an explicit ask pattern always
// overrides the read-only fast path below.
// However, if the outcome is ForceAsk and yoloApproved is true,
// YOLO overrides ForceAsk and auto-allows the call.
// 2. yoloApproved (--yolo) — auto-allow everything else.
// 3. readOnlyHint — auto-allow.
// 4. default — Ask.
//
Expand All @@ -75,23 +75,26 @@ func Decide(
toolArgs map[string]any,
readOnlyHint bool,
) PermissionDecision {
if yoloApproved {
return PermissionDecision{Outcome: OutcomeAllow, Reason: ReasonYolo}
}

for _, pc := range checkers {
switch pc.Checker.CheckWithArgs(toolName, toolArgs) {
case permissions.Deny:
return PermissionDecision{Outcome: OutcomeDeny, Reason: ReasonChecker, Source: pc.Source}
case permissions.Allow:
return PermissionDecision{Outcome: OutcomeAllow, Reason: ReasonChecker, Source: pc.Source}
case permissions.ForceAsk:
if yoloApproved {
return PermissionDecision{Outcome: OutcomeAllow, Reason: ReasonYolo}
}
return PermissionDecision{Outcome: OutcomeAsk, Reason: ReasonChecker, Source: pc.Source}
case permissions.Ask:
// No explicit match at this level; fall through to next checker.
}
}

if yoloApproved {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reorders the approval pipeline for every session, not just sub-sessions with inherited permissions.

Deny winning over --yolo matches the contract PermissionsConfig has documented since it was introduced (pkg/config/latest/types.go, and every frozen version v3 to v11): "Deny: Tools matching these patterns are always rejected, even with --yolo".

ForceAsk winning over --yolo reverses commit 7351e4fa0 ("--yolo must accept any tool call"), which added TestYoloMode_OverridesForceAsk (renamed here to TestForceAskOverridesYoloMode, assertion flipped) specifically to lock in the opposite behavior, and documented it in pkg/permissions/permissions.go's CheckWithArgs comment: "Note that --yolo mode takes precedence over ForceAsk" (still present on main, now stale).

The doc comment on this function, line 61, still lists yolo as step 1 ahead of the checkers; it needs updating either way this lands.

Question: should this only reorder the Deny case and leave ForceAsk behind --yolo (matching the two contracts above), or is overriding ForceAsk too an intentional posture change? If the latter, pkg/permissions/permissions.go and docs/tools/background-agents/index.md:33 ("via YOLO mode or explicit allow rules") need updating in this PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was not an intentional posture change! I have updated the logic to only reorder the Deny case, leaving ForceAsk behind --yolo as originally intended.

Because this preserves the original contract, the external docs (pkg/permissions/permissions.go and index.md) remain accurate and didn't need updating. (I've also fixed the doc comment on Decide() to reflect this correctly).

return PermissionDecision{Outcome: OutcomeAllow, Reason: ReasonYolo}
}

if readOnlyHint {
return PermissionDecision{Outcome: OutcomeAllow, Reason: ReasonReadOnlyHint}
}
Expand Down
4 changes: 2 additions & 2 deletions pkg/runtime/toolexec/permissions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ func newChecker(t *testing.T, allow, ask, deny []string) *permissions.Checker {
})
}

func TestDecide_YoloShortCircuits(t *testing.T) {
func TestDecide_DenyOverridesYolo(t *testing.T) {
t.Parallel()
d := Decide(true, []NamedChecker{
{Checker: newChecker(t, nil, nil, []string{"shell"}), Source: "team"},
}, "shell", nil, false)

assert.Equal(t, PermissionDecision{Outcome: OutcomeAllow, Reason: ReasonYolo}, d)
assert.Equal(t, PermissionDecision{Outcome: OutcomeDeny, Reason: ReasonChecker, Source: "team"}, d)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Matches the documented PermissionsConfig contract for Deny, no objection to this one specifically. See the comment on pkg/runtime/toolexec/permissions.go about the related ForceAsk case that still needs a decision.

}

func TestDecide_DenyFromCheckerWins(t *testing.T) {
Expand Down
15 changes: 2 additions & 13 deletions pkg/session/branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ func (s *Session) Clone() *Session {
InputTokens: s.InputTokens,
OutputTokens: s.OutputTokens,
Cost: s.Cost,
Permissions: clonePermissionsConfig(s.Permissions),
Permissions: s.Permissions.Clone(),
AgentModelOverrides: cloneStringMap(s.AgentModelOverrides),
CustomModelsUsed: cloneStringSlice(s.CustomModelsUsed),
AttachedFiles: cloneStringSlice(s.AttachedFiles),
Expand Down Expand Up @@ -190,7 +190,7 @@ func copySessionMetadata(dst, src *Session, title string) {
dst.MaxConsecutiveToolCalls = src.MaxConsecutiveToolCalls
dst.MaxOldToolCallTokens = src.MaxOldToolCallTokens
dst.Starred = src.Starred
dst.Permissions = clonePermissionsConfig(src.Permissions)
dst.Permissions = src.Permissions.Clone()
dst.AgentModelOverrides = cloneStringMap(src.AgentModelOverrides)
dst.CustomModelsUsed = cloneStringSlice(src.CustomModelsUsed)
dst.AttachedFiles = src.AttachedFilesSnapshot()
Expand Down Expand Up @@ -319,17 +319,6 @@ func cloneEvalResultChecks(src EvalResultChecks) EvalResultChecks {
return cp
}

func clonePermissionsConfig(src *PermissionsConfig) *PermissionsConfig {
if src == nil {
return nil
}
return &PermissionsConfig{
Allow: cloneStringSlice(src.Allow),
Ask: cloneStringSlice(src.Ask),
Deny: cloneStringSlice(src.Deny),
}
}

func cloneStringMap(src map[string]string) map[string]string {
if len(src) == 0 {
return nil
Expand Down
16 changes: 14 additions & 2 deletions pkg/session/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,18 @@ type PermissionsConfig struct {
Deny []string `json:"deny,omitempty"`
}

// Clone returns a deep copy of the permissions configuration.
func (c *PermissionsConfig) Clone() *PermissionsConfig {
if c == nil {
return nil
}
return &PermissionsConfig{
Allow: slices.Clone(c.Allow),
Ask: slices.Clone(c.Ask),
Deny: slices.Clone(c.Deny),
}
}

// Message is a message from an agent
type Message struct {
// ID is the database ID of the message (used for persistence tracking)
Expand Down Expand Up @@ -888,7 +900,7 @@ func WithSendUserMessage(sendUserMessage bool) Opt {

func WithPermissions(perms *PermissionsConfig) Opt {
return func(s *Session) {
s.Permissions = clonePermissionsConfig(perms)
s.Permissions = perms.Clone()
}
}

Expand Down Expand Up @@ -1046,7 +1058,7 @@ func (s *Session) IsToolsApproved() bool {
func (s *Session) ClonePermissions() *PermissionsConfig {
s.mu.RLock()
defer s.mu.RUnlock()
return clonePermissionsConfig(s.Permissions)
return s.Permissions.Clone()
}

// SetPermissions safely updates the session's PermissionsConfig.
Expand Down
4 changes: 2 additions & 2 deletions pkg/session/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ func (s *InMemorySessionStore) UpdateSession(_ context.Context, session *Session
InputTokens: session.InputTokens,
OutputTokens: session.OutputTokens,
Cost: session.Cost,
Permissions: clonePermissionsConfig(session.Permissions),
Permissions: session.Permissions.Clone(),
AgentModelOverrides: cloneStringMap(session.AgentModelOverrides),
CustomModelsUsed: cloneStringSlice(session.CustomModelsUsed),
AttachedFiles: slices.Clone(session.AttachedFiles),
Expand Down Expand Up @@ -954,7 +954,7 @@ func (s *SQLiteSessionStore) UpdateSession(ctx context.Context, session *Session
InputTokens: session.InputTokens,
OutputTokens: session.OutputTokens,
Cost: session.Cost,
Permissions: clonePermissionsConfig(session.Permissions),
Permissions: session.Permissions.Clone(),
AgentModelOverrides: cloneStringMap(session.AgentModelOverrides),
CustomModelsUsed: cloneStringSlice(session.CustomModelsUsed),
ParentID: session.ParentID,
Expand Down
Loading