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
21 changes: 21 additions & 0 deletions cmd/root/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/docker/docker-agent/pkg/cli"
"github.com/docker/docker-agent/pkg/config"
"github.com/docker/docker-agent/pkg/paths"
"github.com/docker/docker-agent/pkg/permissions"
"github.com/docker/docker-agent/pkg/profiling"
"github.com/docker/docker-agent/pkg/runtime"
"github.com/docker/docker-agent/pkg/session"
Expand Down Expand Up @@ -59,6 +60,10 @@ type runExecFlags struct {

// Run only
hideToolResults bool

// globalPermissions holds the user-level global permission checker built
// from user config settings. Nil when no global permissions are configured.
globalPermissions *permissions.Checker
}

func newRunCmd() *cobra.Command {
Expand Down Expand Up @@ -187,6 +192,11 @@ func (f *runExecFlags) runOrExec(ctx context.Context, out *cli.Printer, args []s
}
}

// Build global permissions checker from user config settings.
if userSettings.Permissions != nil {
f.globalPermissions = permissions.NewChecker(userSettings.Permissions)
}

// Start fake proxy if --fake is specified
fakeCleanup, err := setupFakeProxy(f.fakeResponses, f.fakeStreamDelay, &f.runConfig)
if err != nil {
Expand Down Expand Up @@ -308,6 +318,12 @@ func (f *runExecFlags) createRemoteRuntimeAndSession(ctx context.Context, origin
func (f *runExecFlags) createLocalRuntimeAndSession(ctx context.Context, loadResult *teamloader.LoadResult) (runtime.Runtime, *session.Session, error) {
t := loadResult.Team

// Merge user-level global permissions into the team's checker so the
// runtime receives a single, already-merged permission set.
if f.globalPermissions != nil && !f.globalPermissions.IsEmpty() {
t.SetPermissions(permissions.Merge(t.Permissions(), f.globalPermissions))
}

agt, err := t.Agent(f.agentName)
if err != nil {
return nil, nil, err
Expand Down Expand Up @@ -505,6 +521,11 @@ func (f *runExecFlags) createSessionSpawner(agentSource config.Source, sessStore
AgentDefaultModels: loadResult.AgentDefaultModels,
}

// Merge global permissions into the team's checker
if f.globalPermissions != nil && !f.globalPermissions.IsEmpty() {
team.SetPermissions(permissions.Merge(team.Permissions(), f.globalPermissions))
}

// Create the local runtime
localRt, err := runtime.New(team,
runtime.WithSessionStore(sessStore),
Expand Down
16 changes: 16 additions & 0 deletions pkg/permissions/permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,22 @@ func matchAny(patterns []string, toolName string, args map[string]any) bool {
return false
}

// Merge returns a new Checker that combines the patterns from all provided
// checkers. Nil or empty checkers are skipped. The merged checker evaluates
// all deny patterns first, then all allow patterns, then all ask patterns.
func Merge(checkers ...*Checker) *Checker {
var allow, ask, deny []string
for _, c := range checkers {
if c == nil || c.IsEmpty() {
continue
}
allow = append(allow, c.allowPatterns...)
ask = append(ask, c.askPatterns...)
deny = append(deny, c.denyPatterns...)
}
return &Checker{allowPatterns: allow, askPatterns: ask, denyPatterns: deny}
}

// IsEmpty returns true if no permissions are configured
func (c *Checker) IsEmpty() bool {
return len(c.allowPatterns) == 0 && len(c.askPatterns) == 0 && len(c.denyPatterns) == 0
Expand Down
50 changes: 50 additions & 0 deletions pkg/permissions/permissions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -581,3 +581,53 @@ func TestArgToString(t *testing.T) {
})
}
}

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

t.Run("both nil", func(t *testing.T) {
t.Parallel()
merged := Merge(nil, nil)
assert.True(t, merged.IsEmpty())
})

t.Run("one nil", func(t *testing.T) {
t.Parallel()
c := NewChecker(&latest.PermissionsConfig{Allow: []string{"tool_a"}})
merged := Merge(c, nil)
assert.Equal(t, []string{"tool_a"}, merged.AllowPatterns())
})

t.Run("combines patterns", func(t *testing.T) {
t.Parallel()
team := NewChecker(&latest.PermissionsConfig{
Allow: []string{"team_tool"},
Deny: []string{"team_deny"},
})
global := NewChecker(&latest.PermissionsConfig{
Allow: []string{"global_tool"},
Ask: []string{"global_ask"},
})
merged := Merge(team, global)
assert.Equal(t, []string{"team_tool", "global_tool"}, merged.AllowPatterns())
assert.Equal(t, []string{"team_deny"}, merged.DenyPatterns())
assert.Equal(t, []string{"global_ask"}, merged.AskPatterns())
})

t.Run("deny from either source blocks", func(t *testing.T) {
t.Parallel()
team := NewChecker(&latest.PermissionsConfig{Allow: []string{"tool_a"}})
global := NewChecker(&latest.PermissionsConfig{Deny: []string{"tool_a"}})
merged := Merge(team, global)
// Deny is checked first, so global deny overrides team allow
assert.Equal(t, Deny, merged.Check("tool_a"))
})

t.Run("skips empty checkers", func(t *testing.T) {
t.Parallel()
empty := NewChecker(&latest.PermissionsConfig{})
actual := NewChecker(&latest.PermissionsConfig{Deny: []string{"bad"}})
merged := Merge(empty, nil, actual, empty)
assert.Equal(t, []string{"bad"}, merged.DenyPatterns())
})
}
7 changes: 7 additions & 0 deletions pkg/team/team.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,10 @@ func (t *Team) StopToolSets(ctx context.Context) error {
func (t *Team) Permissions() *permissions.Checker {
return t.permissions
}

// SetPermissions replaces the team's permission checker.
// This is used to merge additional permission sources (e.g. user-level global
// permissions) into the team's checker after construction.
func (t *Team) SetPermissions(checker *permissions.Checker) {
t.permissions = checker
}
4 changes: 4 additions & 0 deletions pkg/userconfig/userconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ type Settings struct {
// SoundThreshold is the minimum duration in seconds a task must run
// before a success sound is played. Defaults to 5 seconds.
SoundThreshold int `yaml:"sound_threshold,omitempty"`
// Permissions defines global permission patterns applied across all sessions
// and agents. These act as user-wide defaults; session-level and agent-level
// permissions override them.
Permissions *latest.PermissionsConfig `yaml:"permissions,omitempty"`
}

// DefaultTabTitleMaxLength is the default maximum tab title length when not configured.
Expand Down
30 changes: 30 additions & 0 deletions pkg/userconfig/userconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -860,3 +860,33 @@ func TestSettings_RestoreTabs(t *testing.T) {
})
}
}

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

tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.yaml")

original := &Config{
Aliases: make(map[string]*Alias),
Settings: &Settings{
Permissions: &latest.PermissionsConfig{
Allow: []string{"read_*", "shell:cmd=git*"},
Deny: []string{"shell:cmd=rm*"},
Ask: []string{"shell:cmd=docker*"},
},
},
}

err := original.saveTo(configFile)
require.NoError(t, err)

loaded, err := loadFrom(configFile, "")
require.NoError(t, err)

require.NotNil(t, loaded.Settings)
require.NotNil(t, loaded.Settings.Permissions)
assert.Equal(t, original.Settings.Permissions.Allow, loaded.Settings.Permissions.Allow)
assert.Equal(t, original.Settings.Permissions.Deny, loaded.Settings.Permissions.Deny)
assert.Equal(t, original.Settings.Permissions.Ask, loaded.Settings.Permissions.Ask)
}
Loading