diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 97ee4fe7d..0f8653dda 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -102,6 +102,16 @@ type processOptions struct { InboundContext *bus.InboundContext // Normalized inbound facts for events/hooks RouteResult *routing.ResolvedRoute // Route decision snapshot for events/hooks SessionScope *session.SessionScope // Session scope snapshot for events/hooks + + // Multi-tenancy overrides extracted from inbound message Context.Raw. + // Populated by extractTenantOverrides; enforced against the workspace_root + // boundary set in agents.defaults.workspace_root. The overrides scope the + // effective working directory, config, tool/skill allowlists, and (in a + // follow-up PR) sessions and provider credentials for this single turn. + WorkspaceOverride string // Tenant working directory (relative to workspace_root) + ConfigDir string // Tenant config directory (relative to workspace_root) + AllowedTools []string // Tool name allowlist (empty = all tools) + AllowedSkills []string // Skill name allowlist (empty = all skills) } type continuationTarget struct { diff --git a/pkg/agent/agent_message.go b/pkg/agent/agent_message.go index 96b0b0817..07a19a27d 100644 --- a/pkg/agent/agent_message.go +++ b/pkg/agent/agent_message.go @@ -185,6 +185,17 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) AllowInterimPicoPublish: true, } + // Multi-tenancy: extract validated workspace / config_dir / allowed_tools / + // allowed_skills from msg.Context.Raw and apply to opts. Defined in + // agent_tenant.go; isolated there so future upstream syncs don't conflict + // across the whole agent_message.go file. + tenant, tenantErr := al.extractTenantOverrides(msg) + if tenantErr != nil { + return "", tenantErr + } + tenant.applyTo(&opts) + tenant.logIfPresent(sessionKey) + // context-dependent commands check their own Runtime fields and report // "unavailable" when the required capability is nil. if response, handled := al.handleCommand(ctx, msg, agent, &opts); handled { diff --git a/pkg/agent/agent_tenant.go b/pkg/agent/agent_tenant.go new file mode 100644 index 000000000..e0b28511d --- /dev/null +++ b/pkg/agent/agent_tenant.go @@ -0,0 +1,148 @@ +// PicoClaw - Multi-tenancy overrides for the agent loop. +// +// This file is the magicform fork's primary customization on top of upstream. +// It extracts per-message tenant hints (workspace, config dir, tool/skill +// allowlists) from InboundMessage.Context.Raw and validates them against the +// workspace_root security boundary configured in agents.defaults. +// +// Keeping the logic in its own file (rather than scattered through +// agent_message.go) makes upstream syncs easier: most upstream changes won't +// touch this file, and when they do the conflict surface is small and obvious. +// +// PHASE 1 (current): plumb the override fields onto processOptions so the +// agent loop has them available. The fields ride on processOptions; downstream +// callers (pipeline_llm, turn_state, etc.) read them but don't yet swap +// effective sessions/provider/context based on them. +// +// PHASE 2 (future PR): wire effSessions, effContextBuilder, effProvider, and +// effModel onto processOptions and thread them through the turn execution +// path so each tenant's turn runs against an isolated session store, context +// builder, and provider credential set. + +package agent + +import ( + "errors" + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/pathutil" +) + +// Inbound Context.Raw keys carrying tenant hints. Channels (e.g. magicform) +// populate these; the agent loop reads them here. +const ( + rawKeyWorkspaceOverride = "workspace_override" + rawKeyConfigDir = "config_dir" + rawKeyAllowedTools = "allowed_tools" + rawKeyAllowedSkills = "allowed_skills" +) + +// extractTenantOverrides reads multi-tenancy hints from msg.Context.Raw, and, +// when workspace_root is configured, validates that workspace_override and +// config_dir resolve inside it. Returns nil and a zero-valued options patch +// when no hints are present. Returns an error if a hint escapes workspace_root. +func (al *AgentLoop) extractTenantOverrides(msg bus.InboundMessage) (tenantOverrides, error) { + out := tenantOverrides{} + if msg.Context.Raw == nil { + return out, nil + } + + out.workspace = strings.TrimSpace(msg.Context.Raw[rawKeyWorkspaceOverride]) + out.configDir = strings.TrimSpace(msg.Context.Raw[rawKeyConfigDir]) + out.allowedTools = splitCSV(msg.Context.Raw[rawKeyAllowedTools]) + out.allowedSkills = splitCSV(msg.Context.Raw[rawKeyAllowedSkills]) + + if out.workspace == "" && out.configDir == "" { + return out, nil + } + + root := al.cfg.Agents.Defaults.WorkspaceRoot + if root == "" { + // No boundary configured; fail closed rather than allow unbounded paths. + return tenantOverrides{}, errors.New( + "tenant override rejected: agents.defaults.workspace_root must be set " + + "to validate workspace_override / config_dir hints", + ) + } + + if out.workspace != "" { + resolved, err := pathutil.ResolveWorkspacePath(root, out.workspace) + if err != nil { + return tenantOverrides{}, fmt.Errorf("workspace_override rejected: %w", err) + } + out.workspace = resolved + } + if out.configDir != "" { + resolved, err := pathutil.ResolveWorkspacePath(root, out.configDir) + if err != nil { + return tenantOverrides{}, fmt.Errorf("config_dir rejected: %w", err) + } + out.configDir = resolved + } + return out, nil +} + +// tenantOverrides bundles the hints that need to be threaded onto +// processOptions. Kept as a separate value type so the extractor can return +// "no overrides" cheaply, and so future fields (effSessions, effProvider, …) +// can be added in one place. +type tenantOverrides struct { + workspace string + configDir string + allowedTools []string + allowedSkills []string +} + +// applyTo copies the override fields onto processOptions. Caller must have +// already validated via extractTenantOverrides. +func (o tenantOverrides) applyTo(opts *processOptions) { + if o.workspace != "" { + opts.WorkspaceOverride = o.workspace + } + if o.configDir != "" { + opts.ConfigDir = o.configDir + } + if len(o.allowedTools) > 0 { + opts.AllowedTools = o.allowedTools + } + if len(o.allowedSkills) > 0 { + opts.AllowedSkills = o.allowedSkills + } +} + +// logIfPresent emits a single info log line summarising the override surface +// for a turn, so operators can see tenant routing at a glance. +func (o tenantOverrides) logIfPresent(sessionKey string) { + if o.workspace == "" && o.configDir == "" && len(o.allowedTools) == 0 && len(o.allowedSkills) == 0 { + return + } + logger.InfoCF("agent", "Tenant override applied", + map[string]any{ + "session_key": sessionKey, + "workspace": o.workspace, + "config_dir": o.configDir, + "allowed_tools": o.allowedTools, + "allowed_skills": o.allowedSkills, + }) +} + +func splitCSV(s string) []string { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + parts := strings.Split(s, ",") + out := parts[:0] + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/pkg/agent/agent_tenant_test.go b/pkg/agent/agent_tenant_test.go new file mode 100644 index 000000000..408e38c0e --- /dev/null +++ b/pkg/agent/agent_tenant_test.go @@ -0,0 +1,135 @@ +package agent + +import ( + "reflect" + "testing" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" +) + +func newTestLoopWithRoot(t *testing.T, root string) *AgentLoop { + t.Helper() + cfg := config.DefaultConfig() + cfg.Agents.Defaults.WorkspaceRoot = root + return &AgentLoop{cfg: cfg} +} + +func TestExtractTenantOverrides_NoRaw(t *testing.T) { + al := newTestLoopWithRoot(t, t.TempDir()) + got, err := al.extractTenantOverrides(bus.InboundMessage{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.workspace != "" || got.configDir != "" || len(got.allowedTools) > 0 || len(got.allowedSkills) > 0 { + t.Fatalf("expected zero overrides, got %+v", got) + } +} + +func TestExtractTenantOverrides_AllFields(t *testing.T) { + root := t.TempDir() + al := newTestLoopWithRoot(t, root) + + msg := bus.InboundMessage{ + Context: bus.InboundContext{ + Raw: map[string]string{ + "workspace_override": "tenant-a/workspace", + "config_dir": "tenant-a/config", + "allowed_tools": "read_file, write_file ,exec", + "allowed_skills": "search,plan", + }, + }, + } + got, err := al.extractTenantOverrides(msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.workspace == "" { + t.Fatalf("expected workspace to be resolved, got empty") + } + if got.configDir == "" { + t.Fatalf("expected configDir to be resolved, got empty") + } + wantTools := []string{"read_file", "write_file", "exec"} + if !reflect.DeepEqual(got.allowedTools, wantTools) { + t.Fatalf("allowedTools = %v, want %v", got.allowedTools, wantTools) + } + wantSkills := []string{"search", "plan"} + if !reflect.DeepEqual(got.allowedSkills, wantSkills) { + t.Fatalf("allowedSkills = %v, want %v", got.allowedSkills, wantSkills) + } +} + +func TestExtractTenantOverrides_EscapeRejected(t *testing.T) { + al := newTestLoopWithRoot(t, t.TempDir()) + msg := bus.InboundMessage{ + Context: bus.InboundContext{ + Raw: map[string]string{ + "workspace_override": "../../../etc", + }, + }, + } + if _, err := al.extractTenantOverrides(msg); err == nil { + t.Fatalf("expected error for path escape, got nil") + } +} + +func TestExtractTenantOverrides_NoRootBoundary(t *testing.T) { + // workspace_root unset → any override must be rejected. + cfg := config.DefaultConfig() + cfg.Agents.Defaults.WorkspaceRoot = "" + al := &AgentLoop{cfg: cfg} + + msg := bus.InboundMessage{ + Context: bus.InboundContext{ + Raw: map[string]string{"workspace_override": "anything"}, + }, + } + if _, err := al.extractTenantOverrides(msg); err == nil { + t.Fatalf("expected error when workspace_root is unset, got nil") + } +} + +func TestSplitCSV(t *testing.T) { + cases := []struct { + in string + want []string + }{ + {"", nil}, + {" ", nil}, + {",,", nil}, + {"a", []string{"a"}}, + {"a,b,c", []string{"a", "b", "c"}}, + {" a , b , c ", []string{"a", "b", "c"}}, + {"a,,b", []string{"a", "b"}}, + } + for _, c := range cases { + got := splitCSV(c.in) + if !reflect.DeepEqual(got, c.want) { + t.Errorf("splitCSV(%q) = %v, want %v", c.in, got, c.want) + } + } +} + +func TestApplyTo(t *testing.T) { + o := tenantOverrides{ + workspace: "/ws", + configDir: "/cfg", + allowedTools: []string{"x"}, + allowedSkills: []string{"y"}, + } + var opts processOptions + o.applyTo(&opts) + if opts.WorkspaceOverride != "/ws" { + t.Errorf("WorkspaceOverride = %q", opts.WorkspaceOverride) + } + if opts.ConfigDir != "/cfg" { + t.Errorf("ConfigDir = %q", opts.ConfigDir) + } + if !reflect.DeepEqual(opts.AllowedTools, []string{"x"}) { + t.Errorf("AllowedTools = %v", opts.AllowedTools) + } + if !reflect.DeepEqual(opts.AllowedSkills, []string{"y"}) { + t.Errorf("AllowedSkills = %v", opts.AllowedSkills) + } +}