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 }