feat: Phase 4 governance/execution slice — PolicyService + ExecutionService

classifyAndGate's decision pipeline moves to core: PolicyService runs the
full gate order (classify + transport escalation, plan-first, syntax,
host-only/host-lxc, VM QGA preflight, dedup, approval-flood, window
routing) over ports.GovernanceStore; ExecutionService records and
dispatches (auto-run via ssh.CommandExecutor + TargetResolver, queue via
ExecutionRecorder) with one converged path for run/docker_exec. Gating
matrix test added (risk x window x declared risk -> outcome); pair
coverage 95.6%.

Bug fix surfaced by the matrix: the flag-space syntax regex was inverted
— it refused valid 'tail -n 3' and missed the actual 'head - n' typo.
Fixed to match dash-space-value only.

Remaining Phase 4 items tracked in the plan: ApprovalService.Decide
convergence, execlog fold, execworker poller. VERSION 0.35.0.
This commit is contained in:
2026-08-16 09:48:26 +02:00
parent 60c0432d8b
commit 7c9f4ec79f
19 changed files with 1748 additions and 725 deletions

View File

@@ -105,7 +105,11 @@ func newTestHandler(t *testing.T, cfg config.Config) http.Handler {
&portstest.FakeProvisioner{}, &portstest.FakeResolver{},
repo, db.NewRelRepo(pool))
seeds := app.NewSeedService(db.NewSeedRepo(pool))
return NewHandler(handlerCtx, pool, cfg, app.NewEntityService(repo, onto), repo, db.NewEntityReader(pool), app.NewRelationshipService(db.NewRelRepo(pool), onto), provisioning, seeds)
execSvc := app.NewExecutionService(
app.NewPolicyService(portstest.NewGovernanceStore()),
&portstest.RecordingExecutor{}, &portstest.FakeResolver{Addr: "127.0.0.1"},
portstest.NewExecutionRecorder())
return NewHandler(handlerCtx, pool, cfg, app.NewEntityService(repo, onto), repo, db.NewEntityReader(pool), app.NewRelationshipService(db.NewRelRepo(pool), onto), provisioning, seeds, execSvc)
}
// testAuthToken is the static bearer token devConfig() configures. There is

View File

@@ -74,6 +74,9 @@ type Server struct {
provisioning *app.ProvisioningService
// seeds regenerates seed YAMLs for the export endpoint (Phase 7).
seeds *app.SeedService
// execSvc is the run-submission use-case (Phase 4): PolicyService
// gating + auto-run/queue dispatch, shared by the MCP tools.
execSvc *app.ExecutionService
}
// NewHandler builds the full HTTP handler: /healthz (unauthenticated,
@@ -83,7 +86,7 @@ type Server struct {
// holds a dedicated pooled connection for LISTEN. Callers MUST cancel ctx
// before closing the pool — otherwise the held connection never releases
// and pool.Close() deadlocks.
func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, entities *app.EntityService, entityRepo *db.EntityRepo, readModels ports.ReadModels, relService *app.RelationshipService, provisioning *app.ProvisioningService, seeds *app.SeedService) http.Handler {
func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, entities *app.EntityService, entityRepo *db.EntityRepo, readModels ports.ReadModels, relService *app.RelationshipService, provisioning *app.ProvisioningService, seeds *app.SeedService, execSvc *app.ExecutionService) http.Handler {
s := &Server{
pool: pool,
cfg: cfg,
@@ -96,6 +99,7 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, entities
relService: relService,
provisioning: provisioning,
seeds: seeds,
execSvc: execSvc,
}
// Wire secrets backend: Infisical primary with SOPS DR fallback.
@@ -339,7 +343,7 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, entities
if nomosAgentID == uuid.Nil && cfg.NomosAgentSlug != "" {
_ = pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", cfg.NomosAgentSlug).Scan(&nomosAgentID)
}
r.With(combinedAuth(cfg, false)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID, s.secretsManager, s.entities, s.relService))
r.With(combinedAuth(cfg, false)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID, s.secretsManager, s.entities, s.relService, s.execSvc))
if nomosURL := os.Getenv("NOMOS_PROXY_URL"); nomosURL != "" {
target, _ := url.Parse(nomosURL)
@@ -944,10 +948,10 @@ main();
// ListenAndServe runs the API server with graceful shutdown on ctx cancel
// (SG4): stop accepting, drain in-flight for up to 30s, then exit.
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config, entities *app.EntityService, entityRepo *db.EntityRepo, readModels ports.ReadModels, relService *app.RelationshipService, provisioning *app.ProvisioningService, seeds *app.SeedService) error {
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config, entities *app.EntityService, entityRepo *db.EntityRepo, readModels ports.ReadModels, relService *app.RelationshipService, provisioning *app.ProvisioningService, seeds *app.SeedService, execSvc *app.ExecutionService) error {
srv := &http.Server{
Addr: cfg.APIListen,
Handler: NewHandler(ctx, pool, cfg, entities, entityRepo, readModels, relService, provisioning, seeds),
Handler: NewHandler(ctx, pool, cfg, entities, entityRepo, readModels, relService, provisioning, seeds, execSvc),
ReadHeaderTimeout: 10 * time.Second,
}