The gate digests a marked call's act (address, node, module and tool or seat and verb, build, each argument as itself or its canonical JSON), refuses at once and asks nothing for a secret's shape or more than 16 KiB of arguments, and otherwise asks through operator-channel's client under the marked module's name, its ask kept in the runner's state approvals under the machine's keys with the act and the whole arguments, the act's key naming it. The ask shows each argument whole only where it may leave the mesh, and the first call answers Not run, with the router's record or without it within 5 seconds. Builds: design 46 7-approval-mark §8, §9, §10, §11, §12, §13 (keys) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
292 lines
8.0 KiB
Go
292 lines
8.0 KiB
Go
package approval
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"unicode/utf8"
|
||
|
||
"git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||
"git.novox.be/novox/mesh-sdk/go/outward"
|
||
)
|
||
|
||
// Call is one call the runner serves, as the gate judges it: a module's tool, or a seat verb its module serves.
|
||
type Call struct {
|
||
// Address is the address as called: `<module>.<tool>` or `<seat>.<verb>`.
|
||
Address string
|
||
// Module is the module whose bundle serves it; Seat the seat of a verb, empty for a tool; Name the tool or the verb.
|
||
Module, Seat, Name string
|
||
// Fingerprint is the build the bundle serves, from its composed environment (§5).
|
||
Fingerprint string
|
||
// Caller is the caller the bus proved.
|
||
Caller string
|
||
// Args are the call's arguments, a JSON object.
|
||
Args json.RawMessage
|
||
// Description is the tool's or verb's own description, whose first line the ask explains it by.
|
||
Description string
|
||
}
|
||
|
||
// MostArguments is the most a marked call's arguments may hold: more is refused, asking nothing (§9).
|
||
const MostArguments = 16 * 1024
|
||
|
||
// arguments are a call's arguments by name; a body that is not an object is refused.
|
||
func arguments(raw json.RawMessage) (map[string]json.RawMessage, error) {
|
||
args := map[string]json.RawMessage{}
|
||
if len(bytes.TrimSpace(raw)) == 0 || string(bytes.TrimSpace(raw)) == "null" {
|
||
return args, nil
|
||
}
|
||
if err := json.Unmarshal(raw, &args); err != nil {
|
||
return nil, fmt.Errorf("its arguments are not a JSON object: %v", err)
|
||
}
|
||
return args, nil
|
||
}
|
||
|
||
// refusedUnasked says why a marked call is refused at once, asking nothing (§9): its arguments hold a secret's shape, or are
|
||
// more than MostArguments.
|
||
func refusedUnasked(raw json.RawMessage) string {
|
||
if len(raw) > MostArguments {
|
||
return fmt.Sprintf("its arguments are %d bytes, more than the %d a call that needs the operator's approval may carry", len(raw), MostArguments)
|
||
}
|
||
if r, ok := outward.CheckSecret(string(raw)); !ok {
|
||
return fmt.Sprintf("its arguments hold %s, which no ask may carry", r.What)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// ActOf is the act a marked call binds (§8): `address`, `node`, `module` and `tool` or `seat` and `verb`, `build`, and
|
||
// `arg.<name>` for each argument given, a string as itself and any other value as its JSON in RFC 8785's canonical form.
|
||
func ActOf(c Call, node string) (operatorchannelclient.Act, error) {
|
||
act := operatorchannelclient.Act{"address": c.Address, "node": node, "build": c.Fingerprint}
|
||
if c.Seat == "" {
|
||
act["module"], act["tool"] = c.Module, c.Name
|
||
} else {
|
||
act["seat"], act["verb"] = c.Seat, c.Name
|
||
}
|
||
args, err := arguments(c.Args)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for name, raw := range args {
|
||
var s string
|
||
if json.Unmarshal(raw, &s) == nil {
|
||
act["arg."+name] = s
|
||
continue
|
||
}
|
||
canon, err := Canonical(raw)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("the argument %s cannot be put in canonical form: %v", name, err)
|
||
}
|
||
act["arg."+name] = canon
|
||
}
|
||
return act, nil
|
||
}
|
||
|
||
// Canonical is a JSON value in RFC 8785's canonical form: no white space, an object's members sorted by their names' UTF-16
|
||
// code units, a number as ECMAScript writes a double, a string with only the escapes the RFC names.
|
||
func Canonical(raw json.RawMessage) (string, error) {
|
||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||
dec.UseNumber()
|
||
var v any
|
||
if err := dec.Decode(&v); err != nil {
|
||
return "", err
|
||
}
|
||
var b strings.Builder
|
||
if err := canonicalValue(&b, v); err != nil {
|
||
return "", err
|
||
}
|
||
return b.String(), nil
|
||
}
|
||
|
||
func canonicalValue(b *strings.Builder, v any) error {
|
||
switch x := v.(type) {
|
||
case nil:
|
||
b.WriteString("null")
|
||
case bool:
|
||
b.WriteString(strconv.FormatBool(x))
|
||
case json.Number:
|
||
f, err := strconv.ParseFloat(string(x), 64)
|
||
if err != nil || math.IsInf(f, 0) || math.IsNaN(f) {
|
||
return fmt.Errorf("the number %s is not a double", x)
|
||
}
|
||
b.WriteString(es6Number(f))
|
||
case string:
|
||
canonicalString(b, x)
|
||
case []any:
|
||
b.WriteByte('[')
|
||
for i, e := range x {
|
||
if i > 0 {
|
||
b.WriteByte(',')
|
||
}
|
||
if err := canonicalValue(b, e); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
b.WriteByte(']')
|
||
case map[string]any:
|
||
names := make([]string, 0, len(x))
|
||
for k := range x {
|
||
names = append(names, k)
|
||
}
|
||
sort.Slice(names, func(i, j int) bool { return utf16Less(names[i], names[j]) })
|
||
b.WriteByte('{')
|
||
for i, k := range names {
|
||
if i > 0 {
|
||
b.WriteByte(',')
|
||
}
|
||
canonicalString(b, k)
|
||
b.WriteByte(':')
|
||
if err := canonicalValue(b, x[k]); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
b.WriteByte('}')
|
||
default:
|
||
return fmt.Errorf("a value of type %T", v)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// es6Number is a double as ECMAScript's Number.prototype.toString writes it (RFC 8785 §3.2.2.3).
|
||
func es6Number(f float64) string {
|
||
if f == 0 {
|
||
return "0"
|
||
}
|
||
abs := math.Abs(f)
|
||
if abs >= 1e-6 && abs < 1e21 {
|
||
return strconv.FormatFloat(f, 'f', -1, 64)
|
||
}
|
||
s := strconv.FormatFloat(f, 'e', -1, 64) // 1e+21, 1.5e-07
|
||
mant, exp, _ := strings.Cut(s, "e")
|
||
sign, digits := exp[:1], strings.TrimLeft(exp[1:], "0")
|
||
return mant + "e" + sign + digits
|
||
}
|
||
|
||
func canonicalString(b *strings.Builder, s string) {
|
||
b.WriteByte('"')
|
||
for _, r := range s {
|
||
switch {
|
||
case r == '"':
|
||
b.WriteString(`\"`)
|
||
case r == '\\':
|
||
b.WriteString(`\\`)
|
||
case r == '\b':
|
||
b.WriteString(`\b`)
|
||
case r == '\f':
|
||
b.WriteString(`\f`)
|
||
case r == '\n':
|
||
b.WriteString(`\n`)
|
||
case r == '\r':
|
||
b.WriteString(`\r`)
|
||
case r == '\t':
|
||
b.WriteString(`\t`)
|
||
case r < 0x20:
|
||
fmt.Fprintf(b, `\u%04x`, r)
|
||
default:
|
||
b.WriteRune(r)
|
||
}
|
||
}
|
||
b.WriteByte('"')
|
||
}
|
||
|
||
func utf16Less(a, b string) bool {
|
||
ua, ub := utf16Of(a), utf16Of(b)
|
||
for i := 0; i < len(ua) && i < len(ub); i++ {
|
||
if ua[i] != ub[i] {
|
||
return ua[i] < ub[i]
|
||
}
|
||
}
|
||
return len(ua) < len(ub)
|
||
}
|
||
|
||
func utf16Of(s string) []uint16 {
|
||
out := make([]uint16, 0, utf8.RuneCountInString(s))
|
||
for _, r := range s {
|
||
if r >= 0x10000 {
|
||
r -= 0x10000
|
||
out = append(out, uint16(0xD800+(r>>10)), uint16(0xDC00+(r&0x3FF)))
|
||
} else {
|
||
out = append(out, uint16(r))
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Fingerprint is how an act is shown: its digest's first 24 hexadecimal digits in groups of four (§10).
|
||
func Fingerprint(digest string) string {
|
||
hex := strings.TrimPrefix(digest, "sha256:")
|
||
if len(hex) > 24 {
|
||
hex = hex[:24]
|
||
}
|
||
var groups []string
|
||
for len(hex) > 4 {
|
||
groups, hex = append(groups, hex[:4]), hex[4:]
|
||
}
|
||
return strings.Join(append(groups, hex), " ")
|
||
}
|
||
|
||
// The bounds of the arguments shown in an ask (§11).
|
||
const (
|
||
ShownBytes, ShownLines = 2000, 40
|
||
PartBytes, PartLines = 1200, 20
|
||
)
|
||
|
||
// shown is a call's arguments as an ask shows them (§11), one per line as "name: value", and whether all were shown whole:
|
||
// an argument whole where outward.Check passes it, else its shape, ‹path›, ‹address› or ‹withheld›; the whole when it is at
|
||
// most ShownBytes and ShownLines, else its first PartLines lines and PartBytes bytes, marked NOT SHOWN IN FULL.
|
||
func shown(raw json.RawMessage) (string, bool) {
|
||
args, _ := arguments(raw)
|
||
names := make([]string, 0, len(args))
|
||
for n := range args {
|
||
names = append(names, n)
|
||
}
|
||
sort.Strings(names)
|
||
whole := true
|
||
var lines []string
|
||
for _, n := range names {
|
||
var value string
|
||
if json.Unmarshal(args[n], &value) != nil {
|
||
value = string(args[n])
|
||
}
|
||
if r, ok := outward.Check(value); !ok {
|
||
value, whole = shapeOf(r), false
|
||
}
|
||
lines = append(lines, n+": "+value)
|
||
}
|
||
text := strings.Join(lines, "\n")
|
||
if all := strings.Split(text, "\n"); len(text) > ShownBytes || len(all) > ShownLines {
|
||
if len(all) > PartLines {
|
||
all = all[:PartLines]
|
||
}
|
||
text = strings.Join(all, "\n")
|
||
if len(text) > PartBytes {
|
||
text = cutBytes(text, PartBytes)
|
||
}
|
||
text += "\nNOT SHOWN IN FULL"
|
||
whole = false
|
||
}
|
||
return text, whole
|
||
}
|
||
|
||
// shapeOf is what an argument outward.Check refuses is shown as.
|
||
func shapeOf(r outward.Refusal) string {
|
||
switch r.Class {
|
||
case "path":
|
||
return "‹path›"
|
||
case "address":
|
||
return "‹address›"
|
||
}
|
||
return "‹withheld›"
|
||
}
|
||
|
||
// cutBytes is s at most n bytes, cut at a character's end.
|
||
func cutBytes(s string, n int) string {
|
||
for n > 0 && !utf8.RuneStart(s[n]) {
|
||
n--
|
||
}
|
||
return s[:n]
|
||
}
|