feat: Phase 3 — MCP header fallback, LIKE-based consent window, and auth token fix for dsh integration

- X-Oikos-Session-Id header → context fallback for _session_id
- sessionIDFromArgsOrContext() reads from args, then header
- AssentWindowActive falls back to session-ID-only LIKE lookup
- windowActiveLike() for LIKE-pattern autonomy_settings queries
- mcpBearerToken: package-level resolved token replaces os.Getenv() in decide_approval
- Bump 0.36.0 → 0.37.0
This commit is contained in:
2026-08-16 16:35:34 +02:00
parent b98d7c24bf
commit 04aa1bd5e8
5 changed files with 354 additions and 11 deletions

View File

@@ -42,12 +42,25 @@ func (g *GovernanceRepo) SessionHasPlan(ctx context.Context, sessionID string) b
}
// AssentWindowActive checks the session-scoped assent window key set by
// chat assent and the approval-decide path.
// chat assent and the approval-decide path. When no agent is available (dsh),
// falls back to a session-ID-only LIKE lookup.
func (g *GovernanceRepo) AssentWindowActive(ctx context.Context, agentID domain.UUID, sessionID string) bool {
if agentID == "" || sessionID == "" {
if sessionID == "" {
return false // fail closed
}
return g.windowActive(ctx, "assent_window.agent:"+string(agentID)+".session:"+sessionID)
// Exact key lookup (nomos path)
if agentID != "" {
key := "assent_window.agent:" + string(agentID) + ".session:" + sessionID
if g.windowActive(ctx, key) {
return true
}
}
// Session-ID-only LIKE fallback (dsh path — no agent entity UUID).
// Any window with a matching `.session:<id>` suffix is active.
if g.windowActiveLike(ctx, "%.session:"+sessionID) {
return true
}
return false
}
// DestructiveWindowActive checks the target+session-scoped destructive
@@ -75,6 +88,22 @@ func (g *GovernanceRepo) windowActive(ctx context.Context, key string) bool {
return time.Now().UTC().Before(expires)
}
// windowActiveLike checks a LIKE pattern against autonomy_settings keys.
// Used by AssentWindowActive for session-ID-only fallback lookups (dsh path).
func (g *GovernanceRepo) windowActiveLike(ctx context.Context, pattern string) bool {
var expiresStr string
err := g.pool.QueryRow(ctx,
"SELECT value FROM autonomy_settings WHERE key LIKE $1", pattern).Scan(&expiresStr)
if err != nil {
return false
}
expires, err := time.Parse(time.RFC3339, expiresStr)
if err != nil {
return false
}
return time.Now().UTC().Before(expires)
}
// PendingApprovalCount returns the session's executions at pending_approval.
func (g *GovernanceRepo) PendingApprovalCount(ctx context.Context, sessionID string) int {
var n int

View File

@@ -40,7 +40,7 @@ func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, execSvc *app.
command, _ := args["command"].(string)
purpose, _ := args["purpose"].(string)
declaredRisk, _ := args["declared_risk"].(string)
sessionID, _ := args["_session_id"].(string)
sessionID := sessionIDFromArgsOrContext(ctx, args)
if targetSlug == "" || command == "" {
return textResult("error: target and command are required"), nil
}
@@ -71,7 +71,7 @@ func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, execSvc *app.
command, _ := args["command"].(string)
purpose, _ := args["purpose"].(string)
declaredRisk, _ := args["declared_risk"].(string)
sessionID, _ := args["_session_id"].(string)
sessionID := sessionIDFromArgsOrContext(ctx, args)
if lxcSlug == "" || container == "" || command == "" {
return textResult("error: lxc_slug, container, and command are required"), nil
@@ -662,7 +662,7 @@ func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, execSvc *app.
if targetSlug == "" || service == "" {
return textResult("error: target and service are required"), nil
}
sessionID, _ := args["_session_id"].(string)
sessionID := sessionIDFromArgsOrContext(ctx, args)
var targetID uuid.UUID
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&targetID); err != nil {
return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil
@@ -717,7 +717,7 @@ func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, execSvc *app.
if backup {
cmd = fmt.Sprintf("pct exec %s -- cp -n %s %s.bak 2>/dev/null || true; %s", pveID, destPath, destPath, cmd)
}
sessionID, _ := args["_session_id"].(string)
sessionID := sessionIDFromArgsOrContext(ctx, args)
var hostEntityID uuid.UUID
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", "host:"+hostSlug).Scan(&hostEntityID); err != nil {
// hostSlug may already carry the host: prefix
@@ -800,8 +800,8 @@ func OpsTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, execSvc *app.
return textResult(fmt.Sprintf("error: %v", hreqErr)), nil
}
hreq.Header.Set("Content-Type", "application/json")
if token := os.Getenv("OIKOS_MCP_BEARER_TOKEN"); token != "" {
hreq.Header.Set("Authorization", "Bearer "+token)
if mcpBearerToken != "" {
hreq.Header.Set("Authorization", "Bearer "+mcpBearerToken)
}
resp, reqErr := client.Do(hreq)
if reqErr != nil {

View File

@@ -53,6 +53,7 @@ func objSchema(props ...prop) *jsonschema.Schema {
// NewHandler creates an http.Handler that serves the Oikos MCP server.
// agentID is the Nomos agent entity UUID; tool calls are logged to agent_activity.
func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService, relService *app.RelationshipService, execSvc *app.ExecutionService) http.Handler {
mcpBearerToken = token
s := newServer(pool, agentID, sec, entities, relService, execSvc)
handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
if token != "" {
@@ -62,7 +63,35 @@ func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec ports.Secret
}
return s
}, nil)
return handler
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extract X-Oikos-Session-Id from headers and store in context.
if sid := r.Header.Get("X-Oikos-Session-Id"); sid != "" {
r = r.WithContext(context.WithValue(r.Context(), ctxSessionIDKey{}, sid))
}
handler.ServeHTTP(w, r)
})
}
// ctxSessionIDKey is a Go context key for X-Oikos-Session-Id header value.
type ctxSessionIDKey struct{}
// mcpBearerToken is the resolved MCP bearer token, set at startup from the
// config (after Infisical overlay). Used by decide_approval and similar
// handlers that call back into the oikos HTTP API.
var mcpBearerToken string
// sessionIDFromArgsOrContext returns _session_id from tool call args,
// falling back to the X-Oikos-Session-Id header injected into the request
// context. This lets dsh agents send the session ID as a header without
// injecting it into every tool call's arguments.
func sessionIDFromArgsOrContext(ctx context.Context, args map[string]any) string {
if sid, _ := args["_session_id"].(string); sid != "" {
return sid
}
if sid, ok := ctx.Value(ctxSessionIDKey{}).(string); ok {
return sid
}
return ""
}
// toolHandler is the function signature registered via AddTool.