diff --git a/cmd/wippy/cmd/command_security_test.go b/cmd/wippy/cmd/command_security_test.go new file mode 100644 index 000000000..87ecc8e12 --- /dev/null +++ b/cmd/wippy/cmd/command_security_test.go @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/wippyai/runtime/api/attrs" + ctxapi "github.com/wippyai/runtime/api/context" + "github.com/wippyai/runtime/api/registry" + secapi "github.com/wippyai/runtime/api/security" + "go.uber.org/zap" +) + +type commandEntryRegistry struct { + registry.Registry + err error + entry registry.Entry +} + +func (r *commandEntryRegistry) GetEntry(registry.ID) (registry.Entry, error) { + return r.entry, r.err +} + +type commandSecurityRegistry struct { + secapi.Registry + policies map[registry.ID]secapi.Policy +} + +func (r *commandSecurityRegistry) GetPolicy(id registry.ID) (secapi.Policy, error) { + policy, ok := r.policies[id] + if !ok { + return nil, secapi.ErrPolicyNotFound + } + return policy, nil +} + +type commandPolicy struct{ id registry.ID } + +func (p commandPolicy) ID() registry.ID { return p.id } +func (p commandPolicy) Evaluate(secapi.Actor, string, string, attrs.Bag) secapi.Result { + return secapi.Allow +} + +func TestExtractCommandMeta_Security(t *testing.T) { + t.Run("no security block", func(t *testing.T) { + meta, err := extractCommandMeta(map[string]any{ + "command": map[string]any{"name": "test"}, + }) + require.NoError(t, err) + require.NotNil(t, meta) + assert.Nil(t, meta.Security) + }) + + t.Run("actor metadata and policy references", func(t *testing.T) { + meta, err := extractCommandMeta(map[string]any{ + "command": map[string]any{ + "name": "test", + "security": map[string]any{ + "actor": map[string]any{ + "id": "wippy.test:runner", + "meta": map[string]any{"tenant": "acme"}, + }, + "policies": []any{"wippy.test:runner_policy"}, + "groups": []any{"app.security:admin"}, + }, + }, + }) + require.NoError(t, err) + require.NotNil(t, meta) + require.NotNil(t, meta.Security) + assert.Equal(t, "wippy.test:runner", meta.Security.Actor.ID) + assert.Equal(t, attrs.Bag{"tenant": "acme"}, meta.Security.Actor.Meta) + assert.Equal(t, []registry.ID{registry.NewID("wippy.test", "runner_policy")}, meta.Security.Policies) + assert.Equal(t, []registry.ID{registry.NewID("app.security", "admin")}, meta.Security.PolicyGroups) + }) + + t.Run("empty security block remains declared", func(t *testing.T) { + meta, err := extractCommandMeta(map[string]any{ + "command": map[string]any{ + "name": "test", + "security": map[string]any{"actor": map[string]any{}}, + }, + }) + require.NoError(t, err) + require.NotNil(t, meta) + assert.NotNil(t, meta.Security) + }) + + t.Run("malformed policy entries fail decoding", func(t *testing.T) { + meta, err := extractCommandMeta(map[string]any{ + "command": map[string]any{ + "name": "test", + "security": map[string]any{ + "actor": map[string]any{"id": "a"}, + "policies": []any{7}, + }, + }, + }) + assert.Nil(t, meta) + require.Error(t, err) + assert.Contains(t, err.Error(), "decode command metadata") + }) + + t.Run("unknown command metadata remains compatible", func(t *testing.T) { + meta, err := extractCommandMeta(map[string]any{ + "command": map[string]any{ + "name": "test", + "future": map[string]any{"enabled": true}, + }, + }) + require.NoError(t, err) + require.NotNil(t, meta) + assert.Equal(t, "test", meta.Name) + }) +} + +func TestResolveCommandSecurity_ProducesTerminalFrameContext(t *testing.T) { + policyID := registry.NewID("app", "command") + rootCtx := ctxapi.NewRootContext() + rootCtx = registry.WithRegistry(rootCtx, &commandEntryRegistry{entry: registry.Entry{ + ID: registry.NewID("app", "runner"), + Kind: "process.lua", + Meta: map[string]any{ + "command": map[string]any{ + "name": "runner", + "security": map[string]any{ + "actor": map[string]any{ + "id": "app:runner", + "meta": map[string]any{"tenant": "acme"}, + }, + "policies": []any{policyID.String()}, + }, + }, + }, + }}) + rootCtx = secapi.WithRegistry(rootCtx, &commandSecurityRegistry{ + policies: map[registry.ID]secapi.Policy{policyID: commandPolicy{id: policyID}}, + }) + callerCtx, callerFrame := ctxapi.OpenFrameContext(rootCtx) + t.Cleanup(func() { ctxapi.ReleaseFrameContext(callerFrame) }) + + pairs, err := resolveCommandSecurity(callerCtx, registry.NewID("app", "runner")) + require.NoError(t, err) + require.Len(t, pairs, 2) + + // Mirror terminal.Host.prepareContext: create a process frame and apply + // process.Start.Context after inherited values. + processCtx, processFrame := ctxapi.OpenFrameContextOn(context.Background(), callerCtx) + t.Cleanup(func() { ctxapi.ReleaseFrameContext(processFrame) }) + require.NoError(t, processFrame.SetMultiple(pairs...)) + + actor, ok := secapi.GetActor(processCtx) + require.True(t, ok) + assert.Equal(t, "app:runner", actor.ID) + assert.Equal(t, "acme", actor.Meta["tenant"]) + scope, ok := secapi.GetScope(processCtx) + require.True(t, ok) + assert.True(t, scope.Contains(policyID)) +} + +func TestResolveCommandSecurity_FailsClosed(t *testing.T) { + t.Run("malformed declaration", func(t *testing.T) { + rootCtx := registry.WithRegistry(ctxapi.NewRootContext(), &commandEntryRegistry{entry: registry.Entry{ + Meta: map[string]any{"command": map[string]any{ + "name": "runner", + "security": map[string]any{"policies": []any{7}}, + }}, + }}) + + pairs, err := resolveCommandSecurity(rootCtx, registry.NewID("app", "runner")) + assert.Nil(t, pairs) + require.Error(t, err) + assert.Contains(t, err.Error(), "decode command metadata") + }) + + t.Run("missing policy", func(t *testing.T) { + policyID := registry.NewID("app", "missing") + rootCtx := registry.WithRegistry(ctxapi.NewRootContext(), &commandEntryRegistry{entry: registry.Entry{ + Meta: map[string]any{"command": map[string]any{ + "name": "runner", + "security": map[string]any{"policies": []any{policyID.String()}}, + }}, + }}) + rootCtx = secapi.WithRegistry(rootCtx, &commandSecurityRegistry{policies: map[registry.ID]secapi.Policy{}}) + + pairs, err := resolveCommandSecurity(rootCtx, registry.NewID("app", "runner")) + require.Len(t, pairs, 1) + require.Error(t, err) + assert.Contains(t, err.Error(), policyID.String()) + }) + + t.Run("entry lookup failure", func(t *testing.T) { + rootCtx := registry.WithRegistry(ctxapi.NewRootContext(), &commandEntryRegistry{err: errors.New("not found")}) + pairs, err := resolveCommandSecurity(rootCtx, registry.NewID("app", "runner")) + assert.Nil(t, pairs) + require.Error(t, err) + assert.Contains(t, err.Error(), "get command entry") + }) +} + +func TestLaunchExecProcess_RejectsInvalidSecurityBeforeStarting(t *testing.T) { + rootCtx := registry.WithRegistry(ctxapi.NewRootContext(), &commandEntryRegistry{entry: registry.Entry{ + Meta: map[string]any{"command": map[string]any{ + "name": "runner", + "security": map[string]any{"policies": []any{7}}, + }}, + }}) + + err := launchExecProcess(rootCtx, zap.NewNop(), "app:runner", "terminal", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "resolve command security for app:runner") + assert.NotContains(t, err.Error(), ErrProcessManagerNotAvailable.Error()) +} diff --git a/cmd/wippy/cmd/run.go b/cmd/wippy/cmd/run.go index 78b91e6f8..6f79728ef 100644 --- a/cmd/wippy/cmd/run.go +++ b/cmd/wippy/cmd/run.go @@ -4,6 +4,7 @@ package cmd import ( "context" + "encoding/json" "fmt" "os" "os/signal" @@ -16,11 +17,13 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" "github.com/wippyai/runtime/api/boot" + ctxapi "github.com/wippyai/runtime/api/context" logapi "github.com/wippyai/runtime/api/logs" "github.com/wippyai/runtime/api/payload" "github.com/wippyai/runtime/api/process" "github.com/wippyai/runtime/api/registry" "github.com/wippyai/runtime/api/relay" + secapi "github.com/wippyai/runtime/api/security" embedapi "github.com/wippyai/runtime/api/service/fs/embed" supervisorapi "github.com/wippyai/runtime/api/supervisor" bootpkg "github.com/wippyai/runtime/boot" @@ -32,6 +35,7 @@ import ( "github.com/wippyai/runtime/cmd/internal/entries" "github.com/wippyai/runtime/cmd/internal/shutdown" embedpkg "github.com/wippyai/runtime/service/fs/embed" + securitysys "github.com/wippyai/runtime/system/security" supervisorpkg "github.com/wippyai/runtime/system/supervisor" "go.uber.org/zap" "go.uber.org/zap/zapcore" @@ -109,10 +113,15 @@ func init() { // commandMeta represents the command metadata from entry.Meta type commandMeta struct { - Name string `json:"name"` - Short string `json:"short"` - UseCase string `json:"use_case"` - Main bool `json:"main"` + // Security is the security context the command runs under when launched + // from the CLI. It lives inside meta.command on purpose: it applies only + // to the trusted terminal-launcher path, never to ordinary spawns of the + // same process entry. + Security *secapi.Config `json:"security"` + Name string `json:"name"` + Short string `json:"short"` + UseCase string `json:"use_case"` + Main bool `json:"main"` } // runApp is the primary `wippy run` execution flow. @@ -375,35 +384,35 @@ func isProcessKind(kind registry.Kind) bool { return strings.HasPrefix(kind, "process.") } -// extractCommandMeta extracts command metadata from entry.Meta -func extractCommandMeta(meta map[string]any) *commandMeta { +// extractCommandMeta decodes command metadata from entry.Meta. Decoding the +// complete typed structure keeps command discovery and launch on the same +// schema, including nested actor metadata. +func extractCommandMeta(meta map[string]any) (*commandMeta, error) { if meta == nil { - return nil + return nil, nil } cmdData, ok := meta["command"] if !ok { - return nil + return nil, nil } - cmdMap, ok := cmdData.(map[string]any) - if !ok { - return nil + encoded, err := json.Marshal(cmdData) + if err != nil { + return nil, fmt.Errorf("encode command metadata: %w", err) } - - name, _ := cmdMap["name"].(string) - if name == "" { - return nil + var command commandMeta + if err := json.Unmarshal(encoded, &command); err != nil { + return nil, fmt.Errorf("decode command metadata: %w", err) } - - short, _ := cmdMap["short"].(string) - main, _ := cmdMap["main"].(bool) - useCase, _ := cmdMap["use_case"].(string) - if useCase == "" { - useCase = defaultUseCase + if command.Name == "" { + return nil, nil + } + if command.UseCase == "" { + command.UseCase = defaultUseCase } - return &commandMeta{Name: name, Short: short, UseCase: useCase, Main: main} + return &command, nil } // runList prints all command-enabled process entries from resolved lock modules. @@ -442,7 +451,10 @@ func runList(cmd *cobra.Command, _ []string) error { continue } - cmdMeta := extractCommandMeta(e.Meta) + cmdMeta, err := extractCommandMeta(e.Meta) + if err != nil { + return fmt.Errorf("decode command metadata for %s: %w", e.ID.String(), err) + } if cmdMeta == nil { continue } @@ -835,6 +847,12 @@ func launchExecProcess(ctx context.Context, logger *zap.Logger, execSpec, hostID if err != nil { return NewInvalidExecSpecError(err) } + source := registry.NewID(namespace, entry) + + securityPairs, err := resolveCommandSecurity(ctx, source) + if err != nil { + return fmt.Errorf("resolve command security for %s: %w", source.String(), err) + } if hostID == "" { hostID, err = findTerminalHost(ctx) @@ -852,8 +870,6 @@ func launchExecProcess(ctx context.Context, logger *zap.Logger, execSpec, hostID return err } - source := registry.NewID(namespace, entry) - var input payload.Payloads for _, arg := range args { input = append(input, payload.NewString(arg)) @@ -865,6 +881,14 @@ func launchExecProcess(ctx context.Context, logger *zap.Logger, execSpec, hostID Input: input, } + // A command entry may declare its own security context (actor + policy + // scope). The CLI launcher is the trust anchor for terminal commands — + // the operator started this command on their own deployment — so the + // launcher resolves the declared context and attaches it to the start + // context. Without it a command under strict security mode executes with + // an incomplete context and every check denies. + start.Context = append(start.Context, securityPairs...) + pid, err := manager.Start(ctx, start) if err != nil { return NewStartProcessError(hostID, err) @@ -879,6 +903,31 @@ func launchExecProcess(ctx context.Context, logger *zap.Logger, execSpec, hostID return nil } +// resolveCommandSecurity reads meta.command.security from the command entry +// and resolves it into context pairs for the process start. Entries without a +// command security block resolve to no pairs, preserving the caller context. +// A declared but invalid security block fails closed before the process starts. +func resolveCommandSecurity(ctx context.Context, source registry.ID) ([]ctxapi.Pair, error) { + reg := registry.GetRegistry(ctx) + if reg == nil { + return nil, fmt.Errorf("registry not available") + } + entry, err := reg.GetEntry(source) + if err != nil { + return nil, fmt.Errorf("get command entry: %w", err) + } + + cmdMeta, err := extractCommandMeta(entry.Meta) + if err != nil { + return nil, err + } + if cmdMeta == nil || cmdMeta.Security == nil { + return nil, nil + } + + return securitysys.ResolveConfigPairs(ctx, cmdMeta.Security) +} + // waitForHostRunning waits until host is both running in supervisor state and // discoverable in relay node routing. func waitForHostRunning(ctx context.Context, hostID string) error { diff --git a/cmd/wippy/cmd/run_pack.go b/cmd/wippy/cmd/run_pack.go index ad6bf7b31..539fa41b8 100644 --- a/cmd/wippy/cmd/run_pack.go +++ b/cmd/wippy/cmd/run_pack.go @@ -53,14 +53,17 @@ type packCommand struct { // commandsFromEntries projects registry entries into the command entrypoints // they declare, ignoring entries without process kind or command meta. -func commandsFromEntries(items []registry.Entry) []packCommand { +func commandsFromEntries(items []registry.Entry) ([]packCommand, error) { var commands []packCommand for _, e := range items { if !isProcessKind(e.Kind) { continue } - cmdMeta := extractCommandMeta(e.Meta) + cmdMeta, err := extractCommandMeta(e.Meta) + if err != nil { + return nil, fmt.Errorf("decode command metadata for %s: %w", e.ID.String(), err) + } if cmdMeta == nil { continue } @@ -73,7 +76,7 @@ func commandsFromEntries(items []registry.Entry) []packCommand { }) } - return commands + return commands, nil } // collectCommands gathers every command entrypoint declared in the loaded registry. @@ -88,7 +91,7 @@ func collectCommands(ctx context.Context) ([]packCommand, error) { return nil, fmt.Errorf("failed to query registry for commands: %w", err) } - return commandsFromEntries(allEntries), nil + return commandsFromEntries(allEntries) } func collectPackCommands(ctx context.Context, mainModule string) ([]packCommand, error) { @@ -109,7 +112,7 @@ func collectPackCommands(ctx context.Context, mainModule string) ([]packCommand, } filtered = append(filtered, entry) } - return commandsFromEntries(filtered), nil + return commandsFromEntries(filtered) } // commandForUseCase maps a declared use case to the top-level CLI command that diff --git a/cmd/wippy/cmd/run_pack_test.go b/cmd/wippy/cmd/run_pack_test.go index 3bdfd925a..d02f45be4 100644 --- a/cmd/wippy/cmd/run_pack_test.go +++ b/cmd/wippy/cmd/run_pack_test.go @@ -501,7 +501,10 @@ func TestCommandsFromEntries(t *testing.T) { }, } - got := commandsFromEntries(entries) + got, err := commandsFromEntries(entries) + if err != nil { + t.Fatalf("commandsFromEntries: %v", err) + } want := []packCommand{ {name: "serve", entryID: "app:serve", useCase: defaultUseCase, main: true}, {name: "test", entryID: "app:runner", useCase: "test"}, diff --git a/system/security/context.go b/system/security/context.go index ee513b6d4..95f2b8936 100644 --- a/system/security/context.go +++ b/system/security/context.go @@ -5,6 +5,7 @@ package security import ( "context" + ctxapi "github.com/wippyai/runtime/api/context" "github.com/wippyai/runtime/api/security" ) @@ -14,53 +15,16 @@ func actorConfigured(actor security.Actor) bool { // WithSecurityConfig configures the security context based on the provided configuration. func WithSecurityConfig(ctx context.Context, config *security.Config) context.Context { - if config == nil { + pairs, _ := ResolveConfigPairs(ctx, config) + if len(pairs) == 0 { return ctx } - if actorConfigured(config.Actor) { - if err := security.SetActor(ctx, config.Actor); err != nil { - return ctx - } - } else if _, ok := security.GetActor(ctx); !ok { - if err := security.SetActor(ctx, config.Actor); err != nil { - return ctx - } - } - - reg, ok := security.GetRegistry(ctx) - if !ok { + fc := ctxapi.FrameFromContext(ctx) + if fc == nil { return ctx } - - allPolicies := make([]security.Policy, 0) - - for _, groupID := range config.PolicyGroups { - groupScope, err := reg.GetPolicyGroup(groupID) - if err == nil { - allPolicies = append(allPolicies, groupScope.Policies()...) - } - } - - for _, policyID := range config.Policies { - policy, err := reg.GetPolicy(policyID) - if err == nil { - allPolicies = append(allPolicies, policy) - } - } - - if len(allPolicies) > 0 { - scope := NewScope(allPolicies) - if existing, ok := security.GetScope(ctx); ok && existing != nil { - scope = existing - for _, policy := range allPolicies { - scope = scope.With(policy) - } - } - if err := security.SetScope(ctx, scope); err != nil { - return ctx - } - } + _ = fc.SetMultiple(pairs...) return ctx } diff --git a/system/security/pairs.go b/system/security/pairs.go new file mode 100644 index 000000000..d9b1e1bb1 --- /dev/null +++ b/system/security/pairs.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MPL-2.0 + +package security + +import ( + "context" + "errors" + "fmt" + + ctxapi "github.com/wippyai/runtime/api/context" + "github.com/wippyai/runtime/api/security" +) + +// ResolveConfigPairs resolves config using the actor and scope already present +// in ctx. The returned pairs are a complete security context that can either be +// applied locally or transported to a new process frame. +// +// Resolution is best-effort: valid pairs are returned together with errors for +// unresolved policy references. Existing callers that historically tolerated +// missing references can apply the pairs and ignore the error; trust boundaries +// such as process launchers must reject it. +func ResolveConfigPairs(ctx context.Context, config *security.Config) ([]ctxapi.Pair, error) { + if config == nil { + return nil, nil + } + + actor := config.Actor + if !actorConfigured(actor) { + if existing, ok := security.GetActor(ctx); ok { + actor = existing + } + } + pairs := []ctxapi.Pair{security.ActorPair(actor)} + + existingScope, hasExistingScope := security.GetScope(ctx) + if len(config.PolicyGroups) == 0 && len(config.Policies) == 0 { + if hasExistingScope && existingScope != nil { + pairs = append(pairs, security.ScopePair(existingScope)) + } + return pairs, nil + } + + reg, ok := security.GetRegistry(ctx) + if !ok { + if hasExistingScope && existingScope != nil { + pairs = append(pairs, security.ScopePair(existingScope)) + } + return pairs, fmt.Errorf("security registry not available") + } + + policies := make([]security.Policy, 0, len(config.PolicyGroups)+len(config.Policies)) + var resolutionErrors []error + for _, groupID := range config.PolicyGroups { + groupScope, err := reg.GetPolicyGroup(groupID) + if err != nil { + resolutionErrors = append(resolutionErrors, fmt.Errorf("resolve security policy group %s: %w", groupID.String(), err)) + continue + } + policies = append(policies, groupScope.Policies()...) + } + for _, policyID := range config.Policies { + policy, err := reg.GetPolicy(policyID) + if err != nil { + resolutionErrors = append(resolutionErrors, fmt.Errorf("resolve security policy %s: %w", policyID.String(), err)) + continue + } + policies = append(policies, policy) + } + + scope := existingScope + if scope == nil && len(policies) > 0 { + scope = NewScope(policies) + } else { + for _, policy := range policies { + scope = scope.With(policy) + } + } + if scope != nil { + pairs = append(pairs, security.ScopePair(scope)) + } + + return pairs, errors.Join(resolutionErrors...) +} diff --git a/system/security/pairs_test.go b/system/security/pairs_test.go new file mode 100644 index 000000000..f8ef60a10 --- /dev/null +++ b/system/security/pairs_test.go @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MPL-2.0 + +package security + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/wippyai/runtime/api/attrs" + ctxapi "github.com/wippyai/runtime/api/context" + "github.com/wippyai/runtime/api/event" + "github.com/wippyai/runtime/api/registry" + "github.com/wippyai/runtime/api/security" + "github.com/wippyai/runtime/system/eventbus" +) + +func TestResolveConfigPairs(t *testing.T) { + rootCtx := ctxapi.NewRootContext() + ctx, fc := ctxapi.OpenFrameContext(rootCtx) + t.Cleanup(func() { ctxapi.ReleaseFrameContext(fc) }) + + t.Run("nil config resolves to no pairs", func(t *testing.T) { + pairs, err := ResolveConfigPairs(ctx, nil) + require.NoError(t, err) + assert.Nil(t, pairs) + }) + + t.Run("actor without policy references needs no registry", func(t *testing.T) { + pairs, err := ResolveConfigPairs(ctx, &security.Config{ + Actor: security.Actor{ID: "wippy.test:runner"}, + }) + require.NoError(t, err) + require.Len(t, pairs, 1) + actor, ok := pairs[0].Value.(security.Actor) + require.True(t, ok) + assert.Equal(t, "wippy.test:runner", actor.ID) + }) + + t.Run("declared references require a registry", func(t *testing.T) { + pairs, err := ResolveConfigPairs(ctx, &security.Config{ + Actor: security.Actor{ID: "wippy.test:runner"}, + Policies: []registry.ID{registry.NewID("test", "policy")}, + }) + require.Len(t, pairs, 1) + require.EqualError(t, err, "security registry not available") + }) + + t.Run("unresolvable references return partial pairs and an error", func(t *testing.T) { + reg := NewPolicyRegistry(eventbus.NewBus(), nil) + regCtx := security.WithRegistry(ctx, reg) + pairs, err := ResolveConfigPairs(regCtx, &security.Config{ + Actor: security.Actor{ID: "a"}, + Policies: []registry.ID{registry.NewID("test", "missing")}, + }) + require.Len(t, pairs, 1) + require.Error(t, err) + assert.Contains(t, err.Error(), "test:missing") + }) + + t.Run("pairs install actor and scope into a frame context", func(t *testing.T) { + pairs, err := ResolveConfigPairs(ctx, &security.Config{Actor: security.Actor{ID: "a"}}) + require.NoError(t, err) + childCtx, childFC := ctxapi.OpenFrameContext(rootCtx) + t.Cleanup(func() { ctxapi.ReleaseFrameContext(childFC) }) + require.NoError(t, childFC.SetMultiple(pairs...)) + actor, ok := security.GetActor(childCtx) + require.True(t, ok) + assert.Equal(t, "a", actor.ID) + }) +} + +func TestResolveConfigPairs_TransportsInheritedSecurityToProcessFrame(t *testing.T) { + existingPolicy := newMockPolicy("existing", security.Allow) + addedPolicy := newMockPolicy("added", security.Deny) + addedPolicyID := addedPolicy.ID() + reg := NewPolicyRegistry(eventbus.NewBus(), nil) + reg.handleEvent(event.Event{ + Kind: security.PolicyRegister, + Path: addedPolicyID.String(), + Data: &security.PolicyEntry{Policy: addedPolicy}, + }) + + rootCtx := security.WithRegistry(ctxapi.NewRootContext(), reg) + callerCtx, callerFrame := ctxapi.OpenFrameContext(rootCtx) + t.Cleanup(func() { ctxapi.ReleaseFrameContext(callerFrame) }) + require.NoError(t, security.SetActor(callerCtx, security.Actor{ + ID: "caller", + Meta: attrs.Bag{"tenant": "acme"}, + })) + require.NoError(t, security.SetScope(callerCtx, NewScope([]security.Policy{existingPolicy}))) + + pairs, err := ResolveConfigPairs(callerCtx, &security.Config{ + Policies: []registry.ID{addedPolicyID}, + }) + require.NoError(t, err) + + // This is the same frame boundary used by terminal.Host.prepareContext: + // inherit the caller, then apply process.Start.Context pairs. + processCtx, processFrame := ctxapi.OpenFrameContextOn(rootCtx, callerCtx) + t.Cleanup(func() { ctxapi.ReleaseFrameContext(processFrame) }) + require.NoError(t, processFrame.SetMultiple(pairs...)) + + actor, ok := security.GetActor(processCtx) + require.True(t, ok) + assert.Equal(t, "caller", actor.ID) + assert.Equal(t, "acme", actor.Meta["tenant"]) + scope, ok := security.GetScope(processCtx) + require.True(t, ok) + assert.True(t, scope.Contains(existingPolicy.ID())) + assert.True(t, scope.Contains(addedPolicyID)) +}