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:
viettranx committed 2026-04-12 21:23:21 +07:00
1 parent 611377a57e
commit 4b658a2304
11 files changed
+204 -26

No files matched your search

+1
View File
@@ -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,
+9
View File
@@ -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))
}
}
}
+5
View File
@@ -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)
+8
View File
@@ -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,
+21
View File
@@ -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()),
+9
View File
@@ -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
}
}
}
+10 -9
View File
@@ -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.
+76
View File
@@ -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)
}
}
+23
View File
@@ -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
}
+19 -6
View File
@@ -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()