Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/tools/background-agents/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ The background agents tool lets an orchestrator dispatch work to sub-agents conc
| `task` | string | ✓ | Clear, concise description of the task the sub-agent should achieve. |
| `expected_output` | string | ✗ | Optional description of the result format the caller expects. |

`run_background_agent` returns a **task ID** string. Tools run by the sub-agent are pre-approved, so only dispatch to trusted sub-agents with well-scoped tasks.
`run_background_agent` returns a **task ID** string. Tools run by the sub-agent inherit the parent session's permissions. Because background tasks run non-interactively, any tool call that would normally prompt the user for approval will be automatically denied. To allow background agents to run mutating tools, you must explicitly approve them in the parent session (e.g. via YOLO mode or explicit allow rules).

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.

Needs a decision: this flips run_background_agent from blanket pre-approval to "inherit parent permissions, auto-deny anything that would prompt". In a default interactive session that makes background agents effectively read-only unless YOLO mode or explicit allow rules are set. The wording reads well; just confirming this is the intended default posture and not only an internal-safety tweak, since it can break setups that relied on background agents performing mutating actions.

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.

Yes, confirming this is the intended default safety posture.

The previous blanket pre-approval allowed background agents to bypass user authorization gates entirely. Restricting them to inherit the parent session's permissions (and auto-denying any prompt-requiring tools due to their non-interactive environment) ensures they default to a safe, read-only-equivalent mode unless explicitly authorized by the user (e.g., via YOLO mode or explicit allow rules).


### `view_background_agent` and `stop_background_agent` parameters

