0ade0ec734
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.
195 lines
7.9 KiB
Go
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
|
|
}
|