feat: general gated run primitive + chat-assent approval (Layer 0)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

Implements the first slice of plans/2026-07-10-general-gated-execution.md:
Nomos gets one general execution tool instead of only a fixed action enum,
gated by an automatic risk classifier, and approval can be granted by the
operator just replying in chat instead of clicking a button.

- internal/policy/command.go: ClassifyCommand(cmd, declaredRisk) — rule-based
  read-only allowlist + destructive denylist, default-escalate to
  config_mutation for anything else. Classification can only ESCALATE the
  caller's declared risk, never de-escalate it (destructive always wins even
  if declared read_only). Compound commands (&&, ;, |, $()) never qualify for
  the read-only fast path. Full test corpus.
- internal/mcp/server.go: new `run` MCP tool — target (host:/lxc:), command,
  purpose, optional declared_risk. Read-only commands execute immediately;
  everything else queues an approval exactly like pct_create today, executed
  via httpapi's existing executeApprovedAction. Also fixes a real latent bug:
  pct_exec resolved an LXC's host attribute without the "host:" prefix, so it
  could never find the Proxmox host — new resolveExecTarget/resolveRunTarget
  helpers (mcp + httpapi) fix this for both the new `run` action and existing
  actions that route through the same execution path.
- internal/httpapi/phase3.go: "run" case in executeApprovedAction; fixes two
  bugs found while wiring this up — (1) DecideApproval hardcoded risk_class to
  'config_mutation' on every approve, silently corrupting the audit ledger for
  every other risk class; (2) denying/revoking an approval never updated the
  linked execution's status, so it stayed 'pending_approval' forever instead
  of reflecting the decision.
- cmd/nomos/assent.go: deterministic (not LLM-judged) chat-assent detection.
  Scoped to the immediately-preceding assistant turn's pending approvals only
  — an old "yes" can't retroactively approve something new. Destructive-risk
  actions are excluded from loose assent. Approves via the same HTTP decision
  endpoint the UI button calls, so both paths share one audit trail.
- web/.../InlineApproval.svelte: self-healing poll — a pending approval card
  now picks up being decided via ANY path (chat assent, Ops page, Matrix), not
  just its own button. Previously the banner stayed stuck showing
  Approve/Deny even after the action had already run elsewhere.
- nomos/SOUL.md: `run` is now the general capability ("no fixed menu, only a
  risk gate"); documents chat-assent behavior and the destructive exception.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 09:28:00 +02:00
parent 9daf8220f2
commit d52968876a
9 changed files with 778 additions and 25 deletions

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"strings"
"time"
@@ -30,13 +31,15 @@ var refusalDenylist = []string{
}
type agent struct {
client *mcpClient
system string
provider *openai.Client
model string
store *store
agentID uuid.UUID
reqOpts []option.RequestOption
client *mcpClient
system string
provider *openai.Client
model string
store *store
agentID uuid.UUID
reqOpts []option.RequestOption
apiBase string // oikos HTTP API base, derived from NOMOS_MCP_URL, for chat-assent approvals
httpClient *http.Client
}
func newAgent(ctx context.Context, mcpClient *mcpClient, st *store, agentSlug string) (*agent, error) {
@@ -76,14 +79,26 @@ func newAgent(ctx context.Context, mcpClient *mcpClient, st *store, agentSlug st
}
reqOpts := []option.RequestOption{option.WithJSONSet("provider", providerRouting)}
// Derive the oikos HTTP API base from the MCP URL (e.g.
// "http://api:8090/mcp?session_id=..." -> "http://api:8090"). Used for
// chat-assent approvals, which call the same decision endpoint the UI's
// Approve button calls.
mcpURL := os.Getenv("NOMOS_MCP_URL")
apiBase := ""
if idx := strings.Index(mcpURL, "/mcp"); idx > 0 {
apiBase = mcpURL[:idx]
}
return &agent{
client: mcpClient,
system: system,
provider: &provider,
model: model,
store: st,
agentID: agentID,
reqOpts: reqOpts,
client: mcpClient,
system: system,
provider: &provider,
model: model,
store: st,
agentID: agentID,
reqOpts: reqOpts,
apiBase: apiBase,
httpClient: &http.Client{Timeout: 15 * time.Second},
}, nil
}
@@ -127,6 +142,7 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
}
messages := []openai.ChatCompletionMessageParamUnion{openai.SystemMessage(system)}
history, _ := a.store.getMessages(ctx, sessionID)
var lastAssistantCalls []persistedCall
for _, m := range history {
text := extractText(m.Content)
switch m.Role {
@@ -138,6 +154,7 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
for _, c := range calls {
messages = append(messages, openai.ToolMessage(c.resultText(), c.id))
}
lastAssistantCalls = calls
}
if text != "" {
messages = append(messages, openai.AssistantMessage(text))
@@ -148,6 +165,41 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
messages = append(messages, openai.UserMessage(message))
}
// Chat-assent approval: if the immediately-preceding assistant turn
// proposed gated action(s) and the operator's new message reads as
// authorization ("go ahead", "yes", ...), grant them now — this is the
// primary approval path; the Approve button in the UI is a fallback for
// when the operator wants to click instead of type. Destructive-risk
// actions are never granted by loose assent.
if pending := extractPendingApprovals(lastAssistantCalls); len(pending) > 0 && isAssent(message) {
var granted, blocked []string
for _, p := range pending {
if p.destructive {
blocked = append(blocked, p.execID)
continue
}
ok, status, aerr := a.approveExecution(ctx, p.execID)
if aerr != nil {
slog.Error("nomos: chat-assent approve", "execution", p.execID, "error", aerr)
continue
}
if ok {
granted = append(granted, p.execID)
slog.Info("nomos: chat-assent granted", "execution", p.execID, "status", status, "session", sessionID)
emit(agentEvent{Type: "tool_use", Data: map[string]any{"name": "chat_assent", "args": map[string]any{"execution_id": p.execID}, "id": "assent-" + p.execID}, SessionID: sessionID})
emit(agentEvent{Type: "tool_result", Data: map[string]any{"name": "chat_assent", "result": fmt.Sprintf("Approved via chat assent (%q). Status: %s.", message, status), "id": "assent-" + p.execID}, SessionID: sessionID})
}
}
if len(granted) > 0 {
note := fmt.Sprintf("[System: the operator's message approved pending execution(s) %s via chat assent — they are now running. Do not call request_execution/run again for these; check get_execution_status if you need the outcome before replying.]", strings.Join(granted, ", "))
messages = append(messages, openai.SystemMessage(note))
}
if len(blocked) > 0 {
note := fmt.Sprintf("[System: execution(s) %s are classified DESTRUCTIVE and were NOT approved by loose assent — you must ask the operator for an explicit typed confirmation before they can run.]", strings.Join(blocked, ", "))
messages = append(messages, openai.SystemMessage(note))
}
}
for i := 0; i < maxIterations; i++ {
params := openai.ChatCompletionNewParams{
Model: openai.ChatModel(a.model),