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:
2026-08-04 18:01:28 +04:00
parent d960e211d3
commit 0ade0ec734
7 changed files with 182 additions and 15 deletions
+19 -12
View File
@@ -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} не отвечает, попробуй ещё раз.",
+4
View File
@@ -60,6 +60,10 @@
"fixed": true,
"variants": ["тут нужны аргументы, из голоса не соберу. угадывать не буду."]
},
"act_needs_authed_surface": {
"fixed": true,
"variants": ["это из голоса не выполню — после него ничего не вернуть. запусти сам."]
},
"eco_denied": {
"fixed": true,
"variants": ["{name} отклоняет доступ, проверь токен."]
+50 -1
View File
@@ -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
+30
View File
@@ -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)
}
}
}