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
41 changes: 41 additions & 0 deletions pkg/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,47 @@ func (a *App) Session() *session.Session {
return a.session
}

// PermissionsInfo returns combined permissions info from team and session.
// Returns nil if no permissions are configured at either level.
func (a *App) PermissionsInfo() *runtime.PermissionsInfo {
// Get team-level permissions from runtime
teamPerms := a.runtime.PermissionsInfo()

// Get session-level permissions
var sessionPerms *runtime.PermissionsInfo
if a.session != nil && a.session.Permissions != nil {
if len(a.session.Permissions.Allow) > 0 || len(a.session.Permissions.Deny) > 0 {
sessionPerms = &runtime.PermissionsInfo{
Allow: a.session.Permissions.Allow,
Deny: a.session.Permissions.Deny,
}
}
}

// Return nil if no permissions configured at any level
if teamPerms == nil && sessionPerms == nil {
return nil
}

// Merge permissions, with session taking priority (listed first)
result := &runtime.PermissionsInfo{}
if sessionPerms != nil {
result.Allow = append(result.Allow, sessionPerms.Allow...)
result.Deny = append(result.Deny, sessionPerms.Deny...)
}
if teamPerms != nil {
result.Allow = append(result.Allow, teamPerms.Allow...)
result.Deny = append(result.Deny, teamPerms.Deny...)
}

return result
}

// HasPermissions returns true if any permissions are configured (team or session level).
func (a *App) HasPermissions() bool {
return a.PermissionsInfo() != nil
}

