mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-17 12:16:04 +00:00
feat(tools): tenant-scoped allowed_paths configuration
- Add tenant-level filesystem path restrictions via system_configs table - Merge tenant paths with global skills directories in allowedWithTeamWorkspace() - Propagate tenant paths to subagents via RunContext - Seed allowed_paths from config.json to system_configs on startup - Fix TestStoreTask_RaceToClaimSameTask: use composite PK for team members
This commit is contained in:
1 parent
611377a57e
commit
4b658a2304
11 files changed
+204
-26
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user