Skip to content

Commit d6f7884

Browse files
authored
Merge pull request #2230 from trungutt/feat/global-permissions
Add global-level permissions from user config
2 parents 40e5bec + 1fe3f42 commit d6f7884

6 files changed

Lines changed: 128 additions & 0 deletions

File tree

cmd/root/run.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/docker/docker-agent/pkg/cli"
2121
"github.com/docker/docker-agent/pkg/config"
2222
"github.com/docker/docker-agent/pkg/paths"
23+
"github.com/docker/docker-agent/pkg/permissions"
2324
"github.com/docker/docker-agent/pkg/profiling"
2425
"github.com/docker/docker-agent/pkg/runtime"
2526
"github.com/docker/docker-agent/pkg/session"
@@ -59,6 +60,10 @@ type runExecFlags struct {
5960

6061
// Run only
6162
hideToolResults bool
63+
64+
// globalPermissions holds the user-level global permission checker built
65+
// from user config settings. Nil when no global permissions are configured.
66+
globalPermissions *permissions.Checker
6267
}
6368

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

195+
// Build global permissions checker from user config settings.
196+
if userSettings.Permissions != nil {
197+
f.globalPermissions = permissions.NewChecker(userSettings.Permissions)
198+
}
199+
190200
// Start fake proxy if --fake is specified
191201
fakeCleanup, err := setupFakeProxy(f.fakeResponses, f.fakeStreamDelay, &f.runConfig)
192202
if err != nil {
@@ -308,6 +318,12 @@ func (f *runExecFlags) createRemoteRuntimeAndSession(ctx context.Context, origin
308318
func (f *runExecFlags) createLocalRuntimeAndSession(ctx context.Context, loadResult *teamloader.LoadResult) (runtime.Runtime, *session.Session, error) {
309319
t := loadResult.Team
310320

321+
// Merge user-level global permissions into the team's checker so the
322+
// runtime receives a single, already-merged permission set.
323+
if f.globalPermissions != nil && !f.globalPermissions.IsEmpty() {
324+
t.SetPermissions(permissions.Merge(t.Permissions(), f.globalPermissions))
325+
}
326+
311327
agt, err := t.Agent(f.agentName)
312328
if err != nil {
313329
return nil, nil, err
@@ -505,6 +521,11 @@ func (f *runExecFlags) createSessionSpawner(agentSource config.Source, sessStore
505521
AgentDefaultModels: loadResult.AgentDefaultModels,
506522
}
507523

524+
// Merge global permissions into the team's checker
525+
if f.globalPermissions != nil && !f.globalPermissions.IsEmpty() {
526+
team.SetPermissions(permissions.Merge(team.Permissions(), f.globalPermissions))
527+
}
528+
508529
// Create the local runtime
509530
localRt, err := runtime.New(team,
510531
runtime.WithSessionStore(sessStore),

pkg/permissions/permissions.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,22 @@ func matchAny(patterns []string, toolName string, args map[string]any) bool {
112112
return false
113113
}
114114

115+
// Merge returns a new Checker that combines the patterns from all provided
116+
// checkers. Nil or empty checkers are skipped. The merged checker evaluates
117+
// all deny patterns first, then all allow patterns, then all ask patterns.
118+
func Merge(checkers ...*Checker) *Checker {
119+
var allow, ask, deny []string
120+
for _, c := range checkers {
121+
if c == nil || c.IsEmpty() {
122+
continue
123+
}
124+
allow = append(allow, c.allowPatterns...)
125+
ask = append(ask, c.askPatterns...)
126+
deny = append(deny, c.denyPatterns...)
127+
}
128+
return &Checker{allowPatterns: allow, askPatterns: ask, denyPatterns: deny}
129+
}
130+
115131
// IsEmpty returns true if no permissions are configured
116132
func (c *Checker) IsEmpty() bool {
117133
return len(c.allowPatterns) == 0 && len(c.askPatterns) == 0 && len(c.denyPatterns) == 0

pkg/permissions/permissions_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,3 +581,53 @@ func TestArgToString(t *testing.T) {
581581
})
582582
}
583583
}
584+
585+
func TestMerge(t *testing.T) {
586+
t.Parallel()
587+
588+
t.Run("both nil", func(t *testing.T) {
589+
t.Parallel()
590+
merged := Merge(nil, nil)
591+
assert.True(t, merged.IsEmpty())
592+
})
593+
594+
t.Run("one nil", func(t *testing.T) {
595+
t.Parallel()
596+
c := NewChecker(&latest.PermissionsConfig{Allow: []string{"tool_a"}})
597+
merged := Merge(c, nil)
598+
assert.Equal(t, []string{"tool_a"}, merged.AllowPatterns())
599+
})
600+
601+
t.Run("combines patterns", func(t *testing.T) {
602+
t.Parallel()
603+
team := NewChecker(&latest.PermissionsConfig{
604+
Allow: []string{"team_tool"},
605+
Deny: []string{"team_deny"},
606+
})
607+
global := NewChecker(&latest.PermissionsConfig{
608+
Allow: []string{"global_tool"},
609+
Ask: []string{"global_ask"},
610+
})
611+
merged := Merge(team, global)
612+
assert.Equal(t, []string{"team_tool", "global_tool"}, merged.AllowPatterns())
613+
assert.Equal(t, []string{"team_deny"}, merged.DenyPatterns())
614+
assert.Equal(t, []string{"global_ask"}, merged.AskPatterns())
615+
})
616+
617+
t.Run("deny from either source blocks", func(t *testing.T) {
618+
t.Parallel()
619+
team := NewChecker(&latest.PermissionsConfig{Allow: []string{"tool_a"}})
620+
global := NewChecker(&latest.PermissionsConfig{Deny: []string{"tool_a"}})
621+
merged := Merge(team, global)
622+
// Deny is checked first, so global deny overrides team allow
623+
assert.Equal(t, Deny, merged.Check("tool_a"))
624+
})
625+
626+
t.Run("skips empty checkers", func(t *testing.T) {
627+
t.Parallel()
628+
empty := NewChecker(&latest.PermissionsConfig{})
629+
actual := NewChecker(&latest.PermissionsConfig{Deny: []string{"bad"}})
630+
merged := Merge(empty, nil, actual, empty)
631+
assert.Equal(t, []string{"bad"}, merged.DenyPatterns())
632+
})
633+
}

pkg/team/team.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,10 @@ func (t *Team) StopToolSets(ctx context.Context) error {
127127
func (t *Team) Permissions() *permissions.Checker {
128128
return t.permissions
129129
}
130+
131+
// SetPermissions replaces the team's permission checker.
132+
// This is used to merge additional permission sources (e.g. user-level global
133+
// permissions) into the team's checker after construction.
134+
func (t *Team) SetPermissions(checker *permissions.Checker) {
135+
t.permissions = checker
136+
}

pkg/userconfig/userconfig.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ type Settings struct {
6161
// SoundThreshold is the minimum duration in seconds a task must run
6262
// before a success sound is played. Defaults to 5 seconds.
6363
SoundThreshold int `yaml:"sound_threshold,omitempty"`
64+
// Permissions defines global permission patterns applied across all sessions
65+
// and agents. These act as user-wide defaults; session-level and agent-level
66+
// permissions override them.
67+
Permissions *latest.PermissionsConfig `yaml:"permissions,omitempty"`
6468
}
6569

6670
// DefaultTabTitleMaxLength is the default maximum tab title length when not configured.

pkg/userconfig/userconfig_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -860,3 +860,33 @@ func TestSettings_RestoreTabs(t *testing.T) {
860860
})
861861
}
862862
}
863+
864+
func TestConfig_PermissionsRoundTrip(t *testing.T) {
865+
t.Parallel()
866+
867+
tmpDir := t.TempDir()
868+
configFile := filepath.Join(tmpDir, "config.yaml")
869+
870+
original := &Config{
871+
Aliases: make(map[string]*Alias),
872+
Settings: &Settings{
873+
Permissions: &latest.PermissionsConfig{
874+
Allow: []string{"read_*", "shell:cmd=git*"},
875+
Deny: []string{"shell:cmd=rm*"},
876+
Ask: []string{"shell:cmd=docker*"},
877+
},
878+
},
879+
}
880+
881+
err := original.saveTo(configFile)
882+
require.NoError(t, err)
883+
884+
loaded, err := loadFrom(configFile, "")
885+
require.NoError(t, err)
886+
887+
require.NotNil(t, loaded.Settings)
888+
require.NotNil(t, loaded.Settings.Permissions)
889+
assert.Equal(t, original.Settings.Permissions.Allow, loaded.Settings.Permissions.Allow)
890+
assert.Equal(t, original.Settings.Permissions.Deny, loaded.Settings.Permissions.Deny)
891+
assert.Equal(t, original.Settings.Permissions.Ask, loaded.Settings.Permissions.Ask)
892+
}

0 commit comments

Comments
 (0)