feat: general gated run primitive + chat-assent approval (Layer 0)
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:
@@ -35,6 +35,7 @@
|
||||
exec.set(id, e)
|
||||
if (e.status === 'completed') { phase.set(id, 'completed'); return }
|
||||
if (e.status === 'failed' || e.status === 'cancelled') { phase.set(id, 'failed'); return }
|
||||
if (e.status === 'denied' || e.status === 'revoked') { phase.set(id, 'denied'); return }
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2500))
|
||||
}
|
||||
@@ -53,6 +54,42 @@
|
||||
phase.set(id, 'running')
|
||||
void track(id)
|
||||
}
|
||||
|
||||
// Self-heal: a pending approval can be decided somewhere other than this
|
||||
// button — chat assent ("go ahead" in the next message), the Ops page, or
|
||||
// Matrix. Without this, the banner would sit showing Approve/Deny forever
|
||||
// while the action was already running or done behind the scenes. Poll
|
||||
// every card that's still showing buttons; the moment its execution leaves
|
||||
// pending_approval, adopt that outcome exactly as if the button had been
|
||||
// clicked. Stops immediately if the operator clicks the button first
|
||||
// (phase becomes non-empty, ending this loop's reason to exist).
|
||||
const watching = new Set<string>()
|
||||
async function watchExternal(id: string) {
|
||||
if (watching.has(id)) return
|
||||
watching.add(id)
|
||||
for (let i = 0; i < 200; i++) { // ~10min ceiling at 3s
|
||||
if (phase.get(id)) return // resolved locally (button click) or already picked up
|
||||
const e = await getExecution(id)
|
||||
if (e && e.status !== 'pending_approval') {
|
||||
exec.set(id, e)
|
||||
if (e.status === 'completed') { phase.set(id, 'completed'); return }
|
||||
if (e.status === 'failed' || e.status === 'cancelled') { phase.set(id, 'failed'); return }
|
||||
if (e.status === 'denied' || e.status === 'revoked') { phase.set(id, 'denied'); return }
|
||||
// 'approved' or 'running': someone said yes elsewhere — switch to
|
||||
// the same tracking the button click would have started.
|
||||
phase.set(id, 'running')
|
||||
void track(id)
|
||||
return
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 3000))
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
for (const a of approvals) {
|
||||
if (!phase.get(a.executionId)) void watchExternal(a.executionId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#each approvals as approval (approval.executionId)}
|
||||
|
||||
Reference in New Issue
Block a user