From 0ade0ec7348ab22be1d07db202318c812ca57452 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 18:01:28 +0400 Subject: [PATCH] tool, mavend: Hexis owns the tier of a Hexis capability (V-523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/mavend/actions_act.go | 2 +- cmd/mavend/ecosystem_acts.go | 20 ++++++++++- cmd/mavend/ecosystem_test.go | 59 ++++++++++++++++++++++++++++++++ internal/phraser/acts.go | 31 ++++++++++------- internal/phraser/acts_ru_v1.json | 4 +++ internal/tool/risk.go | 51 ++++++++++++++++++++++++++- internal/tool/risk_test.go | 30 ++++++++++++++++ 7 files changed, 182 insertions(+), 15 deletions(-) diff --git a/cmd/mavend/actions_act.go b/cmd/mavend/actions_act.go index 16211f3..b20cb21 100644 --- a/cmd/mavend/actions_act.go +++ b/cmd/mavend/actions_act.go @@ -57,7 +57,7 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st // help: everything that proposed this act — the STT, the router, // the fuzzy allowlist match — is a guess, and a spoken "да" checks // none of it. She names the gap instead. - return "это я из голоса не выполню — после него ничего не вернуть. запусти сам, если правда надо." + return phraser.A(phraser.ActNeedsAuthedSurface, nil) case errors.Is(err, tool.ErrNotEnabled): return h.proposeGap(ctx, dec) case errors.Is(err, tool.ErrNotConnected), errors.Is(err, mcp.ErrNotConnected), errors.Is(err, mcp.ErrNoServer): diff --git a/cmd/mavend/ecosystem_acts.go b/cmd/mavend/ecosystem_acts.go index 7ed0be7..9a0cdff 100644 --- a/cmd/mavend/ecosystem_acts.go +++ b/cmd/mavend/ecosystem_acts.go @@ -12,6 +12,7 @@ import ( "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" + "github.com/kami/maven/internal/tool" ) // The three services, spelled the way she says them out loud. A service that is @@ -625,7 +626,24 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio // Read-only capabilities run immediately; mutating ones are parked for an // explicit spoken confirm bound to this capability + target. - if !matched.ReadOnly { + // The tier decides, and Hexis owns the tier (Vikunja #523). read_only alone + // used to decide it here, 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 not settable by a caller — was + // read by nobody. docs/ecosystem.md §17.3 says confirmation follows risk. + tier := tool.RiskOfCapability(matched.Risk, matched.ReadOnly, matched.RequiresConfirmation) + policy := tool.PolicyFor(tier) + if !policy.VoiceMayRun { + // Irreversible. A confirm turn would not help, for the same reason it + // does not help a local row: the STT heard it, the model routed it and + // a substring matched the capability, and a spoken "да" checks none of + // those. She names the gap and he runs it himself. + h.recordEcosystemTrace(ctx, "hexis", "confirmation", traceRefused, started, + map[string]any{"entity_id": entityID, "capability": matched.Name, "risk": string(tier)}) + return phraser.A(phraser.ActNeedsAuthedSurface, nil) + } + if policy.Confirm { h.mu.Lock() h.pendingHexis = &pendingHexisExec{ capabilityID: matched.ID, diff --git a/cmd/mavend/ecosystem_test.go b/cmd/mavend/ecosystem_test.go index 9296e1f..6e7133c 100644 --- a/cmd/mavend/ecosystem_test.go +++ b/cmd/mavend/ecosystem_test.go @@ -253,3 +253,62 @@ func actRan(reply string) bool { // muzickIndexer — the display name every ecosystem fixture resolves to. const muzickIndexer = "Muzick indexer" + +// read_only used to be the whole decision on this path, which meant a +// capability that destroys what it names got the same single spoken "да" as one +// that restarts a service. Hexis declares the tier and the voice path is not an +// authorised surface for the top one (Vikunja #523). +func TestHexisIrreversibleCapabilityIsNotRunFromVoice(t *testing.T) { + ctx := context.Background() + resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}` + caps := `[{"id":"cap_wipe","name":"restart","read_only":false,"risk":"irreversible","requires_confirmation":true}]` + h, executed := newHexisTestHandler(t, resolved, caps) + + reply := h.handleHexisAct(ctx, actDec("muzick indexer")) + if *executed { + t.Fatal("an irreversible capability ran from the voice path") + } + if h.pendingHexis != nil { + t.Fatal("an irreversible capability parked a confirm; a spoken да is not enough authority") + } + if !strings.Contains(reply, "не вернуть") { + t.Errorf("reply = %q; want it to name why she will not run it", reply) + } +} + +// The other half: Hexis calling a capability safe is enough to run it, even +// though read_only is the field that used to decide. Nothing here re-derives. +func TestHexisSafeCapabilityRunsOnItsDeclaredTier(t *testing.T) { + ctx := context.Background() + resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}` + caps := `[{"id":"cap_status","name":"restart","read_only":true,"risk":"safe"}]` + h, executed := newHexisTestHandler(t, resolved, caps) + + reply := h.handleHexisAct(ctx, actDec("muzick indexer")) + if !*executed { + t.Fatal("a capability Hexis calls safe should run") + } + if !actRan(reply) { + t.Fatalf("unexpected reply %q", reply) + } +} + +// A mutating capability with no declared tier keeps the confirm turn it has +// always had, so the split does not quietly loosen an existing box. +func TestHexisUndeclaredTierStillConfirms(t *testing.T) { + ctx := context.Background() + resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}` + caps := `[{"id":"cap_restart","name":"restart","read_only":false}]` + h, executed := newHexisTestHandler(t, resolved, caps) + + reply := h.handleHexisAct(ctx, actDec("muzick indexer")) + if *executed { + t.Fatal("a mutating capability ran without a confirm") + } + if h.pendingHexis == nil { + t.Fatal("a mutating capability did not park a confirm") + } + if !strings.Contains(reply, "да или нет") { + t.Errorf("reply = %q; want the confirm question", reply) + } +} diff --git a/internal/phraser/acts.go b/internal/phraser/acts.go index a7a0be5..fd20b46 100644 --- a/internal/phraser/acts.go +++ b/internal/phraser/acts.go @@ -40,6 +40,11 @@ const ( ActServerDown = "act_server_down" ActWithdrawn = "act_withdrawn" ActNeedsArgs = "act_needs_args" + // ActNeedsAuthedSurface — the irreversible tier, local row or Hexis + // capability alike. Not a failure and not a refusal to help: a spoken "да" + // is the only authority the voice path can offer, and this is the one act + // it is not enough for (Vikunja #449, #523). + ActNeedsAuthedSurface = "act_needs_authed_surface" EcoDenied = "eco_denied" EcoDown = "eco_down" @@ -67,6 +72,7 @@ const ( var actKeys = []string{ ActDone, ActDoneOut, ActDoneEntity, ActConfirm, ActConfirmEntity, ActWhich, ActFail, ActFailOut, ActFailEntity, ActServerDown, ActWithdrawn, ActNeedsArgs, + ActNeedsAuthedSurface, EcoDenied, EcoDown, EcoAmbiguous, EcoUnknownEntity, EcoNoNexus, EcoAboutWhat, EcoRecall, AttentionNone, AttentionList, AttentionFail, AttentionNoneEntity, AttentionListEntity, AttentionFailEntity, @@ -79,18 +85,19 @@ var actKeys = []string{ // tracks the file's first variant instead, because a floor that keeps the // wording review threw out would say it back on the one turn nobody is watching. var actFloor = map[string]string{ - ActDone: "готово.", - ActDoneOut: "готово: {out}", - ActDoneEntity: "готово: {name}.", - ActConfirm: "выполнить «{name}»? да или нет.", - ActConfirmEntity: "выполнить «{name}» для {name_entity}? да или нет.", - ActWhich: "какую команду для {name}: {items}?", - ActFail: "не получилось выполнить команду.", - ActFailOut: "не получилось выполнить команду: {out}", - ActFailEntity: "не получилось выполнить команду для {name}.", - ActServerDown: "инструмент есть, но сервер не подключён.", - ActWithdrawn: "сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools.", - ActNeedsArgs: "тут нужны аргументы, из голоса не соберу. угадывать не буду.", + ActDone: "готово.", + ActDoneOut: "готово: {out}", + ActDoneEntity: "готово: {name}.", + ActConfirm: "выполнить «{name}»? да или нет.", + ActConfirmEntity: "выполнить «{name}» для {name_entity}? да или нет.", + ActWhich: "какую команду для {name}: {items}?", + ActFail: "не получилось выполнить команду.", + ActFailOut: "не получилось выполнить команду: {out}", + ActFailEntity: "не получилось выполнить команду для {name}.", + ActServerDown: "инструмент есть, но сервер не подключён.", + ActWithdrawn: "сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools.", + ActNeedsArgs: "тут нужны аргументы, из голоса не соберу. угадывать не буду.", + ActNeedsAuthedSurface: "это из голоса не выполню — после него ничего не вернуть. запусти сам.", EcoDenied: "{name} отклоняет доступ, проверь токен.", EcoDown: "{name} не отвечает, попробуй ещё раз.", diff --git a/internal/phraser/acts_ru_v1.json b/internal/phraser/acts_ru_v1.json index 00908f0..932b5e5 100644 --- a/internal/phraser/acts_ru_v1.json +++ b/internal/phraser/acts_ru_v1.json @@ -60,6 +60,10 @@ "fixed": true, "variants": ["тут нужны аргументы, из голоса не соберу. угадывать не буду."] }, + "act_needs_authed_surface": { + "fixed": true, + "variants": ["это из голоса не выполню — после него ничего не вернуть. запусти сам."] + }, "eco_denied": { "fixed": true, "variants": ["{name} отклоняет доступ, проверь токен."] diff --git a/internal/tool/risk.go b/internal/tool/risk.go index cb1ad38..edd723d 100644 --- a/internal/tool/risk.go +++ b/internal/tool/risk.go @@ -93,7 +93,56 @@ var irreversibleVerbs = map[string]bool{ "prune": true, "truncate": true, } -// RiskOf derives the tier of an enabled tool row. +// 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 diff --git a/internal/tool/risk_test.go b/internal/tool/risk_test.go index 4662e1f..47942b7 100644 --- a/internal/tool/risk_test.go +++ b/internal/tool/risk_test.go @@ -79,3 +79,33 @@ func TestExecConfirmsAnUnreadableRow(t *testing.T) { t.Errorf("%v; want ErrNeedsConfirm", err) } } + +// Hexis owns the tier of a Hexis capability, so this reads rather than derives +// (Vikunja #523). The cases that matter are the ones where the three fields +// disagree, or where the tier is a word this package has never seen: every one +// of those goes up to a confirm, never down to running freely. +func TestRiskOfCapabilityReadsHexis(t *testing.T) { + for _, c := range []struct { + name string + risk string + ro bool + confirm bool + want Risk + }{ + {"hexis says irreversible", "irreversible", false, true, TierIrreversible}, + {"case and space do not change the tier", " Irreversible ", false, true, TierIrreversible}, + {"hexis says destructive", "destructive", false, true, TierDestructive}, + {"a read hexis calls safe", "safe", true, false, TierSafe}, + {"safe but mutating is a contradiction", "safe", false, false, TierDestructive}, + {"safe but wants a confirm is a contradiction", "safe", true, true, TierDestructive}, + {"no tier, read-only, no confirm", "", true, false, TierSafe}, + {"no tier and mutating", "", false, false, TierDestructive}, + {"no tier but hexis wants a confirm", "", true, true, TierDestructive}, + {"a word we have never seen", "spicy", true, false, TierDestructive}, + } { + if got := RiskOfCapability(c.risk, c.ro, c.confirm); got != c.want { + t.Errorf("%s: RiskOfCapability(%q, ro=%v, confirm=%v) = %q; want %q", + c.name, c.risk, c.ro, c.confirm, got, c.want) + } + } +}