Expand Down
30 changes: 15 additions & 15 deletions pkg/runtime/agent_delegation.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ type SubSessionConfig struct {
Title string
// ToolsApproved overrides whether tools are pre-approved in the child session.
ToolsApproved bool
// Permissions defines session-level tool permission overrides.
Permissions *session.PermissionsConfig
// NonInteractive marks the child session as running without a user present
// (e.g. MCP server, A2A adapter, background agent). This causes the runtime
// to auto-stop on max iterations instead of blocking for user input.
Expand Down Expand Up @@ -184,6 +186,7 @@ func newSubSession(parent *session.Session, cfg SubSessionConfig, childAgent *ag
if cfg.PinAgent {
opts = append(opts, session.WithAgentName(cfg.AgentName))
}
opts = append(opts, session.WithPermissions(cfg.Permissions))
// Merge parent's excluded tools with config's excluded tools so that
// nested sub-sessions (e.g. skill → transfer_task → child) inherit
// exclusions from all ancestors and don't re-introduce filtered tools.
Expand Down Expand Up @@ -319,11 +322,12 @@ func (r *LocalRuntime) runForwarding(ctx context.Context, parent *session.Sessio
return nil, subSessionErr
}

// Only propagate ToolsApproved on success. A failed sub-session must not
// silently escalate the parent's tool-approval gate: the user approved
// Only propagate ToolsApproved and Permissions on success. A failed sub-session
// must not silently escalate the parent's tool-approval gate: the user approved
// tools within a sub-session scope that ended in error, and that approval
// should not carry over to the parent's remaining turns.
parent.ToolsApproved = s.ToolsApproved
parent.SetToolsApproved(s.IsToolsApproved())
parent.SetPermissions(s.ClonePermissions())
span.SetStatus(codes.Ok, "sub-session completed")
return tools.ResultSuccess(s.GetLastAssistantMessageContent()), nil
}
Expand Down Expand Up @@ -476,23 +480,18 @@ func (r *LocalRuntime) CurrentAgentSubAgentNames() []string {
// RunAgent implements agenttool.Runner. It starts a sub-agent synchronously
// and blocks until completion or cancellation.
//
// Background tasks run with tools pre-approved because there is no user
// present to respond to interactive approval prompts during async
// execution. This is a deliberate design trade-off: the user implicitly
// authorises all tool calls made by the sub-agent when they approve
// run_background_agent. Callers should be aware that prompt injection in
// the sub-agent's context could exploit this gate-bypass.
//
// TODO: propagate the parent session's per-tool permission rules once the
// runtime supports per-session permission scoping rather than a single
// shared ToolsApproved flag.
// Background tasks inherit the parent session's permissions because there is no user
// present to respond to interactive approval prompts during async execution.
// Tool calls that result in an "Ask" outcome will be auto-denied by the dispatcher
// due to the non-interactive context.
func (r *LocalRuntime) RunAgent(ctx context.Context, params agenttool.RunParams) *agenttool.RunResult {
return r.runCollecting(ctx, params.ParentSession, SubSessionConfig{
Task: params.Task,
ExpectedOutput: params.ExpectedOutput,
AgentName: params.AgentName,
Title: "Background agent task",
ToolsApproved: true,
ToolsApproved: params.ParentSession.IsToolsApproved(),
Permissions: params.ParentSession.ClonePermissions(),

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.

Minor: clone style is inconsistent across call sites. Here RunAgent passes ClonePermissions() (explicit clone), while handleTaskTransfer and RunSkillFork pass the raw sess.Permissions and rely on WithPermissions to clone. Safe either way, but one style everywhere reads better. Since WithPermissions now always clones, passing the raw pointer at all three sites is enough and drops the double clone here.

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.

I've updated both handleTaskTransfer and RunSkillFork to consistently retrieve permissions via sess.ClonePermissions() and sess.IsToolsApproved().

NonInteractive: true,
PinAgent: true,
}, params.OnContent)
Expand Down Expand Up @@ -551,7 +550,8 @@ func (r *LocalRuntime) handleTaskTransfer(ctx context.Context, sess *session.Ses
ExpectedOutput: params.ExpectedOutput,
AgentName: params.Agent,
Title: "Transferred task",
ToolsApproved: sess.ToolsApproved,
ToolsApproved: sess.IsToolsApproved(),
Permissions: sess.ClonePermissions(),
NonInteractive: sess.NonInteractive,
},
SwitchCurrentAgent: true,
Expand Down
206 changes: 206 additions & 0 deletions pkg/runtime/agent_delegation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import (

"github.com/docker/docker-agent/pkg/agent"
"github.com/docker/docker-agent/pkg/session"
"github.com/docker/docker-agent/pkg/team"
"github.com/docker/docker-agent/pkg/tools"
agenttool "github.com/docker/docker-agent/pkg/tools/builtin/agent"
)

func TestBuildTaskSystemMessage(t *testing.T) {
Expand Down Expand Up @@ -266,3 +269,206 @@ func TestSubSessionWithoutAttachedFilesOmitsBlock(t *testing.T) {
assert.NotContains(t, m.Content, "<attached_files>")
}
}

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

parent := session.New(session.WithUserMessage("hello"))
childAgent := agent.New("worker", "")

t.Run("cloned from config", func(t *testing.T) {
perms := &session.PermissionsConfig{
Allow: []string{"read_file"},
}

cfg := SubSessionConfig{
Task: "isolated work",
AgentName: "worker",
Title: "Task",
Permissions: perms,
}

s := newSubSession(parent, cfg, childAgent)

require.NotNil(t, s.Permissions)
assert.Equal(t, []string{"read_file"}, s.Permissions.Allow)

perms.Allow = append(perms.Allow, "write_file")

assert.Equal(t, []string{"read_file"}, s.Permissions.Allow)
})

t.Run("nil permissions", func(t *testing.T) {
cfg := SubSessionConfig{
Task: "work without permissions",
AgentName: "worker",
Title: "Task",
}

s := newSubSession(parent, cfg, childAgent)
assert.Nil(t, s.Permissions)
})
}

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

t.Run("returns deep copy", func(t *testing.T) {
perms := &session.PermissionsConfig{
Allow: []string{"read_file"},
Deny: []string{"write_file"},
}
s := session.New(session.WithPermissions(perms))

cloned := s.ClonePermissions()
require.NotNil(t, cloned)
assert.Equal(t, perms.Allow, cloned.Allow)
assert.Equal(t, perms.Deny, cloned.Deny)

cloned.Allow = append(cloned.Allow, "exec_command")
original := s.ClonePermissions()
assert.Equal(t, []string{"read_file"}, original.Allow)
})

t.Run("returns nil when unset", func(t *testing.T) {
s := session.New()
assert.Nil(t, s.ClonePermissions())
})
}

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

s := session.New()
assert.Nil(t, s.ClonePermissions())

perms := &session.PermissionsConfig{
Allow: []string{"read_file"},
}
s.SetPermissions(perms)

got := s.ClonePermissions()
require.NotNil(t, got)
assert.Equal(t, []string{"read_file"}, got.Allow)
}

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

