Bind trusted task approval to exact producer facts and lifetime
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
This commit is contained in:
parent
4f6946bbb3
commit
2b064eee51
31 changed files with 2170 additions and 30 deletions
55
third_party/approvalfact/README.md
vendored
Normal file
55
third_party/approvalfact/README.md
vendored
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# Approval fact data adapter
|
||||
|
||||
Provider-owned pure Go module for Access's Approval B2 source reader. No network,
|
||||
credentials, subprocess, policy evaluator, server or consume mutation exists here.
|
||||
Python `approval_engine.binding` remains the authoritative native format.
|
||||
|
||||
API:
|
||||
|
||||
- `CanonicalBinding(rawJSON)` returns native five-field canonical UTF-8 bytes
|
||||
and sha256 digest; extra binding metadata is excluded.
|
||||
- `ValidateClaim(rawClaim, Expected, now)` validates one exact human/PDP-path
|
||||
claim against independently owner-derived `Expected.NativeBindingJSON`, exact
|
||||
approval ID and actual Access approval-free PDP digest.
|
||||
- `MatchObserved(submitted, observed)` requires two validated facts at the same
|
||||
captured instant and identical nonfreshness authority facts. No request is
|
||||
rewritten. `Fact.RawJSON()` returns a detached original byte copy; deadlines
|
||||
preserve each input's own freshness and approval expiry.
|
||||
|
||||
`Fact` means validated data, never authenticated provider origin. Access must
|
||||
fetch provider bytes through its independently admitted fixed origin/identity,
|
||||
then wrap this data in its own unforgeable source handle. A caller-supplied claim
|
||||
cannot become source truth merely by passing this helper. Access remains the
|
||||
single evaluator and must bound decision/operation/caller lifetimes separately.
|
||||
PEP consume CAS and live one-shot journal remain required before any effect.
|
||||
|
||||
Expected native act bytes must preserve the actual owner/compiler/normalization
|
||||
representation. Marshaling float64-decoded CheckRequest maps loses 1.0/1 and
|
||||
large integers. Raw submitted resource is sufficient only when Access proves
|
||||
the active registry cannot override that target and applies its exact existing
|
||||
normalization. Otherwise authoritative raw enrichment/compiler evidence is
|
||||
needed. This helper supplies no fallback or invented restricted numeric domain.
|
||||
|
||||
The strict parser retains integer/floating lexical types, arbitrary integer
|
||||
magnitudes, finite IEEE-754 float semantics and valid Unicode scalar values.
|
||||
Canonical float/string formatting follows Python source, including negative
|
||||
floating zero, exponent switching/padding, HTML and U+2028/U+2029. Duplicate
|
||||
object keys, nonfinite/overflow numbers, invalid UTF-8 and lone surrogates refuse.
|
||||
Inputs are bounded to 1 MiB and 128 container levels. Timestamp precision is at
|
||||
most nanoseconds with strict zoned RFC3339; current producer emits whole seconds.
|
||||
These explicit parser/resource limits fail closed, never normalize authority.
|
||||
|
||||
`go test -race ./...` checks the portable producer vectors, typed fact pairing,
|
||||
strict JSON/Unicode and exact deadline semantics. From the owner repo root,
|
||||
`python3 scripts/check_go_approvalfact.py` runs a deterministic 14,114-case
|
||||
development-only differential corpus against the actual Python producer,
|
||||
including random finite IEEE bit patterns, exponent boundaries/subnormals and
|
||||
Unicode. It uses a test-only local oracle file; production imports no oracle or
|
||||
Python runtime. Sample conformance is evidence, not a formal proof over all JSON.
|
||||
|
||||
The module pins Go 1.26.7 to match the existing Access consumer toolchain.
|
||||
`testdata/owner-vectors.json` is an unchanged portable copy of the Approval owner
|
||||
fixture, SHA256 `dfb0a8810cd91847d7af6a7af6a51607a67a66fad7b86fc57365308c36bd9323`.
|
||||
Transport cases requiring HTTP remain the Access adapter's tests; this module
|
||||
checks their duplicate/nonfinite bodies only. Import/deployment/native identity
|
||||
admission and positive policy gates require separate owner evidence.
|
||||
13
third_party/approvalfact/SOURCE.json
vendored
Normal file
13
third_party/approvalfact/SOURCE.json
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"owner": "approval-engine",
|
||||
"module": "github.com/netkingdom/approval-engine/adapters/go/approvalfact",
|
||||
"source_artifact": "approval-go-fact/manifest.json",
|
||||
"review_status": "owner-frozen; independent coordinator review required",
|
||||
"role": "pure data canonicalization and fact correspondence only; not provider authentication or PDP",
|
||||
"files_sha256": {
|
||||
"canonical.go": "d48588fb26e9037c8e83421586b8a818f4c0a52afb874f3fadf7229fc971c282",
|
||||
"claim.go": "007a2494f79c47c104635402407e9e68f17d8599d23e0f2c8ebf6cb1b47e3621",
|
||||
"go.mod": "bb09c53ffea926fbfba6e580ed3bd71ca358575401717389b3c7c49c0c674b43",
|
||||
"README.md": "5d936b7f3de87bbf4e946f0dc44823d2f69b62b4122b55b9064791b7a19fd4f5"
|
||||
}
|
||||
}
|
||||
295
third_party/approvalfact/canonical.go
vendored
Normal file
295
third_party/approvalfact/canonical.go
vendored
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
// Package approvalfact provides data-only Approval claim correspondence.
|
||||
// It neither authenticates a provider nor decides permission or consumes objects.
|
||||
package approvalfact
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const MaxJSONBytes = 1 << 20
|
||||
|
||||
var errJSON = errors.New("invalid Approval JSON")
|
||||
|
||||
// DecodeJSON retains numeric lexical types, refuses duplicates and invalid
|
||||
// Unicode, and bounds work. A Go map previously decoded into float64 cannot
|
||||
// recover the producer's integer-versus-floating identity; pass source bytes.
|
||||
func DecodeJSON(raw []byte) (any, error) {
|
||||
if len(raw) > MaxJSONBytes || !utf8.Valid(raw) || !validSurrogates(raw) {
|
||||
return nil, errJSON
|
||||
}
|
||||
d := json.NewDecoder(bytes.NewReader(raw))
|
||||
d.UseNumber()
|
||||
var read func(int) (any, error)
|
||||
read = func(depth int) (any, error) {
|
||||
if depth > 128 {
|
||||
return nil, errJSON
|
||||
}
|
||||
t, e := d.Token()
|
||||
if e != nil {
|
||||
return nil, errJSON
|
||||
}
|
||||
switch x := t.(type) {
|
||||
case json.Delim:
|
||||
if x == '{' {
|
||||
m := map[string]any{}
|
||||
for d.More() {
|
||||
k, e := d.Token()
|
||||
if e != nil {
|
||||
return nil, errJSON
|
||||
}
|
||||
s, ok := k.(string)
|
||||
if !ok {
|
||||
return nil, errJSON
|
||||
}
|
||||
if _, ok = m[s]; ok {
|
||||
return nil, errJSON
|
||||
}
|
||||
v, e := read(depth + 1)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
m[s] = v
|
||||
}
|
||||
end, e := d.Token()
|
||||
if e != nil || end != json.Delim('}') {
|
||||
return nil, errJSON
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if x == '[' {
|
||||
a := []any{}
|
||||
for d.More() {
|
||||
v, e := read(depth + 1)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
a = append(a, v)
|
||||
}
|
||||
end, e := d.Token()
|
||||
if e != nil || end != json.Delim(']') {
|
||||
return nil, errJSON
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
return nil, errJSON
|
||||
case json.Number:
|
||||
if _, e := pythonNumber(x); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return x, nil
|
||||
case string, bool, nil:
|
||||
return x, nil
|
||||
default:
|
||||
return nil, errJSON
|
||||
}
|
||||
}
|
||||
v, e := read(0)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if _, e = d.Token(); e != io.EOF {
|
||||
return nil, errJSON
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// encoding/json replaces lone UTF-16 surrogates, whereas the Python producer
|
||||
// cannot encode them as UTF-8. Reject before the decoder can erase that fact.
|
||||
func validSurrogates(raw []byte) bool {
|
||||
in := false
|
||||
for i := 0; i < len(raw); i++ {
|
||||
if raw[i] == '"' {
|
||||
in = !in
|
||||
continue
|
||||
}
|
||||
if !in || raw[i] != '\\' {
|
||||
continue
|
||||
}
|
||||
i++
|
||||
if i >= len(raw) {
|
||||
return false
|
||||
}
|
||||
if raw[i] != 'u' {
|
||||
continue
|
||||
}
|
||||
if i+4 >= len(raw) {
|
||||
return false
|
||||
}
|
||||
n, e := strconv.ParseUint(string(raw[i+1:i+5]), 16, 16)
|
||||
if e != nil {
|
||||
return false
|
||||
}
|
||||
i += 4
|
||||
if n >= 0xdc00 && n <= 0xdfff {
|
||||
return false
|
||||
}
|
||||
if n < 0xd800 || n > 0xdbff {
|
||||
continue
|
||||
}
|
||||
if i+6 >= len(raw) || raw[i+1] != '\\' || raw[i+2] != 'u' {
|
||||
return false
|
||||
}
|
||||
low, e := strconv.ParseUint(string(raw[i+3:i+7]), 16, 16)
|
||||
if e != nil || low < 0xdc00 || low > 0xdfff {
|
||||
return false
|
||||
}
|
||||
i += 6
|
||||
}
|
||||
return !in
|
||||
}
|
||||
|
||||
func pythonNumber(n json.Number) (string, error) {
|
||||
s := string(n)
|
||||
if !strings.ContainsAny(s, ".eE") {
|
||||
i, ok := new(big.Int).SetString(s, 10)
|
||||
if !ok {
|
||||
return "", errJSON
|
||||
}
|
||||
return i.String(), nil
|
||||
}
|
||||
f, e := strconv.ParseFloat(s, 64)
|
||||
if e != nil || math.IsInf(f, 0) || math.IsNaN(f) {
|
||||
return "", errJSON
|
||||
}
|
||||
// Python repr uses shortest round-trip digits, fixed form for exponents
|
||||
// -4..15, a mandatory .0 for integral floats, and two exponent digits.
|
||||
sci := strconv.FormatFloat(f, 'e', -1, 64)
|
||||
at := strings.IndexByte(sci, 'e')
|
||||
exp, _ := strconv.Atoi(sci[at+1:])
|
||||
if exp >= -4 && exp < 16 {
|
||||
s = strconv.FormatFloat(f, 'f', -1, 64)
|
||||
if !strings.Contains(s, ".") {
|
||||
s += ".0"
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
sign := "+"
|
||||
if exp < 0 {
|
||||
sign = "-"
|
||||
exp = -exp
|
||||
}
|
||||
digits := strconv.Itoa(exp)
|
||||
if len(digits) < 2 {
|
||||
digits = "0" + digits
|
||||
}
|
||||
return sci[:at] + "e" + sign + digits, nil
|
||||
}
|
||||
|
||||
func quote(s string) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('"')
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '"', '\\':
|
||||
b.WriteByte('\\')
|
||||
b.WriteRune(r)
|
||||
case '\b':
|
||||
b.WriteString(`\b`)
|
||||
case '\f':
|
||||
b.WriteString(`\f`)
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
case '\r':
|
||||
b.WriteString(`\r`)
|
||||
case '\t':
|
||||
b.WriteString(`\t`)
|
||||
default:
|
||||
if r < 32 {
|
||||
b.WriteString(`\u00`)
|
||||
const h = "0123456789abcdef"
|
||||
b.WriteByte(h[byte(r)>>4])
|
||||
b.WriteByte(h[byte(r)&15])
|
||||
} else {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
b.WriteByte('"')
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func canonical(v any) (string, error) {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return "null", nil
|
||||
case bool:
|
||||
if x {
|
||||
return "true", nil
|
||||
}
|
||||
return "false", nil
|
||||
case string:
|
||||
return quote(x), nil
|
||||
case json.Number:
|
||||
return pythonNumber(x)
|
||||
case []any:
|
||||
a := make([]string, len(x))
|
||||
for i, v := range x {
|
||||
s, e := canonical(v)
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
a[i] = s
|
||||
}
|
||||
return "[" + strings.Join(a, ",") + "]", nil
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(x))
|
||||
for k := range x {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
a := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
s, e := canonical(x[k])
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
a = append(a, quote(k)+":"+s)
|
||||
}
|
||||
return "{" + strings.Join(a, ",") + "}", nil
|
||||
default:
|
||||
return "", errJSON
|
||||
}
|
||||
}
|
||||
|
||||
// CanonicalBinding reproduces approval_engine.binding.binding_digest over the
|
||||
// five native act fields. Extra binding metadata is excluded by that contract.
|
||||
func CanonicalBinding(raw []byte) ([]byte, string, error) {
|
||||
v, e := DecodeJSON(raw)
|
||||
if e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return nil, "", errJSON
|
||||
}
|
||||
act := map[string]any{}
|
||||
for _, k := range []string{"action", "actor", "principal", "purpose"} {
|
||||
s, ok := m[k].(string)
|
||||
if !ok || s == "" {
|
||||
return nil, "", errJSON
|
||||
}
|
||||
act[k] = s
|
||||
}
|
||||
target, ok := m["target"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, "", errJSON
|
||||
}
|
||||
act["target"] = target
|
||||
s, e := canonical(act)
|
||||
if e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return []byte(s), "sha256:" + hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
170
third_party/approvalfact/claim.go
vendored
Normal file
170
third_party/approvalfact/claim.go
vendored
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
package approvalfact
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
var errClaim = errors.New("Approval fact does not match required claim")
|
||||
var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
|
||||
var uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
||||
var timePattern = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,9})?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$`)
|
||||
|
||||
// Expected contains independently owner-derived native act bytes and the actual
|
||||
// Access approval-free digest. Marshaling an already float64-decoded Go request
|
||||
// is not an authoritative reconstruction of the native act.
|
||||
type Expected struct {
|
||||
ApprovalID string
|
||||
NativeBindingJSON []byte
|
||||
PDPDigest string
|
||||
}
|
||||
|
||||
// Fact is validated data shape/correspondence, NOT authenticated provider origin
|
||||
// or execution authority. Access must wrap it only after its own source admission.
|
||||
type Fact struct {
|
||||
raw []byte
|
||||
stable string
|
||||
digest string
|
||||
observed, timeLimit, validatedAt time.Time
|
||||
}
|
||||
|
||||
func (f Fact) RawJSON() []byte { return append([]byte(nil), f.raw...) }
|
||||
func (f Fact) ObservedAt() time.Time { return f.observed }
|
||||
func (f Fact) Deadline() time.Time { return f.timeLimit }
|
||||
func (f Fact) NativeDigest() string { return f.digest }
|
||||
|
||||
func members(m map[string]any, required, optional []string) bool {
|
||||
allowed := map[string]bool{}
|
||||
for _, k := range required {
|
||||
allowed[k] = true
|
||||
if _, ok := m[k]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, k := range optional {
|
||||
allowed[k] = true
|
||||
}
|
||||
for k := range m {
|
||||
if !allowed[k] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func object(v any) (map[string]any, bool) { m, ok := v.(map[string]any); return m, ok }
|
||||
func stamp(v any) (time.Time, error) {
|
||||
s, ok := v.(string)
|
||||
if !ok || !timePattern.MatchString(s) {
|
||||
return time.Time{}, errClaim
|
||||
}
|
||||
t, e := time.Parse(time.RFC3339Nano, s)
|
||||
if e != nil {
|
||||
return time.Time{}, errClaim
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// ValidateClaim checks one original or fetched schema-0.1 human/PDP-path claim
|
||||
// at a caller-captured instant, using the existing Approval producer semantics.
|
||||
// It has no I/O, policy evaluator, credentials, cache, or consume mutation.
|
||||
func ValidateClaim(raw []byte, expected Expected, now time.Time) (Fact, error) {
|
||||
if now.IsZero() || !uuidPattern.MatchString(expected.ApprovalID) || !digestPattern.MatchString(expected.PDPDigest) {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
_, want, e := CanonicalBinding(expected.NativeBindingJSON)
|
||||
if e != nil {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
v, e := DecodeJSON(raw)
|
||||
if e != nil {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
m, ok := object(v)
|
||||
if !ok || !members(m, []string{"schema_version", "kind", "issuer", "approval_id", "state", "valid_now", "consumed", "binding", "freshness", "validity", "reason_code"}, []string{"yields_to"}) {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
if y, ok := m["yields_to"]; ok {
|
||||
if _, ok = y.(string); !ok {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
}
|
||||
if m["schema_version"] != "0.1" || m["kind"] != "approval-claim" || m["issuer"] != "approval-engine" || m["approval_id"] != expected.ApprovalID || m["state"] != "valid" || m["valid_now"] != true || m["consumed"] != false || m["reason_code"] != "ok" {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
b, ok := object(m["binding"])
|
||||
if !ok || !members(b, []string{"action", "actor", "principal", "purpose", "target", "digest", "pdp_digest", "pdp_path", "human_control"}, nil) {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
if b["digest"] != want || b["pdp_digest"] != expected.PDPDigest || b["pdp_path"] != true || b["human_control"] != true {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
text, e := canonical(b)
|
||||
if e != nil {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
_, got, e := CanonicalBinding([]byte(text))
|
||||
if e != nil || got != want {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
f, ok := object(m["freshness"])
|
||||
if !ok || !members(f, []string{"observed_at", "ttl_seconds", "not_after"}, nil) {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
observed, e := stamp(f["observed_at"])
|
||||
if e != nil {
|
||||
return Fact{}, e
|
||||
}
|
||||
notAfter, e := stamp(f["not_after"])
|
||||
if e != nil {
|
||||
return Fact{}, e
|
||||
}
|
||||
ttl, ok := f["ttl_seconds"].(json.Number)
|
||||
if !ok {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
seconds, e := strconv.ParseInt(string(ttl), 10, 64)
|
||||
if e != nil || seconds < 1 {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
// Bound arithmetic to time.Duration's representable seconds, not a new
|
||||
// semantic freshness grant or configured TTL. Unsupported extremes refuse.
|
||||
if seconds > int64((1<<63-1)/int64(time.Second)) || !observed.Add(time.Duration(seconds)*time.Second).Equal(notAfter) || now.Before(observed) || !now.Before(notAfter) {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
validity, ok := object(m["validity"])
|
||||
if !ok || !members(validity, []string{"not_before", "expires_at"}, nil) {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
before, e := stamp(validity["not_before"])
|
||||
if e != nil {
|
||||
return Fact{}, e
|
||||
}
|
||||
expires, e := stamp(validity["expires_at"])
|
||||
if e != nil || now.Before(before) || !now.Before(expires) {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
limit := notAfter
|
||||
if expires.Before(limit) {
|
||||
limit = expires
|
||||
}
|
||||
delete(m, "freshness")
|
||||
delete(m, "yields_to")
|
||||
stable, e := canonical(m)
|
||||
if e != nil {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
return Fact{raw: append([]byte(nil), raw...), stable: stable, digest: want, observed: observed, timeLimit: limit, validatedAt: now}, nil
|
||||
}
|
||||
|
||||
// MatchObserved requires independently validated original/fetched facts at the
|
||||
// same captured instant and exact nonfreshness authority correspondence. It
|
||||
// never rewrites the original request or extends its original deadline.
|
||||
func MatchObserved(submitted, observed Fact) error {
|
||||
if submitted.stable == "" || observed.stable == "" || !submitted.validatedAt.Equal(observed.validatedAt) || submitted.stable != observed.stable {
|
||||
return errClaim
|
||||
}
|
||||
return nil
|
||||
}
|
||||
3
third_party/approvalfact/go.mod
vendored
Normal file
3
third_party/approvalfact/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module github.com/netkingdom/approval-engine/adapters/go/approvalfact
|
||||
|
||||
go 1.26.7
|
||||
Loading…
Add table
Add a link
Reference in a new issue