0987dabfc4
The Destructive column was a mechanism with no policy behind it: nothing said which acts are destructive, whether a confirmed act stays confirmed, or what a new tool domain inherits, so each domain answered for itself. Three tiers, derived from the row rather than stored, so the answer can be argued with in one place instead of being whatever the last person to tick the checkbox believed. Safe runs. Destructive costs a confirm turn, every time — a confirmation binds one capability, one target and one argument list, and it dies with the parked turn. Irreversible is refused: a confirm turn there would be theatre, because the STT, the router and the fuzzy allowlist match are all guesses and a spoken "да" checks none of them. She names the gap; the row stays enabled. An unrecognised dispatch shape inherits destructive, not safe. A domain argues its way down to running freely, never up to being gated.
146 lines
5.7 KiB
Go
146 lines
5.7 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,
|
|
}
|
|
|
|
// RiskOf derives the tier of an enabled tool row.
|
|
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
|
|
}
|