workerStream := newStreamBuilder().AddContent("done").AddStopWithUsage(10, 5).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))
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{"read_file", "list_dir"},
Deny: []string{"shell:cmd=rm*"},
}
parentSession := session.New(
session.WithUserMessage("Test"),
session.WithToolsApproved(true),
session.WithPermissions(parentPerms),
)

result := rt.RunAgent(t.Context(), agenttool.RunParams{
AgentName: "worker",
Task: "do something",
ParentSession: parentSession,
})
require.Empty(t, result.ErrMsg, "RunAgent should succeed")

var childSession *session.Session
for _, item := range parentSession.Messages {
if item.SubSession != nil {
childSession = item.SubSession
break
}
}
require.NotNil(t, childSession, "parent must have a sub-session")

assert.True(t, childSession.ToolsApproved,
"child session must inherit ToolsApproved from parent")

require.NotNil(t, childSession.Permissions)
assert.Equal(t, []string{"read_file", "list_dir"}, childSession.Permissions.Allow)
assert.Equal(t, []string{"shell:cmd=rm*"}, childSession.Permissions.Deny)

childSession.Permissions.Allow = append(childSession.Permissions.Allow, "write_file")
parentClone := parentSession.ClonePermissions()
assert.Equal(t, []string{"read_file", "list_dir"}, parentClone.Allow,
"parent permissions must be isolated from child mutations")
}

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

childStream := newStreamBuilder().AddContent("transferred").AddStopWithUsage(10, 5).Build()
prov := &mockProvider{id: "test/mock-model", stream: childStream}

librarian := agent.New("librarian", "Library agent", agent.WithModel(prov))
root := agent.New("root", "Root agent", agent.WithModel(prov))
agent.WithSubAgents(librarian)(root)

tm := team.New(team.WithAgents(root, librarian))
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"},
}
sess := session.New(
session.WithUserMessage("Test"),
session.WithToolsApproved(true),
session.WithPermissions(parentPerms),
)
evts := make(chan Event, 128)

toolCall := tools.ToolCall{
ID: "call_1",
Type: "function",
Function: tools.FunctionCall{
Name: "transfer_task",
Arguments: `{"agent":"librarian","task":"find a book","expected_output":"book title"}`,
},
}

result, err := rt.handleTaskTransfer(t.Context(), sess, toolCall, NewChannelSink(evts))
require.NoError(t, err)
require.NotNil(t, result)
assert.False(t, result.IsError, "transfer to valid sub-agent should succeed")

var childSession *session.Session
for _, item := range sess.Messages {
if item.SubSession != nil {
childSession = item.SubSession
break
}
}
require.NotNil(t, childSession, "parent must have a sub-session after transfer_task")

require.NotNil(t, childSession.Permissions)
assert.Equal(t, []string{"safe_tool"}, childSession.Permissions.Allow)
assert.Equal(t, []string{"dangerous_tool"}, childSession.Permissions.Deny)

assert.True(t, childSession.ToolsApproved,
"child session must inherit ToolsApproved from parent")

