scratch matrix approval notifier, add chat-native MCP approval tools
Some checks failed
Some checks failed
Removes the entire Matrix-based notifier (internal/notifier/) that polled
for pending approvals, sent Matrix alerts, and checked for reaction-based
approve/deny. Approval decisions now work on any chat platform (Hermes
desktop, Telegram, Discord, WhatsApp, CLI) via two new MCP tools:
- list_approvals — query pending/recent approvals by status or entity
- decide_approval — approve/deny via same API endpoint as UI + nomos
Config fields removed: MatrixHomeserver, MatrixUserID, MatrixToken,
MatrixRoomID, ApprovalHMACSecret. Docker notifier: service removed.
Approval HMAC token generation removed (unused by code).
The existing chat-assent path in nomos (cmd/nomos/assent.go) and the
control-room Approve button keep working unchanged — both call the
shared POST /api/v1/approvals/{id}/decision endpoint.
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -724,5 +726,96 @@ func OpsTools(pool *db.Pool, agentID uuid.UUID, sec secretBackend) []toolReg {
|
||||
purpose := fmt.Sprintf("push %s into %s at %s", sourcePath, targetSlug, destPath)
|
||||
return classifyAndGate(ctx, pool, agentID, hostEntityID, "host:"+hostSlug, cmd, purpose, "config_mutation", sessionID), nil
|
||||
}},
|
||||
// ── Approval Management (replaces Matrix notifier) ─────────────
|
||||
{tool: &mcp.Tool{Name: "list_approvals", Description: "List pending and recent approvals. Returns approval ID, action, risk class, target slug, status, and timing. Filter by status (pending, approved, denied) or entity slug to scope. Use after a `run` returns 'requires approval' to see what's pending so you can present it to the operator for a decision.",
|
||||
InputSchema: objSchema(
|
||||
prop{"status", "string", "Optional: filter by status (pending, approved, denied, expired, revoked)"},
|
||||
prop{"entity_slug", "string", "Optional: filter by target entity slug"},
|
||||
prop{"limit", "integer", "Max rows (default 20)"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
status, _ := args["status"].(string)
|
||||
entSlug, _ := args["entity_slug"].(string)
|
||||
lim := int(getFloat(args, "limit", 20))
|
||||
if lim < 1 {
|
||||
lim = 1
|
||||
}
|
||||
if lim > 100 {
|
||||
lim = 100
|
||||
}
|
||||
var statusPtr, slugPtr *string
|
||||
if status != "" {
|
||||
statusPtr = &status
|
||||
}
|
||||
if entSlug != "" {
|
||||
slugPtr = &entSlug
|
||||
}
|
||||
return queryRows(ctx, pool, `
|
||||
SELECT a.entity_id, e.slug AS target_slug, a.action, a.risk_class,
|
||||
a.kind, a.status, a.expires_at, a.decided_at, a.created_at
|
||||
FROM approvals a
|
||||
JOIN entities e ON e.id = COALESCE(a.subject_entity_id, a.entity_id)
|
||||
WHERE ($1::text IS NULL OR a.status = $1)
|
||||
AND ($2::text IS NULL OR e.slug = $2)
|
||||
ORDER BY a.created_at DESC LIMIT $3`, statusPtr, slugPtr, lim), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "decide_approval", Description: "Approve or deny a pending execution approval. Call this after presenting the command details to the operator and getting their explicit authorization (\"go ahead\", \"yes\", \"proceed\", or for destructive actions \"I confirm ...\"). The operator's typed approval in any chat (Hermes desktop, Telegram, Discord, WhatsApp) works — this tool is the agent-side action to record the decision and trigger the execution. Returns the new approval status and execution result once done.",
|
||||
InputSchema: objSchema(
|
||||
prop{"approval_id", "string", "Approval entity UUID from list_approvals or a prior run result (e.g. 'execution X queued')"},
|
||||
prop{"decision", "string", "Decision: 'approve' to authorize the action, 'deny' to reject it. Destructive actions still need explicit typed confirmation from the operator."}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
appID, _ := args["approval_id"].(string)
|
||||
decision, _ := args["decision"].(string)
|
||||
if appID == "" || decision == "" {
|
||||
return textResult("error: approval_id and decision are required"), nil
|
||||
}
|
||||
if decision != "approve" && decision != "deny" {
|
||||
return textResult("error: decision must be 'approve' or 'deny'"), nil
|
||||
}
|
||||
|
||||
// Validate the approval exists and is still pending
|
||||
var status string
|
||||
if err := pool.QueryRow(ctx, `SELECT status FROM approvals WHERE entity_id = $1`, appID).Scan(&status); err != nil {
|
||||
return textResult(fmt.Sprintf("approval not found: %s", appID)), nil
|
||||
}
|
||||
if status != "pending" {
|
||||
return textResult(fmt.Sprintf("approval %s is already %s — cannot decide again", appID, status)), nil
|
||||
}
|
||||
|
||||
// Call the HTTP API decision endpoint (same path as the UI Approve button
|
||||
// and nomos chat-assent), so all approval paths share one code path for
|
||||
// execution dispatch, session management, events, and audit trail.
|
||||
apiBase := os.Getenv("OIKOS_API_BASE")
|
||||
if apiBase == "" {
|
||||
apiBase = "http://api:8090"
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"decision": decision})
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
hreq, hreqErr := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
apiBase+"/api/v1/approvals/"+appID+"/decision", bytes.NewReader(body))
|
||||
if hreqErr != nil {
|
||||
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)
|
||||
}
|
||||
resp, reqErr := client.Do(hreq)
|
||||
if reqErr != nil {
|
||||
return textResult(fmt.Sprintf("error: API call failed: %v", reqErr)), nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err == nil && len(result) > 0 {
|
||||
b, _ := json.MarshalIndent(result, "", " ")
|
||||
return textResult(fmt.Sprintf("%s: approval %s -> %s\nResponse: %s", decision, appID, decision, string(b))), nil
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return textResult(fmt.Sprintf("%s: approval %s decided successfully.", decision, appID)), nil
|
||||
}
|
||||
return textResult(fmt.Sprintf("%s: API returned status %d for approval %s", decision, resp.StatusCode, appID)), nil
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user