-
Notifications
You must be signed in to change notification settings - Fork 406
fix: propagate session permissions correctly to sub-sessions #3542
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a7c749b
f90d228
425b99a
e21bc2b
7dbcbcb
96d089e
25a6f4a
38c27a8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" | ||
|
|
@@ -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) | ||
|
|
||
| // 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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit, not blocking: this builds a |
||
| assert.Equal(t, toolexec.OutcomeDeny, decision.Outcome, "Inherited Deny should override ToolsApproved: true (yolo)") | ||
| } | ||
|
|
||
| func TestNewSubSession_PermissionsIsolation(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
|
|
@@ -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{}), | ||
| ) | ||
|
|
@@ -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() | ||
|
|
||
|
|
@@ -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{}), | ||
| ) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
| // | ||
|
|
@@ -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 { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Because this preserves the original contract, the external docs ( |
||
| return PermissionDecision{Outcome: OutcomeAllow, Reason: ReasonYolo} | ||
| } | ||
|
|
||
| if readOnlyHint { | ||
| return PermissionDecision{Outcome: OutcomeAllow, Reason: ReasonReadOnlyHint} | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Matches the documented |
||
| } | ||
|
|
||
| func TestDecide_DenyFromCheckerWins(t *testing.T) { | ||
|
|
||
There was a problem hiding this comment.
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.