childSession.Permissions.Allow = append(childSession.Permissions.Allow, "exploit")
parentClone := sess.ClonePermissions()
assert.Equal(t, []string{"safe_tool"}, parentClone.Allow,
"parent permissions must remain isolated from child mutations after transfer_task")
}
3 changes: 2 additions & 1 deletion pkg/runtime/skill_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ func (r *LocalRuntime) RunSkillFork(ctx context.Context, sess *session.Session,
ImplicitUserMessage: skills.BuildSkillUserMessage(prepared),
AgentName: ca,
Title: "Skill: " + prepared.SkillName,
ToolsApproved: sess.ToolsApproved,
ToolsApproved: sess.IsToolsApproved(),
Permissions: sess.ClonePermissions(),
NonInteractive: sess.NonInteractive,
ExcludedTools: []string{skills.ToolNameRunSkill},
AllowedTools: prepared.AllowedTools,
Expand Down
8 changes: 4 additions & 4 deletions pkg/runtime/tool_dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,12 @@ func (r *LocalRuntime) processToolCalls(ctx context.Context, sess *session.Sessi
// evaluate (session-level first, then team-level).
func (r *LocalRuntime) permissionCheckers(sess *session.Session) []toolexec.NamedChecker {
var checkers []toolexec.NamedChecker
if sess.Permissions != nil {
if perms := sess.ClonePermissions(); perms != nil {
checkers = append(checkers, toolexec.NamedChecker{
Checker: permissions.NewChecker(&latest.PermissionsConfig{
Allow: sess.Permissions.Allow,
Ask: sess.Permissions.Ask,
Deny: sess.Permissions.Deny,
Allow: perms.Allow,
Ask: perms.Ask,
Deny: perms.Deny,
}),
Source: "session permissions",
})
Expand Down
20 changes: 3 additions & 17 deletions pkg/runtime/toolexec/dispatcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"fmt"
"log/slog"
"maps"
"slices"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -175,7 +174,6 @@ type Dispatcher struct {
Recall func(ctx context.Context, sess *session.Session, a *agent.Agent, message string) error

confirmationMu sync.Mutex
approvalMu sync.Mutex
}

var (
Expand Down Expand Up @@ -434,15 +432,12 @@ func (c *call) approveAndRun(ctx context.Context, runTool func() CallOutcome) Ca
}

func (c *call) permissionDecision(readOnlyHint bool) PermissionDecision {
c.d.approvalMu.Lock()
defer c.d.approvalMu.Unlock()

var checkers []NamedChecker
if c.d.Permissions != nil {
checkers = c.d.Permissions(c.sess)
}
return Decide(
c.sess.ToolsApproved,
c.sess.IsToolsApproved(),
checkers,
c.tc.Function.Name,
ParseToolInput(c.tc.Function.Arguments),
Expand Down Expand Up @@ -789,24 +784,15 @@ func (c *call) handleResume(ctx context.Context, req ResumeRequest, runTool func
return runTool()
case ResumeTypeApproveSession:
slog.DebugContext(ctx, "Resume signal received, approving session", "tool", c.tc.Function.Name, "session_id", c.sess.ID)
c.d.approvalMu.Lock()
c.sess.ToolsApproved = true
c.d.approvalMu.Unlock()
c.sess.SetToolsApproved(true)
c.notifyApproval(ctx, ApprovalDecisionAllow, ApprovalSourceUserApprovedSession)
return runTool()
case ResumeTypeApproveTool:
approvedTool := req.ToolName
if approvedTool == "" {
approvedTool = c.tc.Function.Name
}
c.d.approvalMu.Lock()
if c.sess.Permissions == nil {
c.sess.Permissions = &session.PermissionsConfig{}
}
if !slices.Contains(c.sess.Permissions.Allow, approvedTool) {
c.sess.Permissions.Allow = append(c.sess.Permissions.Allow, approvedTool)
}
c.d.approvalMu.Unlock()
c.sess.AppendPermissionAllow(approvedTool)
slog.DebugContext(ctx, "Resume signal received, approving tool permanently", "tool", approvedTool, "session_id", c.sess.ID)
c.notifyApproval(ctx, ApprovalDecisionAllow, ApprovalSourceUserApprovedTool)
return runTool()
Expand Down
Loading
Loading