// SwitchAgent switches the currently active agent for subsequent user messages
func (a *App) SwitchAgent(agentName string) error {
return a.runtime.SetCurrentAgent(agentName)
Expand Down
3 changes: 2 additions & 1 deletion pkg/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ func (m *mockRuntime) ResumeElicitation(ctx context.Context, action tools.Elicit
func (m *mockRuntime) SessionStore() session.Store { return nil }
func (m *mockRuntime) Summarize(ctx context.Context, sess *session.Session, additionalPrompt string, events chan runtime.Event) {
}
func (m *mockRuntime) Stop() {}
func (m *mockRuntime) PermissionsInfo() *runtime.PermissionsInfo { return nil }
func (m *mockRuntime) Stop() {}

// Verify mockRuntime implements runtime.Runtime
var _ runtime.Runtime = (*mockRuntime)(nil)
Expand Down
10 changes: 10 additions & 0 deletions pkg/permissions/permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,16 @@ func (c *Checker) IsEmpty() bool {
return len(c.allowPatterns) == 0 && len(c.denyPatterns) == 0
}

// AllowPatterns returns the list of allow patterns.
func (c *Checker) AllowPatterns() []string {
return c.allowPatterns
}

// DenyPatterns returns the list of deny patterns.
func (c *Checker) DenyPatterns() []string {
return c.denyPatterns
}

// parsePattern parses a permission pattern into tool name pattern and argument conditions.
// Pattern format: "toolname" or "toolname:arg1=val1:arg2=val2"
// Returns the tool pattern and a map of argument patterns.
Expand Down
1 change: 1 addition & 0 deletions pkg/runtime/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ func (m *mockRuntime) ResumeElicitation(context.Context, tools.ElicitationAction
func (m *mockRuntime) SessionStore() session.Store { return nil }
func (m *mockRuntime) Summarize(context.Context, *session.Session, string, chan Event) {
}
func (m *mockRuntime) PermissionsInfo() *PermissionsInfo { return nil }

func TestResolveCommand_SimpleCommand(t *testing.T) {
t.Parallel()
Expand Down
5 changes: 5 additions & 0 deletions pkg/runtime/remote_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,11 @@ func (r *RemoteRuntime) SessionStore() session.Store {
return nil
}

// PermissionsInfo returns nil for remote runtime since permissions are handled server-side.
func (r *RemoteRuntime) PermissionsInfo() *PermissionsInfo {
return nil
}

// ResetStartupInfo is a no-op for remote runtime.
func (r *RemoteRuntime) ResetStartupInfo() {
}
Expand Down
23 changes: 23 additions & 0 deletions pkg/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,16 @@ type Runtime interface {

// Summarize generates a summary for the session
Summarize(ctx context.Context, sess *session.Session, additionalPrompt string, events chan Event)

// PermissionsInfo returns the team-level permission patterns (allow/deny).
// Returns nil if no permissions are configured.
PermissionsInfo() *PermissionsInfo
}

// PermissionsInfo contains the allow and deny patterns for tool permissions.
type PermissionsInfo struct {
Allow []string
Deny []string
}

type CurrentAgentInfo struct {
Expand Down Expand Up @@ -546,6 +556,19 @@ func (r *LocalRuntime) SessionStore() session.Store {
return r.sessionStore
}

// PermissionsInfo returns the team-level permission patterns.
// Returns nil if no permissions are configured.
func (r *LocalRuntime) PermissionsInfo() *PermissionsInfo {
permChecker := r.team.Permissions()
if permChecker == nil || permChecker.IsEmpty() {
return nil
}
return &PermissionsInfo{
Allow: permChecker.AllowPatterns(),
Deny: permChecker.DenyPatterns(),
}
}

// ResetStartupInfo resets the startup info emission flag.
// This should be called when replacing a session to allow re-emission of
// agent, team, and toolset info to the UI.
Expand Down
21 changes: 21 additions & 0 deletions pkg/tui/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,16 @@ func builtInSessionCommands() []Item {
return core.CmdHandler(messages.ShowCostDialogMsg{})
},
},
{
ID: "session.permissions",
Label: "Permissions",
SlashCommand: "/permissions",
Description: "Show tool permission rules for this session",
Category: "Session",
Execute: func(string) tea.Cmd {
return core.CmdHandler(messages.ShowPermissionsDialogMsg{})
},
},
{
ID: "session.attach",
Label: "Attach",
Expand Down Expand Up @@ -246,6 +256,17 @@ func BuildCommandCategories(ctx context.Context, application *app.App) []Categor
sessionCommands = filtered
}

// Hide /permissions if no permissions are configured
if !application.HasPermissions() {
filtered := make([]Item, 0, len(sessionCommands))
for _, cmd := range sessionCommands {
if cmd.ID != "session.permissions" {
filtered = append(filtered, cmd)
}
}
sessionCommands = filtered
}

categories := []Category{
{
Name: "Session",
Expand Down
209 changes: 209 additions & 0 deletions pkg/tui/dialog/permissions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package dialog

import (
"strings"

"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"

"github.com/docker/cagent/pkg/runtime"
"github.com/docker/cagent/pkg/tui/core"
"github.com/docker/cagent/pkg/tui/core/layout"
"github.com/docker/cagent/pkg/tui/styles"
)

// permissionsDialog displays the configured tool permissions (allow/deny patterns).
type permissionsDialog struct {
BaseDialog
permissions *runtime.PermissionsInfo
yoloEnabled bool
keyMap permissionsDialogKeyMap
offset int
}

type permissionsDialogKeyMap struct {
Close, Up, Down, PageUp, PageDown key.Binding
}

// NewPermissionsDialog creates a new dialog showing tool permission rules.
func NewPermissionsDialog(perms *runtime.PermissionsInfo, yoloEnabled bool) Dialog {
return &permissionsDialog{
permissions: perms,
yoloEnabled: yoloEnabled,
keyMap: permissionsDialogKeyMap{
Close: key.NewBinding(key.WithKeys("esc", "enter", "q"), key.WithHelp("Esc", "close")),
Up: key.NewBinding(key.WithKeys("up", "k")),
Down: key.NewBinding(key.WithKeys("down", "j")),
PageUp: key.NewBinding(key.WithKeys("pgup")),
PageDown: key.NewBinding(key.WithKeys("pgdown")),
},
}
}

func (d *permissionsDialog) Init() tea.Cmd {
return nil
}

func (d *permissionsDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
cmd := d.SetSize(msg.Width, msg.Height)
return d, cmd

case tea.KeyPressMsg:
switch {
case key.Matches(msg, d.keyMap.Close):
return d, core.CmdHandler(CloseDialogMsg{})
case key.Matches(msg, d.keyMap.Up):
d.offset = max(0, d.offset-1)
case key.Matches(msg, d.keyMap.Down):
d.offset++
case key.Matches(msg, d.keyMap.PageUp):
d.offset = max(0, d.offset-d.pageSize())
case key.Matches(msg, d.keyMap.PageDown):
d.offset += d.pageSize()
}

case tea.MouseWheelMsg:
switch msg.Button.String() {
case "wheelup":
d.offset = max(0, d.offset-1)
case "wheeldown":
d.offset++
}
}
return d, nil
}

func (d *permissionsDialog) dialogSize() (dialogWidth, maxHeight, contentWidth int) {
dialogWidth = d.ComputeDialogWidth(60, 40, 70)
maxHeight = min(d.Height()*70/100, 30)
contentWidth = d.ContentWidth(dialogWidth, 2)
return dialogWidth, maxHeight, contentWidth
}

func (d *permissionsDialog) pageSize() int {
_, maxHeight, _ := d.dialogSize()
return max(1, maxHeight-10)
}

func (d *permissionsDialog) Position() (row, col int) {
dialogWidth, maxHeight, _ := d.dialogSize()
return CenterPosition(d.Width(), d.Height(), dialogWidth, maxHeight)
}

func (d *permissionsDialog) View() string {
dialogWidth, maxHeight, contentWidth := d.dialogSize()
content := d.renderContent(contentWidth, maxHeight)
return styles.DialogStyle.Padding(1, 2).Width(dialogWidth).Render(content)
}

func (d *permissionsDialog) renderContent(contentWidth, maxHeight int) string {
// Build all lines
lines := []string{
RenderTitle("Tool Permissions", contentWidth, styles.DialogTitleStyle),
RenderSeparator(contentWidth),
"",
}

// Show yolo mode status
lines = append(lines, d.renderYoloStatus(), "")

if d.permissions == nil {
lines = append(lines, styles.MutedStyle.Render("No permission patterns configured."), "")
} else {
// Deny section (checked first during evaluation)
if len(d.permissions.Deny) > 0 {
lines = append(lines, d.renderSectionHeader("Deny", "Always blocked, even with yolo mode"), "")
for _, pattern := range d.permissions.Deny {
lines = append(lines, d.renderPattern(pattern, true))
}
lines = append(lines, "")
}

// Allow section
if len(d.permissions.Allow) > 0 {
lines = append(lines, d.renderSectionHeader("Allow", "Auto-approved without confirmation"), "")
for _, pattern := range d.permissions.Allow {
lines = append(lines, d.renderPattern(pattern, false))
}
lines = append(lines, "")
}

// If both are empty
if len(d.permissions.Allow) == 0 && len(d.permissions.Deny) == 0 {
lines = append(lines, styles.MutedStyle.Render("No permission patterns configured."), "")
}
}

// Apply scrolling
return d.applyScrolling(lines, contentWidth, maxHeight)
}

func (d *permissionsDialog) renderYoloStatus() string {
label := lipgloss.NewStyle().Bold(true).Render("Yolo Mode: ")
var status string
if d.yoloEnabled {
status = lipgloss.NewStyle().Foreground(styles.Success).Render("ON")
status += styles.MutedStyle.Render(" (auto-approve unmatched tools)")
} else {
status = lipgloss.NewStyle().Foreground(styles.TextSecondary).Render("OFF")
status += styles.MutedStyle.Render(" (ask for unmatched tools)")
}
return label + status
}

func (d *permissionsDialog) renderSectionHeader(title, description string) string {
header := lipgloss.NewStyle().Bold(true).Foreground(styles.TextSecondary).Render(title)
desc := styles.MutedStyle.Render(" - " + description)
return header + desc
}

func (d *permissionsDialog) renderPattern(pattern string, isDeny bool) string {
// Use different colors for deny (red-ish) vs allow (green-ish)
var icon string
var style lipgloss.Style
if isDeny {
icon = "✗"
style = lipgloss.NewStyle().Foreground(styles.Error)
} else {
icon = "✓"
style = lipgloss.NewStyle().Foreground(styles.Success)
}

return style.Render(icon) + " " + lipgloss.NewStyle().Foreground(styles.Highlight).Render(pattern)
}

func (d *permissionsDialog) applyScrolling(allLines []string, contentWidth, maxHeight int) string {
const headerLines = 3 // title + separator + space
const footerLines = 2 // space + help

visibleLines := max(1, maxHeight-headerLines-footerLines-4)
contentLines := allLines[headerLines:]
totalContentLines := len(contentLines)

// Clamp offset
maxOffset := max(0, totalContentLines-visibleLines)
d.offset = min(d.offset, maxOffset)

// Extract visible portion
endIdx := min(d.offset+visibleLines, totalContentLines)
parts := append(allLines[:headerLines], contentLines[d.offset:endIdx]...)

// Scroll indicator
if totalContentLines > visibleLines {
scrollInfo := lipgloss.NewStyle().Render("")
if d.offset > 0 {
scrollInfo = "↑ "
}
scrollInfo += styles.MutedStyle.Render("[" + strings.Repeat("─", 3) + "]")
if endIdx < totalContentLines {
scrollInfo += " ↓"
}
parts = append(parts, styles.MutedStyle.Render(scrollInfo))
}

parts = append(parts, "", RenderHelpKeys(contentWidth, "↑↓", "scroll", "Esc", "close"))
return lipgloss.JoinVertical(lipgloss.Left, parts...)
}
11 changes: 11 additions & 0 deletions pkg/tui/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,17 @@ func (a *appModel) handleShowCostDialog() (tea.Model, tea.Cmd) {
})
}

// Permissions

func (a *appModel) handleShowPermissionsDialog() (tea.Model, tea.Cmd) {
perms := a.application.PermissionsInfo()
sess := a.application.Session()
yoloEnabled := sess != nil && sess.ToolsApproved
return a, core.CmdHandler(dialog.OpenDialogMsg{
Model: dialog.NewPermissionsDialog(perms, yoloEnabled),
})
}

// MCP prompt handlers

func (a *appModel) handleShowMCPPromptInput(promptName string, promptInfo any) (tea.Model, tea.Cmd) {
Expand Down
Loading