diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go index c84fc720..5165a090 100644 --- a/cmd/gateway_managed.go +++ b/cmd/gateway_managed.go @@ -186,6 +186,7 @@ func wireExtras( TenantStore: stores.Tenants, BuiltinToolTenantCfgs: stores.BuiltinToolTenantCfgs, SkillTenantCfgs: stores.SkillTenantCfgs, + SystemConfigs: stores.SystemConfigs, Workspace: workspace, AutoInjector: autoInjector, EvolutionMetricsStore: stores.EvolutionMetrics, diff --git a/cmd/gateway_system_config_sync.go b/cmd/gateway_system_config_sync.go index 7e8b5e02..dbff536f 100644 --- a/cmd/gateway_system_config_sync.go +++ b/cmd/gateway_system_config_sync.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "fmt" "log/slog" @@ -133,4 +134,12 @@ func seedConfigForContext(ctx context.Context, sc store.SystemConfigStore, cfg * set("compaction.provider", pc.Provider) set("compaction.model", pc.Model) } + + // Allowed paths (tenant-scoped filesystem access beyond workspace) + // Stored as JSON array, loaded per-tenant at request time. + if len(cfg.Agents.Defaults.AllowedPaths) > 0 { + if b, err := json.Marshal(cfg.Agents.Defaults.AllowedPaths); err == nil { + set("allowed_paths", string(b)) + } + } } diff --git a/internal/agent/loop_context.go b/internal/agent/loop_context.go index 75623c90..796022e6 100644 --- a/internal/agent/loop_context.go +++ b/internal/agent/loop_context.go @@ -77,6 +77,10 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup if l.tenantToolSettings != nil { ctx = tools.WithTenantToolSettings(ctx, l.tenantToolSettings) } + // Inject tenant-specific allowed paths for filesystem tools. + if len(l.tenantAllowedPaths) > 0 { + ctx = tools.WithTenantAllowedPaths(ctx, l.tenantAllowedPaths) + } // Inject channel type into context for tools (e.g. message tool needs it for Zalo group routing) if req.ChannelType != "" { ctx = tools.WithToolChannelType(ctx, req.ChannelType) @@ -327,6 +331,7 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup TeamTaskID: req.TeamTaskID, LeaderAgentID: tools.LeaderAgentIDFromCtx(ctx), AgentToolKey: l.id, + TenantAllowedPaths: l.tenantAllowedPaths, } ctx = store.WithRunContext(ctx, rc) diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index 7cda95dc..c7d6ea0a 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -169,6 +169,10 @@ type Loop struct { // BuiltinToolSettingsFromCtx with global winning at tool-name level. tenantToolSettings tools.BuiltinToolSettings + // Tenant-specific allowed paths beyond workspace (from system_configs['allowed_paths']). + // Filesystem tools (read_file, write_file, edit, list_files) check these at execution time. + tenantAllowedPaths []string + // Per-tenant disabled tools (tool name → true means excluded from LLM) disabledTools map[string]bool @@ -339,6 +343,9 @@ type LoopConfig struct { // Tenant-layer tool settings overlay (from builtin_tool_tenant_configs.settings). TenantToolSettings tools.BuiltinToolSettings + // Tenant-specific allowed paths beyond workspace (from system_configs['allowed_paths']). + TenantAllowedPaths []string + // Per-tenant disabled tools (tool name → true means excluded) DisabledTools map[string]bool @@ -484,6 +491,7 @@ func NewLoop(cfg LoopConfig) *Loop { maxMessageChars: cfg.MaxMessageChars, builtinToolSettings: cfg.BuiltinToolSettings, tenantToolSettings: cfg.TenantToolSettings, + tenantAllowedPaths: cfg.TenantAllowedPaths, disabledTools: cfg.DisabledTools, reasoningConfig: cfg.ReasoningConfig, promptMode: cfg.PromptMode, diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index 9f29fe5d..f7f8ff67 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -2,6 +2,7 @@ package agent import ( "context" + "encoding/json" "fmt" "log/slog" "os" @@ -109,6 +110,9 @@ type ResolverDeps struct { BuiltinToolTenantCfgs store.BuiltinToolTenantConfigStore SkillTenantCfgs store.SkillTenantConfigStore + // System config store for tenant-scoped settings (allowed_paths, etc.) + SystemConfigs store.SystemConfigStore + // Global workspace root (GOCLAW_WORKSPACE) Workspace string @@ -368,6 +372,22 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { } } + // Load tenant-specific allowed paths (from system_configs['allowed_paths']). + // These extend filesystem tool access beyond the agent's workspace. + var tenantAllowedPaths []string + if deps.SystemConfigs != nil && ag.TenantID != uuid.Nil { + tenantCtx := store.WithTenantID(ctx, ag.TenantID) + if raw, err := deps.SystemConfigs.Get(tenantCtx, "allowed_paths"); err == nil && raw != "" { + if json.Unmarshal([]byte(raw), &tenantAllowedPaths) == nil && len(tenantAllowedPaths) > 0 { + // Expand home directory in paths + for i, p := range tenantAllowedPaths { + tenantAllowedPaths[i] = config.ExpandHome(p) + } + slog.Debug("tenant allowed paths loaded", "agent", agentKey, "tenant", ag.TenantID, "paths", len(tenantAllowedPaths)) + } + } + } + // Filter skills by visibility + agent grants. // Only public skills and explicitly granted internal skills appear in the system prompt. var skillAllowList []string @@ -467,6 +487,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { SandboxWorkspaceAccess: sandboxWorkspaceAccess, BuiltinToolSettings: builtinSettings, TenantToolSettings: tenantToolSettings, + TenantAllowedPaths: tenantAllowedPaths, DisabledTools: disabledTools, ReasoningConfig: store.ResolveEffectiveReasoningConfig(providerReasoningDefaults, ag.ParseReasoningConfig()), PromptMode: PromptMode(ag.ParsePromptMode()), diff --git a/internal/config/config_system.go b/internal/config/config_system.go index c3e576aa..3d225ece 100644 --- a/internal/config/config_system.go +++ b/internal/config/config_system.go @@ -1,6 +1,7 @@ package config import ( + "encoding/json" "strconv" ) @@ -83,4 +84,12 @@ func (c *Config) ApplySystemConfigs(configs map[string]string) { str("compaction.provider", &pc.Provider) str("compaction.model", &pc.Model) } + + // Allowed paths (JSON array) + if v, ok := configs["allowed_paths"]; ok && v != "" { + var paths []string + if err := json.Unmarshal([]byte(v), &paths); err == nil { + c.Agents.Defaults.AllowedPaths = paths + } + } } diff --git a/internal/store/run_context.go b/internal/store/run_context.go index 1656722a..f6c70aa1 100644 --- a/internal/store/run_context.go +++ b/internal/store/run_context.go @@ -46,15 +46,16 @@ type RunContext struct { ShellDenyGroups map[string]bool // Workspace - Workspace string - TeamWorkspace string - TeamID string - WorkspaceChannel string - WorkspaceChatID string - TeamTaskID string - DelegationID string // delegation identifier for vault auto-linking (empty when not in delegation) - LeaderAgentID string // leader's agent UUID for member memory read fallback - AgentToolKey string // tool-level agent key for registry routing + Workspace string + TeamWorkspace string + TeamID string + WorkspaceChannel string + WorkspaceChatID string + TeamTaskID string + DelegationID string // delegation identifier for vault auto-linking (empty when not in delegation) + LeaderAgentID string // leader's agent UUID for member memory read fallback + AgentToolKey string // tool-level agent key for registry routing + TenantAllowedPaths []string // tenant-specific allowed paths beyond workspace (from system_configs) } // WithRunContext stores a RunContext on the context. diff --git a/internal/tools/boundary_test.go b/internal/tools/boundary_test.go index 42be520f..53cb815f 100644 --- a/internal/tools/boundary_test.go +++ b/internal/tools/boundary_test.go @@ -1,6 +1,7 @@ package tools import ( + "context" "os" "path/filepath" "runtime" @@ -463,3 +464,78 @@ func TestResolvePathWithAllowed_SymlinkEscapeBlocked(t *testing.T) { t.Fatal("expected error for symlink escape attempt") } } + +func TestAllowedWithTeamWorkspace_TenantPathsMerge(t *testing.T) { + // Test that allowedWithTeamWorkspace correctly merges: + // base (global) + tenant paths (from context) + team workspace (from context) + ctx := context.Background() + + base := []string{"/global/skills", "/global/builtin"} + tenantPaths := []string{"/tenant/allowed1", "/tenant/allowed2"} + teamWs := "/team/workspace" + + // Inject tenant paths and team workspace into context + ctx = WithTenantAllowedPaths(ctx, tenantPaths) + ctx = WithToolTeamWorkspace(ctx, teamWs) + + result := allowedWithTeamWorkspace(ctx, base) + + // Verify all paths are present in correct order + expected := []string{"/global/skills", "/global/builtin", "/tenant/allowed1", "/tenant/allowed2", "/team/workspace"} + if len(result) != len(expected) { + t.Fatalf("expected %d paths, got %d", len(expected), len(result)) + } + for i, exp := range expected { + if result[i] != exp { + t.Errorf("path[%d]: expected %q, got %q", i, exp, result[i]) + } + } +} + +func TestAllowedWithTeamWorkspace_TenantPathsOnly(t *testing.T) { + // Test tenant paths without team workspace + ctx := context.Background() + base := []string{"/global/skills"} + tenantPaths := []string{"/tenant/data"} + + ctx = WithTenantAllowedPaths(ctx, tenantPaths) + + result := allowedWithTeamWorkspace(ctx, base) + + if len(result) != 2 { + t.Fatalf("expected 2 paths, got %d", len(result)) + } + if result[0] != "/global/skills" || result[1] != "/tenant/data" { + t.Errorf("unexpected result: %v", result) + } +} + +func TestAllowedWithTeamWorkspace_EmptyContext(t *testing.T) { + // Test with no tenant paths or team workspace in context + ctx := context.Background() + base := []string{"/global/skills"} + + result := allowedWithTeamWorkspace(ctx, base) + + // Should return base unchanged + if len(result) != 1 || result[0] != "/global/skills" { + t.Errorf("expected base unchanged, got: %v", result) + } +} + +func TestTenantAllowedPathsFromCtx_Inheritance(t *testing.T) { + // Test that TenantAllowedPathsFromCtx correctly reads from RunContext fallback + ctx := context.Background() + tenantPaths := []string{"/tenant/path1", "/tenant/path2"} + + // Set via direct context + ctx = WithTenantAllowedPaths(ctx, tenantPaths) + result := TenantAllowedPathsFromCtx(ctx) + + if len(result) != 2 { + t.Fatalf("expected 2 paths, got %d", len(result)) + } + if result[0] != "/tenant/path1" || result[1] != "/tenant/path2" { + t.Errorf("unexpected result: %v", result) + } +} diff --git a/internal/tools/context_keys.go b/internal/tools/context_keys.go index 2f12f68d..8ed3022b 100644 --- a/internal/tools/context_keys.go +++ b/internal/tools/context_keys.go @@ -710,3 +710,26 @@ func SandboxConfigFromCtx(ctx context.Context) *sandbox.Config { } return nil } + +// --- Per-tenant allowed paths (filesystem tool access beyond workspace) --- + +const ctxTenantAllowedPaths toolContextKey = "tool_tenant_allowed_paths" + +// WithTenantAllowedPaths injects tenant-specific allowed path prefixes into context. +// These paths extend filesystem tool access beyond the agent's workspace. +// Loaded from system_configs['allowed_paths'] per tenant. +func WithTenantAllowedPaths(ctx context.Context, paths []string) context.Context { + return context.WithValue(ctx, ctxTenantAllowedPaths, paths) +} + +// TenantAllowedPathsFromCtx returns tenant-specific allowed paths from context. +// Falls back to RunContext for subagent inheritance. +func TenantAllowedPathsFromCtx(ctx context.Context) []string { + if v, _ := ctx.Value(ctxTenantAllowedPaths).([]string); len(v) > 0 { + return v + } + if rc := store.RunContextFromCtx(ctx); rc != nil { + return rc.TenantAllowedPaths + } + return nil +} diff --git a/internal/tools/filesystem.go b/internal/tools/filesystem.go index 3c4e8060..ffd7f40f 100644 --- a/internal/tools/filesystem.go +++ b/internal/tools/filesystem.go @@ -304,16 +304,29 @@ func (t *ReadFileTool) paginateOutput(content string, args map[string]any) *Resu return SilentResult(output) } -// allowedWithTeamWorkspace returns the allowed prefixes with team workspace appended -// if present in context. Thread-safe: creates a new slice per request. +// allowedWithTeamWorkspace returns the allowed prefixes with team workspace and +// tenant-specific paths appended if present in context. Thread-safe: creates a +// new slice per request. Merge order: base (global) → tenant paths → team workspace. func allowedWithTeamWorkspace(ctx context.Context, base []string) []string { + tenantPaths := TenantAllowedPathsFromCtx(ctx) teamWs := ToolTeamWorkspaceFromCtx(ctx) - if teamWs == "" { + + if len(tenantPaths) == 0 && teamWs == "" { return base } - out := make([]string, len(base)+1) - copy(out, base) - out[len(base)] = teamWs + + // Pre-allocate capacity for all sources + capacity := len(base) + len(tenantPaths) + if teamWs != "" { + capacity++ + } + + out := make([]string, 0, capacity) + out = append(out, base...) + out = append(out, tenantPaths...) + if teamWs != "" { + out = append(out, teamWs) + } return out } diff --git a/tests/integration/v3_team_task_lifecycle_test.go b/tests/integration/v3_team_task_lifecycle_test.go index bd31f1d4..1ef2a752 100644 --- a/tests/integration/v3_team_task_lifecycle_test.go +++ b/tests/integration/v3_team_task_lifecycle_test.go @@ -3,6 +3,7 @@ package integration import ( + "fmt" "sync" "sync/atomic" "testing" @@ -437,24 +438,35 @@ func TestStoreTask_RaceToClaimSameTask(t *testing.T) { teamID, _ := seedTeam(t, db, tenantID, agentID) - // Create additional team members - var memberIDs []uuid.UUID + // Create additional agents and add them as team members + var memberAgentIDs []uuid.UUID for i := 0; i < 5; i++ { - memberID := uuid.New() + memberAgentID := uuid.New() + memberAgentKey := fmt.Sprintf("race-member-%d-%s", i, memberAgentID.String()[:8]) + // Insert agent with all required columns if _, err := db.Exec( - `INSERT INTO agent_team_members (id, team_id, agent_id, role, tenant_id) - VALUES ($1, $2, $3, 'member', $4)`, - memberID, teamID, agentID, tenantID, + `INSERT INTO agents (id, tenant_id, agent_key, agent_type, status, provider, model, owner_id) + VALUES ($1, $2, $3, 'predefined', 'active', 'test', 'test-model', 'test-owner')`, + memberAgentID, tenantID, memberAgentKey, + ); err != nil { + t.Fatalf("create agent %d: %v", i, err) + } + // Add as team member + if _, err := db.Exec( + `INSERT INTO agent_team_members (team_id, agent_id, role, tenant_id) + VALUES ($1, $2, 'member', $3)`, + teamID, memberAgentID, tenantID, ); err != nil { t.Fatalf("create member %d: %v", i, err) } - memberIDs = append(memberIDs, memberID) + memberAgentIDs = append(memberAgentIDs, memberAgentID) } - // Cleanup test members + // Cleanup test members and agents t.Cleanup(func() { - for _, mid := range memberIDs { - db.Exec("DELETE FROM agent_team_members WHERE id = $1", mid) + for _, aid := range memberAgentIDs { + db.Exec("DELETE FROM agent_team_members WHERE agent_id = $1", aid) + db.Exec("DELETE FROM agents WHERE id = $1", aid) } }) @@ -468,7 +480,7 @@ func TestStoreTask_RaceToClaimSameTask(t *testing.T) { var successCount atomic.Int32 var failCount atomic.Int32 - for _, memberID := range memberIDs { + for _, memberID := range memberAgentIDs { wg.Add(1) go func(mid uuid.UUID) { defer wg.Done()