forward-port: multi-tenancy hint plumbing on agent processOptions (phase 1)
Re-applies the tenant-isolation extension from the deleted pkg/agent/loop.go
to upstream's split agent_*.go layout. Phase 1 plumbs the protocol; Phase 2
(future PR) wires effective sessions / provider / context per turn.
What's added:
- New file pkg/agent/agent_tenant.go (kept isolated for sync friendliness):
- extractTenantOverrides reads workspace_override, config_dir,
allowed_tools, allowed_skills from msg.Context.Raw
- Validates workspace_override and config_dir resolve within the
workspace_root boundary set in agents.defaults; rejects path escape
- Fails closed when any override is present but workspace_root is unset,
so tenants cannot bypass the security boundary
- applyTo copies the overrides onto processOptions
- logIfPresent logs a single line summarising tenant routing per turn
- processOptions in agent.go gains four override fields (WorkspaceOverride,
ConfigDir, AllowedTools, AllowedSkills)
- agent_message.go processMessage extracts and applies the overrides right
after building processOptions
- agent_tenant_test.go covers the happy path, escape rejection, missing
boundary rejection, and CSV parsing
Phase 2 will introduce effSessions, effContextBuilder, effProvider, effModel
on processOptions and thread them through pipeline_llm/turn_state/context_*
so each tenant turn runs against an isolated session store, context builder,
and provider credential set. Phase 2 is deferred because:
- MagicForm doesn't currently send webhook traffic (verified), so phase 1's
protocol-level plumbing has no live consumer that requires the swap yet.
- Phase 2 needs design discussion (per-tenant credential storage, session
backend selection) that warrants its own focused PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cd1720f40b
commit
9a0b281734
4 changed files with 304 additions and 0 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
148
pkg/agent/agent_tenant.go
Normal file
148
pkg/agent/agent_tenant.go
Normal file
|
|
@ -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
|
||||
}
|
||||
135
pkg/agent/agent_tenant_test.go
Normal file
135
pkg/agent/agent_tenant_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue