-
Notifications
You must be signed in to change notification settings - Fork 413
fix: correct Decide precedence and remove sub-session scope escalation #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
Changes from all commits
65f350b
a7b6349
6e18ffc
592035e
c5ccc5b
e96e53e
386cfbd
ad7cf88
9030854
f18be82
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,47 @@ 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) | ||||||||||||||||||||
|
Piyush0049 marked this conversation as resolved.
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| // Even with ToolsApproved set (yolo), an inherited Deny must win 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) | ||||||||||||||||||||
|
Piyush0049 marked this conversation as resolved.
|
||||||||||||||||||||
| assert.Equal(t, toolexec.OutcomeDeny, decision.Outcome, "Inherited Deny should override ToolsApproved: true (yolo)") | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| func TestNewSubSession_PermissionsIsolation(t *testing.T) { | ||||||||||||||||||||
| t.Parallel() | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
@@ -409,6 +454,125 @@ func TestRunAgent_InheritsParentPermissions(t *testing.T) { | |||||||||||||||||||
| "parent permissions must be isolated from child mutations") | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // TestRunForwarding_DoesNotBackPropagateApprovals locks the "permissions only | ||||||||||||||||||||
| // flow downwards" invariant: approvals granted within a sub-session scope must | ||||||||||||||||||||
| // not escalate the parent's ToolsApproved gate or permission rules. | ||||||||||||||||||||
| func TestRunForwarding_DoesNotBackPropagateApprovals(t *testing.T) { | ||||||||||||||||||||
| t.Parallel() | ||||||||||||||||||||
|
|
||||||||||||||||||||
| childStream := newStreamBuilder().AddContent("done").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) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| parent := session.New( | ||||||||||||||||||||
| session.WithUserMessage("Test"), | ||||||||||||||||||||
| session.WithPermissions(&session.PermissionsConfig{Deny: []string{"dangerous_tool"}}), | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| require.False(t, parent.IsToolsApproved()) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| evts := make(chan Event, 128) | ||||||||||||||||||||
| // Child scope broader than the parent's, as if the user had clicked | ||||||||||||||||||||
| // "approve all" / "always allow" inside the sub-session. | ||||||||||||||||||||
| _, err = rt.runForwarding(t.Context(), parent, NewChannelSink(evts), delegationRequest{ | ||||||||||||||||||||
| SubSessionConfig: SubSessionConfig{ | ||||||||||||||||||||
| Task: "find a book", | ||||||||||||||||||||
| AgentName: "librarian", | ||||||||||||||||||||
| Title: "Transferred task", | ||||||||||||||||||||
| ToolsApproved: true, | ||||||||||||||||||||
| Permissions: &session.PermissionsConfig{ | ||||||||||||||||||||
| Allow: []string{"exploit_tool"}, | ||||||||||||||||||||
| Deny: []string{"dangerous_tool"}, | ||||||||||||||||||||
| }, | ||||||||||||||||||||
| }, | ||||||||||||||||||||
| SwitchCurrentAgent: true, | ||||||||||||||||||||
| }) | ||||||||||||||||||||
| require.NoError(t, err) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| assert.False(t, parent.IsToolsApproved(), | ||||||||||||||||||||
| "a sub-session must not escalate the parent's ToolsApproved gate") | ||||||||||||||||||||
| parentPerms := parent.ClonePermissions() | ||||||||||||||||||||
| require.NotNil(t, parentPerms) | ||||||||||||||||||||
| assert.Empty(t, parentPerms.Allow, | ||||||||||||||||||||
| "child-scope approvals must not leak into the parent's Allow list") | ||||||||||||||||||||
| assert.Equal(t, []string{"dangerous_tool"}, parentPerms.Deny) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| func TestRunAgent_EndToEndPermissions(t *testing.T) { | ||||||||||||||||||||
|
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. The new no-back-propagation invariant has no locking test: no existing test fails if // TestRunForwarding_DoesNotBackPropagateApprovals locks the "permissions only
// flow downwards" invariant: approvals granted within a sub-session scope must
// not escalate the parent's ToolsApproved gate or permission rules.
func TestRunForwarding_DoesNotBackPropagateApprovals(t *testing.T) {
t.Parallel()
childStream := newStreamBuilder().AddContent("done").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)
parent := session.New(
session.WithUserMessage("Test"),
session.WithPermissions(&session.PermissionsConfig{Deny: []string{"dangerous_tool"}}),
)
require.False(t, parent.IsToolsApproved())
evts := make(chan Event, 128)
// Child scope broader than the parent's, as if the user had clicked
// "approve all" / "always allow" inside the sub-session.
_, err = rt.runForwarding(t.Context(), parent, NewChannelSink(evts), delegationRequest{
SubSessionConfig: SubSessionConfig{
Task: "find a book",
AgentName: "librarian",
Title: "Transferred task",
ToolsApproved: true,
Permissions: &session.PermissionsConfig{
Allow: []string{"exploit_tool"},
Deny: []string{"dangerous_tool"},
},
},
SwitchCurrentAgent: true,
})
require.NoError(t, err)
assert.False(t, parent.IsToolsApproved(),
"a sub-session must not escalate the parent's ToolsApproved gate")
parentPerms := parent.ClonePermissions()
require.NotNil(t, parentPerms)
assert.Empty(t, parentPerms.Allow,
"child-scope approvals must not leak into the parent's Allow list")
assert.Equal(t, []string{"dangerous_tool"}, parentPerms.Deny)
} |
||||||||||||||||||||
| 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), | ||||||||||||||||||||
| ) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| 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") | ||||||||||||||||||||
| require.NotNil(t, childSession.Permissions) | ||||||||||||||||||||
| assert.Equal(t, []string{"dangerous_tool"}, childSession.Permissions.Deny, | ||||||||||||||||||||
| "child must inherit the parent's Deny rules") | ||||||||||||||||||||
| require.False(t, executed, "expected dangerous_tool to NOT be executed because it is denied by inherited permissions") | ||||||||||||||||||||
|
Comment on lines
+569
to
+573
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:
Suggested change
|
||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| func TestTransferTask_PropagatesPermissions(t *testing.T) { | ||||||||||||||||||||
| t.Parallel() | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| 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,12 +18,26 @@ func newChecker(t *testing.T, allow, ask, deny []string) *permissions.Checker { | |
| }) | ||
| } | ||
|
|
||
| func TestDecide_YoloShortCircuits(t *testing.T) { | ||
| func TestDecide_DenyOverridesYolo(t *testing.T) { | ||
|
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: after the rename, two paths of the new ordering have no func TestDecide_YoloAllowsWhenNoCheckerMatches(t *testing.T) {
t.Parallel()
d := Decide(true, nil, "shell", nil, false)
assert.Equal(t, PermissionDecision{Outcome: OutcomeAllow, Reason: ReasonYolo}, d)
}
func TestDecide_YoloOverridesForceAsk(t *testing.T) {
t.Parallel()
d := Decide(true, []NamedChecker{
{Checker: newChecker(t, nil, []string{"shell"}, nil), Source: "team"},
}, "shell", nil, false)
assert.Equal(t, PermissionDecision{Outcome: OutcomeAllow, Reason: ReasonYolo}, d)
} |
||
| t.Parallel() | ||
| d := Decide(true, []NamedChecker{ | ||
| {Checker: newChecker(t, nil, nil, []string{"shell"}), Source: "team"}, | ||
| }, "shell", nil, false) | ||
|
|
||
| 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_YoloAllowsWhenNoCheckerMatches(t *testing.T) { | ||
| t.Parallel() | ||
| d := Decide(true, nil, "shell", nil, false) | ||
| assert.Equal(t, PermissionDecision{Outcome: OutcomeAllow, Reason: ReasonYolo}, d) | ||
| } | ||
|
|
||
| func TestDecide_YoloOverridesForceAsk(t *testing.T) { | ||
| t.Parallel() | ||
| d := Decide(true, []NamedChecker{ | ||
| {Checker: newChecker(t, nil, []string{"shell"}, nil), Source: "team"}, | ||
| }, "shell", nil, false) | ||
| assert.Equal(t, PermissionDecision{Outcome: OutcomeAllow, Reason: ReasonYolo}, d) | ||
| } | ||
|
|
||
|
|
||
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 removal is a real behavior change, not a cleanup, and it was not among the previous round's asks. The success-path back-propagation was introduced deliberately in 838f7dd ("Update the tools approved of the parent session once the sub-session ended") and recently narrowed to the success path on main (fd89580) with an explicit rationale comment, the one deleted here.
What changes for interactive users:
transfer_task/run_skillchild<tool>"The security argument is sound: it matches the "permissions only flow downwards" scoping this PR establishes, and it closes the ratchet where a child scope elevates the parent for the rest of the turn. The cost is UX: users who click "approve all" inside a delegation will be prompted again in the parent.
Accepting the removal is reasonable, under two conditions (tracked in the summary table):
runForwardingdoc comment (line 251, "propagating tool-approval state back to the parent on completion") must state the new contract instead, e.g. "The child's approval state stays scoped to the sub-session and never flows back to the parent";agent_delegation_test.go.If keeping the "approve all persists to the parent" UX is preferred instead, this hunk can be reverted and moved to its own PR so the
Decideprecedence fix is not held up.