diff --git a/internal/agent/loop.go b/internal/agent/loop.go index e462d9da..077863fc 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -83,6 +83,9 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) if l.sandboxCfg != nil { ctx = tools.WithSandboxConfig(ctx, l.sandboxCfg) } + if l.shellDenyGroups != nil { + ctx = store.WithShellDenyGroups(ctx, l.shellDenyGroups) + } // Workspace scope propagation (delegation origin → workspace tools). if req.WorkspaceChannel != "" { diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go index 82fd963d..6805d1d0 100644 --- a/internal/agent/loop_history.go +++ b/internal/agent/loop_history.go @@ -194,6 +194,7 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s SandboxEnabled: l.sandboxEnabled, SandboxContainerDir: l.sandboxContainerDir, SandboxWorkspaceAccess: l.sandboxWorkspaceAccess, + ShellDenyGroups: l.shellDenyGroups, SelfEvolve: l.selfEvolve, CredentialCLIContext: l.buildCredentialCLIContext(ctx), IsBootstrap: hadBootstrap && l.agentType != "predefined", diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index 41224eb8..8a587a26 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -91,6 +91,9 @@ type Loop struct { sandboxContainerDir string sandboxWorkspaceAccess string + // Shell deny group overrides from agent other_config (nil = all defaults) + shellDenyGroups map[string]bool + // Event callback for broadcasting agent events (run.started, chunk, tool.call, etc.) onEvent func(event AgentEvent) @@ -200,6 +203,9 @@ type LoopConfig struct { SandboxContainerDir string // e.g. "/workspace" SandboxWorkspaceAccess string // "none", "ro", "rw" + // Shell deny group overrides (nil = all defaults) + ShellDenyGroups map[string]bool + // Agent UUID for context propagation to tools AgentUUID uuid.UUID AgentType string // "open" or "predefined" @@ -319,6 +325,7 @@ func NewLoop(cfg LoopConfig) *Loop { sandboxEnabled: cfg.SandboxEnabled, sandboxContainerDir: cfg.SandboxContainerDir, sandboxWorkspaceAccess: cfg.SandboxWorkspaceAccess, + shellDenyGroups: cfg.ShellDenyGroups, traceCollector: cfg.TraceCollector, inputGuard: guard, injectionAction: action, diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index 651c3eb7..7789e394 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -348,6 +348,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { SkillEvolve: ag.AgentType == "predefined" && ag.ParseSkillEvolve(), SkillNudgeInterval: ag.ParseSkillNudgeInterval(), WorkspaceSharing: ag.ParseWorkspaceSharing(), + ShellDenyGroups: ag.ParseShellDenyGroups(), GroupWriterCache: deps.GroupWriterCache, TeamStore: deps.TeamStore, SecureCLIStore: deps.SecureCLIStore, diff --git a/internal/store/agent_store.go b/internal/store/agent_store.go index 605e1858..74ebb38d 100644 --- a/internal/store/agent_store.go +++ b/internal/store/agent_store.go @@ -232,6 +232,21 @@ func (a *AgentData) ParseWorkspaceSharing() *WorkspaceSharingConfig { return cfg.WS } +// ParseShellDenyGroups extracts shell_deny_groups from other_config JSONB. +// Returns nil if not configured (all defaults apply). +func (a *AgentData) ParseShellDenyGroups() map[string]bool { + if len(a.OtherConfig) == 0 { + return nil + } + var cfg struct { + ShellDenyGroups map[string]bool `json:"shell_deny_groups"` + } + if json.Unmarshal(a.OtherConfig, &cfg) != nil || len(cfg.ShellDenyGroups) == 0 { + return nil + } + return cfg.ShellDenyGroups +} + // AgentShareData represents an agent share grant. type AgentShareData struct { BaseModel diff --git a/internal/tools/message_test.go b/internal/tools/message_test.go index 27b340a6..9f8c19a0 100644 --- a/internal/tools/message_test.go +++ b/internal/tools/message_test.go @@ -21,8 +21,13 @@ func TestResolveMediaPath(t *testing.T) { t.Fatal(err) } + // Normalize paths to canonical form (resolves macOS /var/folders → /private/var/folders symlink). + // The resolvePath function uses filepath.EvalSymlinks, so test expectations must too. + testFileCanonical, _ := filepath.EvalSymlinks(testFile) + workspaceCanonical, _ := filepath.EvalSymlinks(workspace) + t.Run("restricted", func(t *testing.T) { - tool := NewMessageTool(workspace, true) + tool := NewMessageTool(workspaceCanonical, true) ctx := context.Background() tests := []struct { @@ -36,8 +41,8 @@ func TestResolveMediaPath(t *testing.T) { {"valid nested temp", "MEDIA:" + filepath.Join(tmpDir, "sub", "file.txt"), filepath.Join(tmpDir, "sub", "file.txt"), true}, // Workspace files allowed - {"workspace absolute", "MEDIA:" + testFile, testFile, true}, - {"workspace relative", "MEDIA:docs/report.pdf", testFile, true}, + {"workspace absolute", "MEDIA:" + testFileCanonical, testFileCanonical, true}, + {"workspace relative", "MEDIA:docs/report.pdf", testFileCanonical, true}, // Not a MEDIA: message {"no prefix", filepath.Join(tmpDir, "test.png"), "", false}, @@ -48,7 +53,7 @@ func TestResolveMediaPath(t *testing.T) { // Outside workspace + outside /tmp/ → blocked {"outside workspace", "MEDIA:/etc/passwd", "", false}, - {"traversal attack", "MEDIA:" + filepath.Join(workspace, "..", "etc", "passwd"), "", false}, + {"traversal attack", "MEDIA:" + filepath.Join(workspaceCanonical, "..", "etc", "passwd"), "", false}, } for _, tt := range tests { @@ -91,14 +96,14 @@ func TestResolveMediaPath(t *testing.T) { t.Run("context workspace override", func(t *testing.T) { // Tool has no workspace, but context provides one. tool := NewMessageTool("", true) - ctx := WithToolWorkspace(context.Background(), workspace) + ctx := WithToolWorkspace(context.Background(), workspaceCanonical) got, ok := tool.resolveMediaPath(ctx, "MEDIA:docs/report.pdf") if !ok { t.Fatal("expected ok=true for workspace-relative path with context workspace") } - if got != testFile { - t.Errorf("got %q, want %q", got, testFile) + if got != testFileCanonical { + t.Errorf("got %q, want %q", got, testFileCanonical) } }) }