Make each sensitive vault verb judge its own caller

The controller judged the operator's account for approving or deleting a
retirement, rotating a secret and exporting sealed copies; it is not
placed, and these verbs are reached through the mesh MCP server on each
machine. Each now answers the operator's account (person.<name>), or a
caller naming its own recorded hand act for that verb and subject, and
nothing else (ADR 0590 §8, issue 1126).

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
2026-11-02 12:29:56 +01:00
parent f723d4372f
commit f83e6e4713
7 changed files with 208 additions and 24 deletions
@@ -34,10 +34,10 @@ func TestTheRetirementToolsPassTheActThrough(t *testing.T) {
sort.Strings(set)
defs := definitions(&Env{Custody: c.Custody})
approve := toolNamed(t, defs, "provisioner_retire_approve")
if _, err := approve.runAs(ControllerCaller, map[string]any{"consumers": anyOf(set[:2]), "why": "x"}); err == nil {
if _, err := approve.runAs("person.operator", map[string]any{"consumers": anyOf(set[:2]), "why": "x"}); err == nil {
t.Error("the tool took a set other than the one waiting")
}
got, err := approve.runAs(ControllerCaller, map[string]any{"consumers": anyOf(set), "why": "the apps are gone",
got, err := approve.runAs("person.operator", map[string]any{"consumers": anyOf(set), "why": "the apps are gone",
"by": "person.jochen", "via": "telegram"})
if err != nil {
t.Fatal(err)
@@ -51,7 +51,7 @@ func TestTheRetirementToolsPassTheActThrough(t *testing.T) {
last = b
}
}
if last["why"] != "the apps are gone" || last["by"] != "person.jochen" || last["via"] != "telegram, called by controller" {
if last["why"] != "the apps are gone" || last["by"] != "person.jochen" || last["via"] != "telegram, called by person.operator" {
t.Errorf("the approval said %v", last)
}
@@ -118,7 +118,7 @@ func TestTheExportsFingerprintAndVaultStatus(t *testing.T) {
if _, err := v.Put("pair/postgres/gitea", "x", "the test"); err != nil {
t.Fatal(err)
}
answer, err := toolNamed(t, definitions(&Env{Vault: v.Vault}), "secret_vault_export").Run(map[string]any{})
answer, err := toolNamed(t, definitions(&Env{Vault: v.Vault}), "secret_vault_export").runAs("person.jochen", map[string]any{})
if err != nil {
t.Fatal(err)
}
@@ -536,7 +536,7 @@ func TestTheVerbsTakeTheirCallerFromTheRuntime(t *testing.T) {
t.Fatalf("issued: %+v %v", answer, err)
}
broken := &Env{Broken: errors.New("no store")}
if _, err := toolNamed(t, definitions(broken), Seat+".rotate").runAs(ControllerCaller, map[string]any{}); err == nil ||
if _, err := toolNamed(t, definitions(broken), Seat+".rotate").runAs("person.jochen", map[string]any{"node": "g14", "module": "dunst", "name": "token"}); err == nil ||
err.Error() != "no store" {
t.Errorf("a broken vault said %v", err)
}
@@ -0,0 +1,89 @@
package main
// **Each sensitive verb judges its own caller** (novox/hq ADR 0590 §8, issue 1126). The controller judged the
// operator's account for these verbs (ADR 0272 §6, §9) and relayed who asked at its terminal; it is not placed, and
// nox and every local user reach the vault through the mesh MCP server on their machine. So each verb that approves or
// deletes a retirement, rotates a secret or exports sealed copies answers only:
//
// - the operator's account: a caller the runtime proves as `person.<name>`; or
// - a recorded hand act: any other proven caller names (`act`) the hand act it recorded through mesh-hand-acts for
// this verb, read back here by its id: recorded by that same caller, within HandActWithin, for this verb, and
// naming what it acts on when the verb acts on something named.
//
// Nothing else passes: an unproven caller, a module with no act, an act of another caller, verb or subject. A `by`
// argument is a word, never the proof (actOf keeps it beside the proven caller).
import (
"fmt"
"slices"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// PersonPrefix begins the bus user of a person's account: `person.<name>`.
const PersonPrefix = "person."
// The sensitive verbs, as a hand act names them.
const (
SensitiveRotate = "rotate"
SensitiveRetireApprove = "provisioner_retire_approve"
SensitiveDelete = "provisioner_delete"
SensitiveExport = "secret_export"
SensitiveVaultExport = "secret_vault_export"
)
// JudgeSensitive answers whether caller may do verb on about (the names it acts on, each of which a hand act must name;
// none for an export): nil for the operator's account, or for a hand act of caller's own that warrants it.
func JudgeSensitive(read ActReader, now func() time.Time, caller, verb, act string, about ...string) error {
caller = strings.TrimSpace(caller)
if caller == "" || caller == stdio.Unproven {
return fmt.Errorf("%s answers a caller the runtime proves, and it proved none (novox/hq ADR 0590 §8)", verb)
}
if strings.HasPrefix(caller, PersonPrefix) && len(caller) > len(PersonPrefix) {
return nil
}
if strings.TrimSpace(act) == "" {
return fmt.Errorf("%s answers the operator's account (person.<name>), or a caller naming the hand act it "+
"recorded for it through mesh-hand-acts (`act`); %s named none (novox/hq ADR 0590 §8)", verb, caller)
}
if read == nil {
return fmt.Errorf("%s: the vault reads no hand acts, so it answers only the operator's account", verb)
}
a, err := read(act)
if err != nil {
return fmt.Errorf("%s: the hand act %s could not be read back from mesh-hand-acts: %w", verb, act, err)
}
switch {
case a == nil:
return fmt.Errorf("%s: mesh-hand-acts holds no hand act %s: record one first", verb, act)
case a.By != caller:
return fmt.Errorf("%s: the hand act %s was recorded by %s, not by %s who asks", verb, act, a.By, caller)
case now().Sub(a.At) > HandActWithin:
return fmt.Errorf("%s: the hand act %s is older than %s: record a new one", verb, act, HandActWithin)
case a.Verb != verb && !strings.HasSuffix(a.Verb, "."+verb):
return fmt.Errorf("%s: the hand act %s is %q: it warrants no %s", verb, act, a.Verb, verb)
}
for _, name := range about {
if !slices.Contains(a.Args, name) {
return fmt.Errorf("%s: the hand act %s names %s, not %s", verb, act, strings.Join(a.Args, " "), name)
}
}
return nil
}
// judge is JudgeSensitive with the vault's reader of hand acts and its clock; with no vault ready, only the operator's
// account passes.
func (e *Env) judge(caller, verb string, a map[string]any, about ...string) error {
var read ActReader
now := time.Now
if e != nil && e.Vault != nil {
read = e.Vault.ReadAct
if e.Vault.Now != nil {
now = e.Vault.Now
}
}
act, _ := a["act"].(string)
return JudgeSensitive(read, now, caller, verb, act, about...)
}
@@ -0,0 +1,72 @@
package main
import (
"strings"
"testing"
"time"
)
// ADR 0590 §8 (issue 1126): approving or deleting a retirement, rotating a secret and exporting sealed copies each
// answer the operator's account, or a caller naming the hand act it recorded for that verb and that subject, and
// nothing else: the controller that judged for them is not placed.
func TestEachSensitiveVerbJudgesItsOwnCaller(t *testing.T) {
now := time.Date(2026, 11, 2, 12, 0, 0, 0, time.UTC)
acts := map[string]HandActSeen{
"mine": {ID: "mine", By: "novox.mesh-healers", At: now.Add(-time.Hour), Verb: "mesh-vault.rotate", Args: []string{"own/g14/app/key"}},
"old": {ID: "old", By: "novox.mesh-healers", At: now.Add(-25 * time.Hour), Verb: "rotate", Args: []string{"own/g14/app/key"}},
"other": {ID: "other", By: "novox.mesh-config", At: now.Add(-time.Hour), Verb: "rotate", Args: []string{"own/g14/app/key"}},
"del": {ID: "del", By: "novox.mesh-healers", At: now.Add(-time.Hour), Verb: "provisioner_delete", Args: []string{"d-app"}},
}
read := func(id string) (*HandActSeen, error) {
if a, ok := acts[id]; ok {
return &a, nil
}
return nil, nil
}
clock := func() time.Time { return now }
for _, row := range []struct {
name, caller, verb, act string
about []string
want string // empty: allowed
}{
{"the operator's account", "person.jochen", SensitiveRotate, "", []string{"own/g14/app/key"}, ""},
{"the operator's account exports", "person.jochen", SensitiveExport, "", nil, ""},
{"unproven", "unproven", SensitiveVaultExport, "", nil, "proved none"},
{"a bare person prefix", "person.", SensitiveExport, "", nil, "named none"},
{"a module with no act", "novox.mesh-healers", SensitiveRotate, "", []string{"own/g14/app/key"}, "named none"},
{"a module's own act", "novox.mesh-healers", SensitiveRotate, "mine", []string{"own/g14/app/key"}, ""},
{"an act of another subject", "novox.mesh-healers", SensitiveRotate, "mine", []string{"own/ace/app/key"}, "not own/ace/app/key"},
{"an act of another verb", "novox.mesh-healers", SensitiveRotate, "del", []string{"own/g14/app/key"}, "warrants no rotate"},
{"an act of another caller", "novox.mesh-healers", SensitiveRotate, "other", []string{"own/g14/app/key"}, "recorded by novox.mesh-config"},
{"an act too old", "novox.mesh-healers", SensitiveRotate, "old", []string{"own/g14/app/key"}, "older than"},
{"an act not recorded", "novox.mesh-healers", SensitiveDelete, "none", []string{"d-app"}, "holds no hand act"},
{"a delete's own act", "novox.mesh-healers", SensitiveDelete, "del", []string{"d-app"}, ""},
} {
err := JudgeSensitive(read, clock, row.caller, row.verb, row.act, row.about...)
if (row.want == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), row.want)) {
t.Errorf("%s: %v, want %q", row.name, err, row.want)
}
}
}
// The tools themselves judge: a module's call with no act is refused before anything is read or changed, and the
// operator's account goes through.
func TestTheSensitiveToolsRefuseACallerTheyDoNotJudgeTheirs(t *testing.T) {
env := &Env{Root: t.TempDir()}
defs := definitions(env)
for _, name := range []string{Seat + ".rotate", "provisioner_retire_approve", "provisioner_delete", "secret_export",
"secret_vault_export"} {
if _, err := toolNamed(t, defs, name).runAs("novox.some-module", map[string]any{"why": "x", "consumer": "c",
"confirm": "c", "consumers": []any{"c"}, "node": "g14", "module": "app", "name": "key"}); err == nil ||
!strings.Contains(err.Error(), "ADR 0590") {
t.Errorf("%s answered a module with no act: %v", name, err)
}
}
if _, err := toolNamed(t, defs, "secret_export").runAs("person.jochen", map[string]any{}); err != nil {
t.Errorf("the operator's export was refused: %v", err)
}
// Listing holders alone is no sealed copy: any proven caller reads it.
if _, err := toolNamed(t, defs, "secret_export").runAs("novox.some-module", map[string]any{"sealed": false}); err != nil {
t.Errorf("a listing without sealed copies was refused: %v", err)
}
}
+38 -15
View File
@@ -171,7 +171,7 @@ func definitions(env *Env) []toolDef {
Description: "Make a secret anew (ADR 0114, ADR 0228): node, module and name for a module's own, or provider " +
"and provision for a provision's credential. Refused for a value a provider's software issued, which " +
"the software rotates, and for an accepted credential, which the vault does not remake. Never answers " +
"the value. For a caller the runtime proves, its manifest granted the verb, and with a why.",
"the value. Answers the operator's account, or a caller naming its own hand act for this rotation (`act`; novox/hq ADR 0590 §8), with a why.",
Input: map[string]any{"type": "object", "properties": map[string]any{
"node": str("the machine, for a module's own secret"), "module": str("the module"),
"name": str("the secret's name in the module"), "provider": str("the provider's machine"),
@@ -179,14 +179,18 @@ func definitions(env *Env) []toolDef {
"consumer": str("a provision's credential of a provider whose consumers keep a login each: only " +
"this consumer's machine's own"),
"consumer_module": str("with it or alone: only this consuming module's own login"),
"by": str("who asked, as the controller names them when it carries the act"),
"why": str("why it is rotated: required")}},
"by": str("who asked, a word kept beside the proven caller"),
"why": str("why it is rotated: required"),
"act": str("the id of the hand act the caller recorded for this through mesh-hand-acts; not needed by the operator's account (person.<name>)")}},
run: func(caller string, a map[string]any) (any, error) {
v, err := env.ready()
secret, err := secretNamed(a)
if err != nil {
return nil, err
}
secret, err := secretNamed(a)
if err := env.judge(caller, SensitiveRotate, a, secret); err != nil {
return nil, err
}
v, err := env.ready()
if err != nil {
return nil, err
}
@@ -384,11 +388,18 @@ func definitions(env *Env) []toolDef {
"sealed to the operator's key (novox/hq ADR 0085, amended). Ciphertext — nothing here can open a line " +
"of it; the operator, holding the private key off the mesh, recovers one with " +
"`nox secret recover --from-export` on the control-node. Also lists what is NOT recoverable: secrets " +
"made before the mesh had an operator key.",
"made before the mesh had an operator key. The sealed copies answer the operator's account, or a caller naming its own hand act for the export (`act`; novox/hq ADR 0590 §8).",
Input: map[string]any{"sealed": map[string]any{"type": "boolean",
"description": "include the sealed blobs (default true); false lists holders and the key only"}},
run: func(_ string, a map[string]any) (any, error) {
return exportAnswer(env.Root, a["sealed"] != false), nil
"description": "include the sealed blobs (default true); false lists holders and the key only"},
"act": str("the id of the hand act the caller recorded for this through mesh-hand-acts; not needed by the operator's account (person.<name>)")},
run: func(caller string, a map[string]any) (any, error) {
sealed := a["sealed"] != false
if sealed {
if err := env.judge(caller, SensitiveExport, a); err != nil {
return nil, err
}
}
return exportAnswer(env.Root, sealed), nil
}},
{Name: "secret_holders",
Description: "Who holds a secret from this vault: each consumer's login, node and module, when it was " +
@@ -521,8 +532,13 @@ func definitions(env *Env) []toolDef {
{Name: "secret_vault_export",
Description: "Every secret the vault itself holds, as its copy sealed to the operator key, in the shape of " +
"`secret export`: saved to a file, `nox secret recover vault mesh-vault <name> --key <operator key file> " +
"--from-export <file> --out <file>` opens one with the operator's private key. Ciphertext only.",
run: func(string, map[string]any) (any, error) {
"--from-export <file> --out <file>` opens one with the operator's private key. Ciphertext only. Answers the " +
"operator's account, or a caller naming its own hand act for it (`act`; novox/hq ADR 0590 §8).",
Input: map[string]any{"act": str("the id of the hand act the caller recorded for this through mesh-hand-acts; not needed by the operator's account (person.<name>)")},
run: func(caller string, a map[string]any) (any, error) {
if err := env.judge(caller, SensitiveVaultExport, a); err != nil {
return nil, err
}
if env.Vault == nil {
return nil, errors.New("the vault is not ready")
}
@@ -539,9 +555,12 @@ func definitions(env *Env) []toolDef {
return env.Custody.Retirement(), nil
}},
{Name: "provisioner_retire_approve",
Description: "Retire exactly the set waiting for a person (or rejected earlier). Repeat the set in consumers; why is required.",
Description: "Retire exactly the set waiting for a person (or rejected earlier). Repeat the set in consumers; why is required. Answers the operator's account, or a caller naming its own hand act naming each consumer (`act`; novox/hq ADR 0590 §8).",
Input: actInput(true, false),
run: func(caller string, a map[string]any) (any, error) {
if err := env.judge(caller, SensitiveRetireApprove, a, stringsOf(a["consumers"])...); err != nil {
return nil, err
}
if env.Custody == nil {
return nil, errors.New("the vault has no custody ledger")
}
@@ -566,13 +585,17 @@ func definitions(env *Env) []toolDef {
}},
{Name: "provisioner_delete",
Description: "Delete one RETIRED holder from the custody ledger, its rotation history with it — never an active one. " +
"Repeat its name in confirm; why is required.",
"Repeat its name in confirm; why is required. Answers the operator's account, or a caller naming its own hand " +
"act naming the consumer (`act`; novox/hq ADR 0590 §8).",
Input: actInput(false, true),
run: func(caller string, a map[string]any) (any, error) {
consumer, _ := a["consumer"].(string)
if err := env.judge(caller, SensitiveDelete, a, consumer); err != nil {
return nil, err
}
if env.Custody == nil {
return nil, errors.New("the vault has no custody ledger")
}
consumer, _ := a["consumer"].(string)
confirm, _ := a["confirm"].(string)
if err := env.Custody.Delete(consumer, confirm, actOf(caller, a)); err != nil {
return nil, err
@@ -665,7 +688,7 @@ func definitions(env *Env) []toolDef {
// actInput is the arguments of an act on retirement, as the SDK's harness takes them.
func actInput(set, one bool) map[string]any {
text := map[string]any{"type": "string"}
in := map[string]any{"why": text, "by": text, "via": text}
in := map[string]any{"why": text, "by": text, "via": text, "act": text}
if set {
in["consumers"] = map[string]any{"type": "array", "items": text}
}
@@ -70,18 +70,18 @@ func TestSecretExportAnswersTheMeshsExport(t *testing.T) {
root := t.TempDir()
env := &Env{Root: root}
export := toolNamed(t, definitions(env), "secret_export")
got, _ := export.Run(map[string]any{})
got, _ := export.runAs("person.jochen", map[string]any{})
if got.(map[string]any)["available"] != false {
t.Errorf("no export said %v", got)
}
doc := `{"export":1,"operator-key":"b3A=","fingerprint":"sha256:f","kept":[{"node":"g14","module":"dunst","name":"token","kind":"own","origin":"made","sealed":"c2VhbGVk","key":"b3A=","made-at":"2026-10-01T00:00:00Z"}],"unrecoverable":[{"node":"ace","module":"x","name":"y"}]}`
_ = os.WriteFile(filepath.Join(root, "export.json"), []byte(doc), 0o600)
got, _ = export.Run(map[string]any{})
got, _ = export.runAs("person.jochen", map[string]any{})
a := got.(map[string]any)
if a["available"] != true || a["count"] != 1 || a["operator-key"] != "b3A=" {
t.Errorf("the export said %v", a)
}
got, _ = export.Run(map[string]any{"sealed": false})
got, _ = export.runAs("person.jochen", map[string]any{"sealed": false})
kept := got.(map[string]any)["kept"].([]map[string]any)
if _, ok := kept[0]["sealed"]; ok {
t.Error("sealed=false still returned the blobs")
@@ -796,7 +796,7 @@ func TestTheOperatorRecoversAVaultSecretFromItsExport(t *testing.T) {
if _, err := v.Put("pair/postgres/gitea", "recover-me", "the test"); err != nil {
t.Fatal(err)
}
answer, err := toolNamed(t, definitions(&Env{Vault: v.Vault}), "secret_vault_export").Run(map[string]any{})
answer, err := toolNamed(t, definitions(&Env{Vault: v.Vault}), "secret_vault_export").runAs("person.jochen", map[string]any{})
if err != nil {
t.Fatal(err)
}