From 1fe3f424c651d493446b5dd6ab35f21790c27153 Mon Sep 17 00:00:00 2001 From: Trung Nguyen Date: Tue, 24 Mar 2026 20:50:29 +0100 Subject: [PATCH] feat: add global-level permissions from user config Allow users to define permission patterns in ~/.config/cagent/config.yaml that apply across all sessions and agents as user-wide defaults. Global patterns are merged into the team's permission checker before the runtime is created, so the runtime receives a single pre-merged set. --- cmd/root/run.go | 21 ++++++++++++ pkg/permissions/permissions.go | 16 +++++++++ pkg/permissions/permissions_test.go | 50 +++++++++++++++++++++++++++++ pkg/team/team.go | 7 ++++ pkg/userconfig/userconfig.go | 4 +++ pkg/userconfig/userconfig_test.go | 30 +++++++++++++++++ 6 files changed, 128 insertions(+) diff --git a/cmd/root/run.go b/cmd/root/run.go index c650c98f6..fdea45d34 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -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" @@ -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 { @@ -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 { @@ -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 @@ -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), diff --git a/pkg/permissions/permissions.go b/pkg/permissions/permissions.go index 5653e9b37..ed1d7d982 100644 --- a/pkg/permissions/permissions.go +++ b/pkg/permissions/permissions.go @@ -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 diff --git a/pkg/permissions/permissions_test.go b/pkg/permissions/permissions_test.go index 44784a3ea..2a648e11f 100644 --- a/pkg/permissions/permissions_test.go +++ b/pkg/permissions/permissions_test.go @@ -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()) + }) +} diff --git a/pkg/team/team.go b/pkg/team/team.go index df68ec8bc..872a3f191 100644 --- a/pkg/team/team.go +++ b/pkg/team/team.go @@ -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 +} diff --git a/pkg/userconfig/userconfig.go b/pkg/userconfig/userconfig.go index f6e8bb688..b1b62e3bf 100644 --- a/pkg/userconfig/userconfig.go +++ b/pkg/userconfig/userconfig.go @@ -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. diff --git a/pkg/userconfig/userconfig_test.go b/pkg/userconfig/userconfig_test.go index d7bc612a8..feb82d21a 100644 --- a/pkg/userconfig/userconfig_test.go +++ b/pkg/userconfig/userconfig_test.go @@ -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) +}