tool, mavend: Hexis owns the tier of a Hexis capability (V-523)
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.
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+19
-12
@@ -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} не отвечает, попробуй ещё раз.",
|
||||
|
||||
@@ -60,6 +60,10 @@
|
||||
"fixed": true,
|
||||
"variants": ["тут нужны аргументы, из голоса не соберу. угадывать не буду."]
|
||||
},
|
||||
"act_needs_authed_surface": {
|
||||
"fixed": true,
|
||||
"variants": ["это из голоса не выполню — после него ничего не вернуть. запусти сам."]
|
||||
},
|
||||
"eco_denied": {
|
||||
"fixed": true,
|
||||
"variants": ["{name} отклоняет доступ, проверь токен."]
|
||||
|
||||
+50
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user