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 }