Files
claude 0ade0ec734 tool, mavend: Hexis owns the tier of a Hexis capability (V-523)
read_only was the whole decision on the Hexis act path, which flattened three
answers into two. A capability that wipes the thing it names got the same
single spoken "да" as one that restarts a service, and requires_confirmation —
which the Hexis contract calls server-derived and never settable by a caller —
was read by nobody. docs/ecosystem.md §17.3 says confirmation follows risk.

RiskOfCapability reads Hexis's risk, read_only and requires_confirmation and
returns one of the three tiers internal/tool already had. It takes plain values
rather than a Capability, so internal/tool keeps no dependency on the Hexis
client. RiskOf keeps deriving, because a shell row the owner ticked on /tools
has no upstream to ask.

Every disagreement between the three fields goes up, never down: safe and
mutating is a contradiction and takes the confirm, an unrecognised tier takes
the confirm, and requires_confirmation may only raise. Same default as an
unrecognised dispatch shape — argue your way down, never up.

The irreversible refusal was a Go literal in two places and is now one deck
entry, act_needs_authed_surface. It lost four words to the persona ceiling.
2026-08-04 18:01:28 +04:00

195 lines
7.9 KiB
Go

package tool
import (
"strings"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/mcp"
"github.com/kami/maven/internal/smarthome"
)
// Risk tiers (Vikunja #449).
//
// What existed before this file was a mechanism and no policy: one
// `Destructive` boolean per row, set by whoever ticked the checkbox on /tools.
// Nothing said which acts are destructive, whether a confirmed act stays
// confirmed, or what a new tool domain inherits — so every domain answered
// those questions for itself, and two of them answered differently.
//
// The tiers below are the policy. They are derived from the row, not stored:
// a derivation can be argued with and corrected in one place, while a column
// is whatever the last person to enable the tool believed.
//
// The three questions, answered once:
//
// - WHICH ACTS ARE DESTRUCTIVE. A house row always is, because there is no
// read-only way to turn the heating off. A row whose argv names one of the
// irreversible verbs always is, whatever the checkbox says. Everything else
// is what the row was enabled as.
// - DOES A CONFIRMED ACT STAY CONFIRMED. No. Never, at any tier. A
// confirmation binds one capability, one target and one argument list, and
// it expires with the parked turn (confirmTTL, 90s). "Same act again" is a
// new act and costs a new turn. A sticky confirm is a standing grant, and
// nothing on the voice path may hold one.
// - WHAT A NEW DOMAIN INHERITS. The default is TierDestructive, not
// TierSafe. A dispatch shape this file does not recognise gets the confirm
// turn — a new domain must argue its way DOWN to running freely, never up
// to needing a confirm.
type Risk string
const (
// TierSafe — a read, or a mutation the owner can undo by saying the
// opposite. Runs on first hearing.
TierSafe Risk = "safe"
// TierDestructive — it changes something real and undoing it takes work.
// One confirm turn, every time, never remembered.
TierDestructive Risk = "destructive"
// TierIrreversible — the thing it acts on does not come back: a wipe, a
// format, a delete with no bin behind it. A confirm turn is not enough,
// because the whole chain that proposed it — an STT guess, a router guess,
// a fuzzy allowlist match — has a spoken "да" as its only check. She names
// the gap and he runs it himself.
TierIrreversible Risk = "irreversible"
)
// Policy — what a tier requires of the act path.
//
// There is deliberately no "sticky for" field. Non-stickiness is the policy,
// and a knob that could turn it off would be the thing to argue with instead
// of the rule.
type Policy struct {
// Confirm — the act does not run on first hearing.
Confirm bool
// VoiceMayRun — a spoken confirmation is enough authority to run it.
VoiceMayRun bool
}
// PolicyFor returns the requirements of a tier. An unknown tier is treated as
// destructive, for the same reason the default derivation is.
func PolicyFor(r Risk) Policy {
switch r {
case TierSafe:
return Policy{Confirm: false, VoiceMayRun: true}
case TierIrreversible:
return Policy{Confirm: true, VoiceMayRun: false}
default:
return Policy{Confirm: true, VoiceMayRun: true}
}
}
// irreversibleVerbs — argv heads and subcommands that destroy the thing they
// name. Matched as whole argv elements, never as substrings: "rm" must not
// fire on "/usr/bin/rmdir-report" and "drop" must not fire on "dropbox".
//
// The list is short on purpose. It is not a sandbox and it does not try to be
// one — an enabled row can already run anything the daemon's user can run.
// What it is, is the set of words that mean "and then it is gone", so that the
// one act nobody can walk back is the one act a spoken "да" cannot authorise.
var irreversibleVerbs = map[string]bool{
"rm": true, "rmdir": true, "shred": true, "srm": true,
"mkfs": true, "fdisk": true, "parted": true, "wipefs": true,
"dd": true, "format": true,
"drop": true, "drop-database": true, "destroy": true, "purge": true,
"prune": true, "truncate": true,
}
// RiskOfCapability — the tier of a Hexis capability, which Hexis decides.
//
// Everything below this comment in RiskOf is a derivation, and a derivation is
// only honest where nobody else holds the answer. Hexis does hold it: the
// capability carries risk, read_only and requires_confirmation, and its own
// contract says requires_confirmation is server-derived from the tier and never
// settable by a caller. Deriving a second opinion here is the same defect as
// inventing a local fact key for something Nexus resolves — two answers, one of
// them stale, and the wrong one authorising an act (Vikunja #523).
//
// So this reads rather than decides. The three arguments are Capability.Risk,
// Capability.ReadOnly and Capability.RequiresConfirmation, passed as plain
// values so internal/tool keeps no dependency on the Hexis client.
//
// The one judgement left is what to do with an answer we cannot read. It goes
// up, never down: an unrecognised tier gets the confirm turn, the same default
// a dispatch shape RiskOf does not know gets. And requires_confirmation may
// only raise — a capability that calls itself safe and then asks for a confirm
// is telling us two things, and the cautious one wins.
func RiskOfCapability(risk string, readOnly, requiresConfirmation bool) Risk {
switch Risk(strings.ToLower(strings.TrimSpace(risk))) {
case TierIrreversible:
return TierIrreversible
case TierDestructive:
return TierDestructive
case TierSafe:
// Safe and mutating is a contradiction, and so is safe with a confirm
// required. Either way the act changes something.
if requiresConfirmation || !readOnly {
return TierDestructive
}
return TierSafe
case "":
// No tier declared. Fall back to the shape Hexis did give us: a
// read-only capability that wants no confirm is a read, and anything
// else takes the confirm turn.
if readOnly && !requiresConfirmation {
return TierSafe
}
return TierDestructive
default:
// A word this file has never seen. It is not safe by default.
return TierDestructive
}
}
// RiskOf derives the tier of a locally enabled tool row — a shell command, an
// MCP call or a house service. Nothing here is a Hexis capability, and nothing
// upstream has an opinion about a row the owner ticked on /tools, which is why
// this one derives and RiskOfCapability reads.
func RiskOf(t ipc.Tool) Risk {
if isIrreversible(t.Cmd) {
return TierIrreversible
}
// A house row is a physical change to the flat, and the confirm turn on it
// is structural rather than a column: /tools writes the checkbox straight
// through on enable, so unticking it once turned an unlock into a row that
// ran on first hearing. Nothing any surface writes removes the second turn
// from a physical device.
if _, _, ok := smarthome.ParseCmd(t.Cmd); ok {
return TierDestructive
}
// An MCP row is a call to somebody else's server. It is enabled with a
// fingerprint of what it declared at approval time (Vikunja #251), and the
// tier tracks the same flag every other row uses — the point of this branch
// is that it is NOT special-cased into running freely.
if _, _, ok := mcp.ParseCmd(t.Cmd); ok {
if t.Destructive {
return TierDestructive
}
return TierSafe
}
if t.Destructive {
return TierDestructive
}
if len(t.Cmd) == 0 {
// Not a shape this file knows how to read. The default is the confirm
// turn: a new domain argues its way down, not up.
return TierDestructive
}
return TierSafe
}
// isIrreversible reports whether any argv element is one of the verbs that
// destroys what it names. Every element, not just the head: "sudo rm" and
// "docker volume prune" both hide the verb behind a wrapper.
func isIrreversible(cmd []string) bool {
for _, arg := range cmd {
word := strings.ToLower(strings.TrimSpace(arg))
// Take the last path element, so /bin/rm reads as rm.
if i := strings.LastIndex(word, "/"); i >= 0 {
word = word[i+1:]
}
if irreversibleVerbs[word] {
return true
}
}
return false
}