From 74ea94f5d5e1a95493824fbcc3637d256e412115 Mon Sep 17 00:00:00 2001 From: Wolfy-J Date: Wed, 5 Aug 2026 21:08:46 -0400 Subject: [PATCH 1/3] feat(process): honor entry-declared security config on process start Process entries silently dropped their security block: ProcessConfig and BytecodeProcessConfig had no Security field, and the process component registered factories with method-only metadata. Function entries and supervised services already apply entry security via WithSecurityConfig, leaving processes as the one component type without declared capabilities. Security now flows from the entry config through process.Meta into both host Run paths (service host and terminal host), applied to the process frame context before scheduling. Semantics match the function path: a declared actor replaces the inherited one, declared policies merge onto the inherited scope. Entries without a security block are unaffected; meta.Security is nil and the frame context is unchanged. --- api/process/process.go | 4 ++ api/runtime/lua/config.go | 26 ++++---- runtime/lua/component/process/manager.go | 25 ++++---- runtime/lua/component/process/manager_test.go | 2 +- service/host/host.go | 4 ++ service/host/host_test.go | 61 +++++++++++++++++++ service/terminal/host.go | 4 ++ 7 files changed, 102 insertions(+), 24 deletions(-) diff --git a/api/process/process.go b/api/process/process.go index 9c4beb3fc..3ebf8705a 100644 --- a/api/process/process.go +++ b/api/process/process.go @@ -14,6 +14,7 @@ import ( "github.com/wippyai/runtime/api/registry" "github.com/wippyai/runtime/api/relay" "github.com/wippyai/runtime/api/runtime" + "github.com/wippyai/runtime/api/security" ) // System identifies the process system in the event bus. @@ -39,6 +40,9 @@ type ( // Meta contains metadata about a process type. Meta struct { Method string + // Security carries the entry-declared security configuration; hosts + // apply it to the process frame context at start. + Security *security.Config } // Start contains the configuration needed to start a new process. diff --git a/api/runtime/lua/config.go b/api/runtime/lua/config.go index 7f63bb12c..109540f59 100644 --- a/api/runtime/lua/config.go +++ b/api/runtime/lua/config.go @@ -79,11 +79,12 @@ type ( // ProcessConfig defines the configuration for a Lua processes. ProcessConfig struct { - Meta attrs.Bag `json:"meta"` // Metadata for the terminal - Source string `json:"source" resolve:"-"` // Lua source code - Method string `json:"method"` // Alias of the Lua method to execute - Imports map[string]registry.ID `json:"imports,omitempty"` // Imports aliases for the library - Modules []string `json:"modules,omitempty"` // Shortcut for importing modules + Meta attrs.Bag `json:"meta"` // Metadata for the terminal + Security *security.Config `json:"security,omitempty" yaml:"security,omitempty"` + Source string `json:"source" resolve:"-"` // Lua source code + Method string `json:"method"` // Alias of the Lua method to execute + Imports map[string]registry.ID `json:"imports,omitempty"` // Imports aliases for the library + Modules []string `json:"modules,omitempty"` // Shortcut for importing modules } // WorkflowConfig defines the configuration for a Lua workflow. @@ -131,13 +132,14 @@ type ( // BytecodeProcessConfig defines configuration for a precompiled Lua process. BytecodeProcessConfig struct { - Imports map[string]registry.ID `json:"imports,omitempty"` - Meta attrs.Bag `json:"meta,omitempty"` - FS string `json:"fs"` - Path string `json:"path"` - Hash string `json:"hash"` - Method string `json:"method"` - Modules []string `json:"modules,omitempty"` + Imports map[string]registry.ID `json:"imports,omitempty"` + Meta attrs.Bag `json:"meta,omitempty"` + Security *security.Config `json:"security,omitempty" yaml:"security,omitempty"` + FS string `json:"fs"` + Path string `json:"path"` + Hash string `json:"hash"` + Method string `json:"method"` + Modules []string `json:"modules,omitempty"` } // BytecodeWorkflowConfig defines configuration for a precompiled Lua workflow. diff --git a/runtime/lua/component/process/manager.go b/runtime/lua/component/process/manager.go index e30dafcb9..360adeb00 100644 --- a/runtime/lua/component/process/manager.go +++ b/runtime/lua/component/process/manager.go @@ -13,6 +13,7 @@ import ( "github.com/wippyai/runtime/api/process" "github.com/wippyai/runtime/api/registry" api "github.com/wippyai/runtime/api/runtime/lua" + "github.com/wippyai/runtime/api/security" runtimelua "github.com/wippyai/runtime/runtime/lua" "github.com/wippyai/runtime/runtime/lua/code" "github.com/wippyai/runtime/runtime/lua/component" @@ -26,6 +27,7 @@ type configEntry struct { source *api.ProcessConfig bytecode *api.BytecodeProcessConfig method string + security *security.Config } // Manager handles both source and bytecode Lua process components. @@ -115,7 +117,7 @@ func (m *Manager) Invalidate(ctx context.Context, ids []registry.ID) error { } } - if err := m.registerFactory(ctx, id, cfg.method); err != nil { + if err := m.registerFactory(ctx, id, cfg.method, cfg.security); err != nil { m.log.Error("failed to invalidate process", zap.Error(err)) errs = append(errs, err) continue @@ -166,9 +168,9 @@ func (m *Manager) addSource(ctx context.Context, entry registry.Entry) error { return runtimelua.NewAddNodeError("process", err) } - m.configs.Store(entry.ID, &configEntry{method: cfg.Method, source: cfg}) + m.configs.Store(entry.ID, &configEntry{method: cfg.Method, source: cfg, security: cfg.Security}) - if err := m.registerFactory(ctx, entry.ID, cfg.Method); err != nil { + if err := m.registerFactory(ctx, entry.ID, cfg.Method, cfg.Security); err != nil { _ = m.code.DeleteNode(ctx, entry.ID) m.configs.Delete(entry.ID) return runtimelua.NewRegisterFactoryError(err) @@ -200,9 +202,9 @@ func (m *Manager) addBytecode(ctx context.Context, entry registry.Entry) error { return runtimelua.NewAddNodeError("process", err) } - m.configs.Store(entry.ID, &configEntry{method: cfg.Method, bytecode: cfg}) + m.configs.Store(entry.ID, &configEntry{method: cfg.Method, bytecode: cfg, security: cfg.Security}) - if err := m.registerFactory(ctx, entry.ID, cfg.Method); err != nil { + if err := m.registerFactory(ctx, entry.ID, cfg.Method, cfg.Security); err != nil { _ = m.code.DeleteNode(ctx, entry.ID) m.configs.Delete(entry.ID) return runtimelua.NewRegisterFactoryError(err) @@ -234,9 +236,9 @@ func (m *Manager) updateSource(ctx context.Context, entry registry.Entry) error return runtimelua.NewUpdateNodeError("process", err) } - m.configs.Store(entry.ID, &configEntry{method: cfg.Method, source: cfg}) + m.configs.Store(entry.ID, &configEntry{method: cfg.Method, source: cfg, security: cfg.Security}) - if err := m.registerFactory(ctx, entry.ID, cfg.Method); err != nil { + if err := m.registerFactory(ctx, entry.ID, cfg.Method, cfg.Security); err != nil { return runtimelua.NewUpdateFactoryError(err) } @@ -266,9 +268,9 @@ func (m *Manager) updateBytecode(ctx context.Context, entry registry.Entry) erro return runtimelua.NewUpdateNodeError("process", err) } - m.configs.Store(entry.ID, &configEntry{method: cfg.Method, bytecode: cfg}) + m.configs.Store(entry.ID, &configEntry{method: cfg.Method, bytecode: cfg, security: cfg.Security}) - if err := m.registerFactory(ctx, entry.ID, cfg.Method); err != nil { + if err := m.registerFactory(ctx, entry.ID, cfg.Method, cfg.Security); err != nil { return runtimelua.NewUpdateFactoryError(err) } @@ -277,7 +279,7 @@ func (m *Manager) updateBytecode(ctx context.Context, entry registry.Entry) erro } // registerFactory registers a process factory with the factory registry and waits for confirmation. -func (m *Manager) registerFactory(ctx context.Context, id registry.ID, method string) error { +func (m *Manager) registerFactory(ctx context.Context, id registry.ID, method string, sec *security.Config) error { // Create factory using ProcessFactory factoryFn, err := m.factory.CreateFactory(id, engine.WithModules(component.ExecutableAmbientModules()...)) if err != nil { @@ -308,7 +310,8 @@ func (m *Manager) registerFactory(ctx context.Context, id registry.ID, method st Data: &process.FactoryEntry{ Factory: factoryFn, Meta: process.Meta{ - Method: method, + Method: method, + Security: sec, }, }, }) diff --git a/runtime/lua/component/process/manager_test.go b/runtime/lua/component/process/manager_test.go index d1cab0a0f..00619e5b4 100644 --- a/runtime/lua/component/process/manager_test.go +++ b/runtime/lua/component/process/manager_test.go @@ -212,7 +212,7 @@ func TestManager_registerFactory_PreparesBeforeSend(t *testing.T) { } ctx := event.WithAwaitService(ctxapi.NewRootContext(), awaitSvc) - err := manager.registerFactory(ctx, registry.NewID("app.test", "process"), "main") + err := manager.registerFactory(ctx, registry.NewID("app.test", "process"), "main", nil) require.NoError(t, err) assert.False(t, sendBeforePrepare, "factory register was sent before await prepare") } diff --git a/service/host/host.go b/service/host/host.go index f8c977671..cc74171f4 100644 --- a/service/host/host.go +++ b/service/host/host.go @@ -18,6 +18,7 @@ import ( hostapi "github.com/wippyai/runtime/api/service/host" "github.com/wippyai/runtime/api/topology" "github.com/wippyai/runtime/system/scheduler/actor" + securitysys "github.com/wippyai/runtime/system/security" "go.uber.org/zap" ) @@ -98,6 +99,9 @@ func (h *Host) Run(ctx context.Context, start *process.Start) (pid.PID, error) { processID := h.preparePID(ctx, start) frameCtx := h.prepareContext(ctx, processID, start) + if meta != nil && meta.Security != nil { + frameCtx = securitysys.WithSecurityConfig(frameCtx, meta.Security) + } method := "main" if meta != nil && meta.Method != "" { diff --git a/service/host/host_test.go b/service/host/host_test.go index 71e0a98da..b2ebac186 100644 --- a/service/host/host_test.go +++ b/service/host/host_test.go @@ -19,6 +19,7 @@ import ( "github.com/wippyai/runtime/api/registry" "github.com/wippyai/runtime/api/relay" "github.com/wippyai/runtime/api/runtime" + secapi "github.com/wippyai/runtime/api/security" hostapi "github.com/wippyai/runtime/api/service/host" "github.com/wippyai/runtime/api/topology" "github.com/wippyai/runtime/internal/uniqid" @@ -748,6 +749,66 @@ func TestHost_ConcurrentSend(t *testing.T) { wg.Wait() } +// --- Entry Security --- + +type stubPolicy struct{ id registry.ID } + +func (p stubPolicy) ID() registry.ID { return p.id } +func (p stubPolicy) Evaluate(_ secapi.Actor, _, _ string, _ attrs.Bag) secapi.Result { + return secapi.Allow +} + +type stubPolicyRegistry struct{ policy secapi.Policy } + +func (r stubPolicyRegistry) GetPolicy(id registry.ID) (secapi.Policy, error) { + if r.policy != nil && r.policy.ID() == id { + return r.policy, nil + } + return nil, errors.New("policy not found") +} +func (r stubPolicyRegistry) GetPolicyGroup(registry.ID) (secapi.Scope, error) { + return nil, errors.New("group not found") +} +func (r stubPolicyRegistry) ListGroups() []registry.ID { return nil } +func (r stubPolicyRegistry) ListPolicies() []registry.ID { return nil } + +func TestHost_RunAppliesEntrySecurity(t *testing.T) { + policyID := registry.NewID("test", "allow_all") + + var gotActor secapi.Actor + var hasActor bool + var allowed bool + done := make(chan struct{}) + + th := newTestHost(func(th *testHost) { + th.factory.meta = &process.Meta{ + Security: &secapi.Config{ + Actor: secapi.Actor{ID: "test:entry-actor"}, + Policies: []registry.ID{policyID}, + }, + } + th.lifecycle.onStartFunc = func(ctx context.Context, _ pid.PID, _ process.Process) { + gotActor, hasActor = secapi.GetActor(ctx) + allowed = secapi.IsAllowed(ctx, "process.spawn", "test:child", nil) + close(done) + } + }) + ctx := secapi.WithRegistry(ctxWithAppContext(), stubPolicyRegistry{policy: stubPolicy{id: policyID}}) + _, err := th.host.Start(ctx) + require.NoError(t, err) + defer th.stop() + + _, err = th.host.Run(ctx, &process.Start{ + Source: registry.NewID("test", "proc"), + }) + require.NoError(t, err) + + <-done + require.True(t, hasActor, "entry security actor must be set on the process frame context") + assert.Equal(t, "test:entry-actor", gotActor.ID) + assert.True(t, allowed, "entry security policies must be evaluable on the process frame context") +} + // --- Interface Compliance --- var _ process.Host = (*Host)(nil) diff --git a/service/terminal/host.go b/service/terminal/host.go index 295739a4b..534ae26f8 100644 --- a/service/terminal/host.go +++ b/service/terminal/host.go @@ -23,6 +23,7 @@ import ( supervisorapi "github.com/wippyai/runtime/api/supervisor" "github.com/wippyai/runtime/system/logs" "github.com/wippyai/runtime/system/scheduler/actor" + securitysys "github.com/wippyai/runtime/system/security" "go.uber.org/zap" ) @@ -205,6 +206,9 @@ func (h *Host) Run(ctx context.Context, start *process.Start) (pid.PID, error) { } frameCtx := h.prepareContext(ctx, processID, start) + if meta != nil && meta.Security != nil { + frameCtx = securitysys.WithSecurityConfig(frameCtx, meta.Security) + } method := "main" if meta != nil && meta.Method != "" { From 1ba4e5cf848ac8e700bdd357d7ebf52f6fc26bf7 Mon Sep 17 00:00:00 2001 From: Wolfy-J Date: Thu, 6 Aug 2026 20:48:56 -0400 Subject: [PATCH 2/3] fix(process): satisfy runtime lint gate --- api/process/process.go | 2 +- api/runtime/lua/config.go | 2 +- runtime/lua/component/process/manager.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/process/process.go b/api/process/process.go index 3ebf8705a..a30f678fa 100644 --- a/api/process/process.go +++ b/api/process/process.go @@ -39,10 +39,10 @@ const ( type ( // Meta contains metadata about a process type. Meta struct { - Method string // Security carries the entry-declared security configuration; hosts // apply it to the process frame context at start. Security *security.Config + Method string } // Start contains the configuration needed to start a new process. diff --git a/api/runtime/lua/config.go b/api/runtime/lua/config.go index 109540f59..a056d1c90 100644 --- a/api/runtime/lua/config.go +++ b/api/runtime/lua/config.go @@ -79,7 +79,7 @@ type ( // ProcessConfig defines the configuration for a Lua processes. ProcessConfig struct { - Meta attrs.Bag `json:"meta"` // Metadata for the terminal + Meta attrs.Bag `json:"meta"` // Metadata for the terminal Security *security.Config `json:"security,omitempty" yaml:"security,omitempty"` Source string `json:"source" resolve:"-"` // Lua source code Method string `json:"method"` // Alias of the Lua method to execute diff --git a/runtime/lua/component/process/manager.go b/runtime/lua/component/process/manager.go index 360adeb00..3d21fa0d9 100644 --- a/runtime/lua/component/process/manager.go +++ b/runtime/lua/component/process/manager.go @@ -26,8 +26,8 @@ import ( type configEntry struct { source *api.ProcessConfig bytecode *api.BytecodeProcessConfig - method string security *security.Config + method string } // Manager handles both source and bytecode Lua process components. From 016d22911c05412b23670f5e08ae16be1ecb9327 Mon Sep 17 00:00:00 2001 From: Wolfy-J Date: Sat, 8 Aug 2026 18:22:33 -0400 Subject: [PATCH 3/3] test(process): define entry security precedence --- runtime/lua/component/process/manager_test.go | 8 ++- service/host/host_test.go | 33 +++++++--- service/terminal/host_test.go | 61 +++++++++++++++++++ 3 files changed, 91 insertions(+), 11 deletions(-) diff --git a/runtime/lua/component/process/manager_test.go b/runtime/lua/component/process/manager_test.go index 00619e5b4..ec3ed1560 100644 --- a/runtime/lua/component/process/manager_test.go +++ b/runtime/lua/component/process/manager_test.go @@ -17,6 +17,7 @@ import ( processapi "github.com/wippyai/runtime/api/process" "github.com/wippyai/runtime/api/registry" api "github.com/wippyai/runtime/api/runtime/lua" + "github.com/wippyai/runtime/api/security" "github.com/wippyai/runtime/runtime/lua/code" "github.com/wippyai/runtime/runtime/lua/engine" systempayload "github.com/wippyai/runtime/system/payload" @@ -212,7 +213,12 @@ func TestManager_registerFactory_PreparesBeforeSend(t *testing.T) { } ctx := event.WithAwaitService(ctxapi.NewRootContext(), awaitSvc) - err := manager.registerFactory(ctx, registry.NewID("app.test", "process"), "main", nil) + securityConfig := &security.Config{Actor: security.Actor{ID: "app.test:process"}} + err := manager.registerFactory(ctx, registry.NewID("app.test", "process"), "main", securityConfig) require.NoError(t, err) assert.False(t, sendBeforePrepare, "factory register was sent before await prepare") + require.Len(t, bus.events, 1) + registered, ok := bus.events[0].Data.(*processapi.FactoryEntry) + require.True(t, ok) + assert.Same(t, securityConfig, registered.Meta.Security) } diff --git a/service/host/host_test.go b/service/host/host_test.go index b2ebac186..4b11cad93 100644 --- a/service/host/host_test.go +++ b/service/host/host_test.go @@ -8,6 +8,7 @@ import ( "sync" "sync/atomic" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -24,6 +25,7 @@ import ( "github.com/wippyai/runtime/api/topology" "github.com/wippyai/runtime/internal/uniqid" "github.com/wippyai/runtime/system/scheduler/actor" + securitysys "github.com/wippyai/runtime/system/security" "go.uber.org/zap" ) @@ -773,40 +775,51 @@ func (r stubPolicyRegistry) ListGroups() []registry.ID { return nil } func (r stubPolicyRegistry) ListPolicies() []registry.ID { return nil } func TestHost_RunAppliesEntrySecurity(t *testing.T) { - policyID := registry.NewID("test", "allow_all") + entryPolicyID := registry.NewID("test", "entry_allow") + launchPolicy := stubPolicy{id: registry.NewID("test", "launch_allow")} var gotActor secapi.Actor - var hasActor bool - var allowed bool + var gotScope secapi.Scope done := make(chan struct{}) th := newTestHost(func(th *testHost) { th.factory.meta = &process.Meta{ Security: &secapi.Config{ Actor: secapi.Actor{ID: "test:entry-actor"}, - Policies: []registry.ID{policyID}, + Policies: []registry.ID{entryPolicyID}, }, } th.lifecycle.onStartFunc = func(ctx context.Context, _ pid.PID, _ process.Process) { - gotActor, hasActor = secapi.GetActor(ctx) - allowed = secapi.IsAllowed(ctx, "process.spawn", "test:child", nil) + gotActor, _ = secapi.GetActor(ctx) + gotScope, _ = secapi.GetScope(ctx) close(done) } }) - ctx := secapi.WithRegistry(ctxWithAppContext(), stubPolicyRegistry{policy: stubPolicy{id: policyID}}) + ctx := secapi.WithRegistry(ctxWithAppContext(), stubPolicyRegistry{ + policy: stubPolicy{id: entryPolicyID}, + }) _, err := th.host.Start(ctx) require.NoError(t, err) defer th.stop() _, err = th.host.Run(ctx, &process.Start{ Source: registry.NewID("test", "proc"), + Context: []ctxapi.Pair{ + secapi.ActorPair(secapi.Actor{ID: "test:launch-actor"}), + secapi.ScopePair(securitysys.NewScope([]secapi.Policy{launchPolicy})), + }, }) require.NoError(t, err) - <-done - require.True(t, hasActor, "entry security actor must be set on the process frame context") + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("process did not start") + } assert.Equal(t, "test:entry-actor", gotActor.ID) - assert.True(t, allowed, "entry security policies must be evaluable on the process frame context") + require.NotNil(t, gotScope) + assert.True(t, gotScope.Contains(entryPolicyID), "entry policy must be present") + assert.True(t, gotScope.Contains(launchPolicy.ID()), "launch policy must be preserved") } // --- Interface Compliance --- diff --git a/service/terminal/host_test.go b/service/terminal/host_test.go index 7bf5e57e0..248d8d5a4 100644 --- a/service/terminal/host_test.go +++ b/service/terminal/host_test.go @@ -21,6 +21,7 @@ import ( "github.com/wippyai/runtime/api/registry" "github.com/wippyai/runtime/api/relay" "github.com/wippyai/runtime/api/runtime" + secapi "github.com/wippyai/runtime/api/security" terminalapi "github.com/wippyai/runtime/api/service/terminal" "github.com/wippyai/runtime/internal/uniqid" "github.com/wippyai/runtime/system/logs" @@ -252,6 +253,66 @@ func TestHost_Run_ShuttingDown(t *testing.T) { _ = h.Stop(context.Background()) } +type securityCaptureProcess struct { + actor chan secapi.Actor +} + +func (p *securityCaptureProcess) Init(ctx context.Context, _ string, _ payload.Payloads) error { + actor, _ := secapi.GetActor(ctx) + p.actor <- actor + return nil +} + +func (*securityCaptureProcess) Step([]process.Event, *process.StepOutput) error { return nil } +func (*securityCaptureProcess) Close() {} + +type securityFactory struct { + proc process.Process + meta *process.Meta +} + +func (f securityFactory) Create(registry.ID) (process.Process, *process.Meta, error) { + return f.proc, f.meta, nil +} + +func TestHost_RunAppliesEntrySecurity(t *testing.T) { + actorCh := make(chan secapi.Actor, 1) + factory := securityFactory{ + proc: &securityCaptureProcess{actor: actorCh}, + meta: &process.Meta{Security: &secapi.Config{ + Actor: secapi.Actor{ID: "test:entry-actor"}, + }}, + } + scheduler := actor.NewScheduler(&mockCommandRegistry{}, actor.WithWorkers(1)) + h := NewHost( + registry.NewID("test", "host"), + &terminalapi.HostConfig{}, + scheduler, + factory, + logs.NewConfigurator(nil, zap.NewNop()), + zap.NewNop(), + ) + ctx := process.WithPIDGenerator(ctxapi.NewRootContext(), newTestPIDGen()) + _, err := h.Start(ctx) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, h.Stop(context.Background())) }) + + _, err = h.Run(ctx, &process.Start{ + Source: registry.NewID("test", "process"), + Context: []ctxapi.Pair{ + secapi.ActorPair(secapi.Actor{ID: "test:launch-actor"}), + }, + }) + require.NoError(t, err) + + select { + case got := <-actorCh: + assert.Equal(t, "test:entry-actor", got.ID) + case <-time.After(3 * time.Second): + t.Fatal("terminal process did not initialize") + } +} + func TestHost_Send_ShuttingDown(t *testing.T) { id := registry.ID{NS: "test", Name: "host1"} cfg := &terminalapi.HostConfig{}